@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
@@ -13,7 +13,6 @@ import type {
13
13
  UploadEvent,
14
14
  } from './types';
15
15
  import { useFileUpload } from './useFileUpload';
16
- import { defaultEndpoints } from './types';
17
16
 
18
17
  function buildHttpClient(
19
18
  baseUrl: string,
@@ -177,34 +176,14 @@ export function createDocumentUploadProvider(
177
176
  };
178
177
 
179
178
  const httpClient = buildHttpClient(baseUrl, defaultHeaders);
180
- const endpoints = defaultEndpoints;
181
179
 
182
- // ---- Search store resolution (one per user) ----
180
+ // ---- Search store resolution (one per conversation) ----
183
181
 
184
- let resolvedStoreId: string | null = null;
185
- let resolvePromise: Promise<string> | null = null;
186
- const resolvedWorkflowIdByStore = new Map<string, string>();
182
+ const storeIdByConversation = new Map<string, string>();
183
+ const resolvePromises = new Map<string, Promise<string>>();
187
184
 
188
- async function createWorkflowForStore(storeId: string): Promise<string> {
189
- const workflowRes = await httpClient.post<{ id: string }>(
190
- endpoints.createWorkflow(storeId),
191
- workflowConfig,
192
- );
193
- return workflowRes.id;
194
- }
195
-
196
- async function createSearchStoreWithWorkflow(): Promise<string> {
197
- const name = `store-${new Date().toISOString().replace(/[:.]/g, '-')}`;
198
- const storeRes = await httpClient.post<{ id: string }>('search-stores', {
199
- name,
200
- ...searchStoreConfig,
201
- });
202
- const newStoreId = storeRes.id;
203
-
204
- const workflowApiId = await createWorkflowForStore(newStoreId);
205
- resolvedWorkflowIdByStore.set(newStoreId, workflowApiId);
206
-
207
- return newStoreId;
185
+ function searchStoreNameForConversation(conversationId: string): string {
186
+ return conversationId;
208
187
  }
209
188
 
210
189
  interface SearchStoreListResponse {
@@ -217,63 +196,109 @@ export function createDocumentUploadProvider(
217
196
  }
218
197
 
219
198
  interface WorkflowListResponse {
220
- items: Array<{
221
- id: string;
222
- name: string;
223
- created_at: string;
224
- }>;
225
- total: number;
199
+ items: Array<{ id: string; name?: string }>;
226
200
  }
227
201
 
228
- async function fetchOrCreateWorkflow(storeId: string): Promise<string> {
229
- const cached = resolvedWorkflowIdByStore.get(storeId);
230
- if (cached) return cached;
202
+ function findConfiguredWorkflow(
203
+ items: WorkflowListResponse['items'],
204
+ ): { id: string } | undefined {
205
+ const configuredWorkflowName =
206
+ typeof workflowConfig.name === 'string' ? workflowConfig.name : undefined;
207
+
208
+ return items?.find(
209
+ (workflow) =>
210
+ workflow.id === workflowId ||
211
+ workflow.name === workflowId ||
212
+ (configuredWorkflowName != null &&
213
+ workflow.name === configuredWorkflowName),
214
+ );
215
+ }
231
216
 
217
+ async function resolveWorkflowId(storeId: string): Promise<string> {
232
218
  const response = await httpClient.get<WorkflowListResponse>(
233
- endpoints.workflows(storeId),
219
+ `search-stores/${storeId}/workflows`,
234
220
  );
221
+ const existing = findConfiguredWorkflow(response.items);
235
222
 
236
- if (response.items.length > 0) {
237
- const match = response.items.find((item) => item.name === workflowId);
238
- const selected = match ?? response.items[0]!;
239
- resolvedWorkflowIdByStore.set(storeId, selected.id);
240
- return selected.id;
223
+ if (existing) {
224
+ return existing.id;
241
225
  }
242
226
 
243
- const workflowApiId = await createWorkflowForStore(storeId);
244
- resolvedWorkflowIdByStore.set(storeId, workflowApiId);
245
- return workflowApiId;
227
+ const created = await httpClient.post<{ id: string }>(
228
+ `search-stores/${storeId}/workflows`,
229
+ workflowConfig,
230
+ );
231
+ return created.id;
246
232
  }
247
233
 
248
- async function fetchOrCreateStore(): Promise<string> {
249
- try {
250
- const response =
251
- await httpClient.get<SearchStoreListResponse>('search-stores');
234
+ async function createSearchStore(conversationId: string): Promise<string> {
235
+ const storeRes = await httpClient.post<{ id: string }>('search-stores', {
236
+ name: searchStoreNameForConversation(conversationId),
237
+ ...searchStoreConfig,
238
+ });
252
239
 
253
- const expectedModel = searchStoreConfig.models[0]?.embedding_model;
254
- const match = response.items.find((store) =>
255
- store.models.some((m) => m.embedding_model === expectedModel),
256
- );
240
+ await httpClient.post(
241
+ `search-stores/${storeRes.id}/workflows`,
242
+ workflowConfig,
243
+ );
257
244
 
258
- if (match) {
259
- resolvedStoreId = match.id;
260
- return resolvedStoreId;
261
- }
245
+ return storeRes.id;
246
+ }
262
247
 
263
- resolvedStoreId = await createSearchStoreWithWorkflow();
264
- return resolvedStoreId;
265
- } catch (err) {
266
- resolvePromise = null;
267
- throw err;
248
+ function findStoreForConversation(
249
+ items: SearchStoreListResponse['items'],
250
+ conversationId: string,
251
+ ) {
252
+ const expectedName = searchStoreNameForConversation(conversationId);
253
+ const expectedModel = searchStoreConfig.models[0]?.embedding_model;
254
+
255
+ return items.find(
256
+ (store) =>
257
+ store.name === expectedName &&
258
+ (!expectedModel ||
259
+ store.models.some((m) => m.embedding_model === expectedModel)),
260
+ );
261
+ }
262
+
263
+ async function fetchOrCreateStore(conversationId: string): Promise<string> {
264
+ const response =
265
+ await httpClient.get<SearchStoreListResponse>('search-stores');
266
+
267
+ const existing = findStoreForConversation(response.items, conversationId);
268
+
269
+ if (existing) {
270
+ await resolveWorkflowId(existing.id);
271
+ storeIdByConversation.set(conversationId, existing.id);
272
+ return existing.id;
268
273
  }
274
+
275
+ const newStoreId = await createSearchStore(conversationId);
276
+ storeIdByConversation.set(conversationId, newStoreId);
277
+ return newStoreId;
269
278
  }
270
279
 
271
- async function resolveSearchStore(): Promise<string> {
272
- if (resolvedStoreId) return resolvedStoreId;
273
- if (resolvePromise) return resolvePromise;
280
+ async function resolveSearchStore(conversationId: string): Promise<string> {
281
+ if (!conversationId) {
282
+ throw new Error('conversationId is required to resolve a search store.');
283
+ }
284
+
285
+ const cached = storeIdByConversation.get(conversationId);
286
+ if (cached) return cached;
287
+
288
+ const pending = resolvePromises.get(conversationId);
289
+ if (pending) return pending;
290
+
291
+ const promise = fetchOrCreateStore(conversationId).catch((err) => {
292
+ storeIdByConversation.delete(conversationId);
293
+ throw err;
294
+ });
295
+ resolvePromises.set(conversationId, promise);
274
296
 
275
- resolvePromise = fetchOrCreateStore();
276
- return resolvePromise;
297
+ try {
298
+ return await promise;
299
+ } finally {
300
+ resolvePromises.delete(conversationId);
301
+ }
277
302
  }
278
303
 
279
304
  // ---- Upload SDK singleton ----
@@ -319,7 +344,9 @@ export function createDocumentUploadProvider(
319
344
  const fileInput = ref<HTMLInputElement | null>(null);
320
345
  const upload = getUploadInstance();
321
346
  const resolvedMaxFiles = upload.getSupportedFileTypes().maxFilesPerUpload;
322
- const storeId = ref<string>(resolvedStoreId ?? '');
347
+ const storeId = ref<string>(
348
+ storeIdByConversation.get(toValue(conversationIdRef)) ?? '',
349
+ );
323
350
 
324
351
  const currentScope = shallowRef<ScopedFileView | null>(
325
352
  upload.scopeBy('conversationId', toValue(conversationIdRef)),
@@ -327,15 +354,20 @@ export function createDocumentUploadProvider(
327
354
 
328
355
  watch(
329
356
  () => toValue(conversationIdRef),
330
- (newId: string, oldId: string) => {
331
- if (oldId && oldId !== newId) {
332
- const oldScope = upload.scopeBy('conversationId', oldId);
333
- for (const entry of oldScope.files.value) {
334
- upload.updateFileMetadata(entry.id, { conversationId: newId });
335
- }
336
- }
357
+ async (newId: string) => {
337
358
  currentScope.value = upload.scopeBy('conversationId', newId);
359
+ if (!newId) {
360
+ storeId.value = '';
361
+ return;
362
+ }
363
+
364
+ try {
365
+ storeId.value = await resolveSearchStore(newId);
366
+ } catch {
367
+ storeId.value = '';
368
+ }
338
369
  },
370
+ { immediate: true },
339
371
  );
340
372
 
341
373
  function scopedFiles(): FileEntry[] {
@@ -345,13 +377,15 @@ export function createDocumentUploadProvider(
345
377
  async function uploadFiles(files: File[]) {
346
378
  if (!files.length) return;
347
379
 
348
- const userStoreId = await resolveSearchStore();
349
- const workflowApiId = await fetchOrCreateWorkflow(userStoreId);
380
+ const conversationId = toValue(conversationIdRef);
381
+ if (!conversationId) return;
382
+
383
+ const userStoreId = await resolveSearchStore(conversationId);
384
+ const workflowApiId = await resolveWorkflowId(userStoreId);
350
385
  upload.setStoreId(userStoreId);
351
386
  upload.setWorkflowId(workflowApiId);
352
387
  storeId.value = userStoreId;
353
388
 
354
- const conversationId = toValue(conversationIdRef);
355
389
  const result = await upload.upload({
356
390
  id: `${conversationId}::${Date.now()}`,
357
391
  files,
@@ -425,13 +459,12 @@ export function createDocumentUploadProvider(
425
459
  ) {
426
460
  const input = event.target as HTMLInputElement;
427
461
  let fileList = Array.from(input.files ?? []);
428
- const availableSlots = resolvedMaxFiles - unassignedFiles.value.length;
429
462
 
430
- if (fileList.length > availableSlots) {
431
- const kept = Math.max(0, availableSlots);
463
+ if (fileList.length > resolvedMaxFiles) {
464
+ const kept = Math.max(0, resolvedMaxFiles);
432
465
  options?.onFilesExceeded?.({
433
466
  selected: fileList.length,
434
- allowed: availableSlots,
467
+ allowed: resolvedMaxFiles,
435
468
  kept,
436
469
  });
437
470
  fileList = fileList.slice(0, kept);
@@ -4,6 +4,8 @@ export {
4
4
  remarkNormalizeMath,
5
5
  normalizeMathDelimiters,
6
6
  } from '../helpers/unified/remarkNormalizeMath';
7
+ export { remarkCitations } from '../helpers/unified/remarkCitations';
8
+ export type { RemarkCitationsOptions } from '../helpers/unified/remarkCitations';
7
9
  export {
8
10
  provideChatKitLabels,
9
11
  useChatKitLabels,
@@ -81,6 +81,17 @@ export type ChatKitLabels = {
81
81
  * can scroll wide tables). Falls back to `defaultChatKitLabels` when omitted.
82
82
  */
83
83
  markdownTableLabel?: string;
84
+ /**
85
+ * Accessible label for the external-link action in a citation card
86
+ * (`CkCitation` / `CkCitationCard`). Falls back to `defaultChatKitLabels`.
87
+ */
88
+ citationOpenSource?: string;
89
+ /**
90
+ * Prefix for a citation marker's accessible name in `CkCitation`, composed
91
+ * as `"{citationSourceLabel} {index}"` (e.g. "Source 1"). Falls back to
92
+ * `defaultChatKitLabels`.
93
+ */
94
+ citationSourceLabel?: string;
84
95
  /** Fallback title used by `CkHistory` when a conversation has no `title`. */
85
96
  historyItemUntitled: string;
86
97
  /** Accessible name for the trailing kebab trigger on each `CkHistory` row. */
@@ -123,6 +134,8 @@ export const defaultChatKitLabels: ChatKitLabels = {
123
134
  modelSelectorListAriaLabel: 'Models',
124
135
  modelSelectorPlaceholder: 'Select model',
125
136
  markdownTableLabel: 'Table',
137
+ citationOpenSource: 'Open source',
138
+ citationSourceLabel: 'Source',
126
139
  historyItemUntitled: 'Untitled conversation',
127
140
  historyItemActionAriaLabel: 'Conversation options',
128
141
  historyItemEditAction: 'Edit',
@@ -83,8 +83,11 @@ function buildProcessor(plugins: PluggableList): SyncProcessor {
83
83
  .use(remarkNormalizeMath)
84
84
  .use(remarkGfm)
85
85
  .use(remarkMath);
86
- for (const plugin of plugins) {
87
- proc = proc.use(plugin as any);
86
+ // Apply the whole list at once. unified resolves `[plugin, options]` tuples
87
+ // when they're entries of a list; calling `.use(tuple)` per entry instead
88
+ // misreads the options object as a preset and throws.
89
+ if (plugins.length > 0) {
90
+ proc = proc.use(plugins);
88
91
  }
89
92
  return proc.use(remarkRehype).use(rehypeKatex).use(rehypeStringify);
90
93
  }
@@ -0,0 +1,112 @@
1
+ import { remarkCitations } from './remarkCitations';
2
+
3
+ // The plugin is a pure mdast transformer, so we can exercise it directly on a
4
+ // hand-built tree without spinning up the full unified/remark parser.
5
+ interface MdNode {
6
+ type: string;
7
+ value?: string;
8
+ children?: MdNode[];
9
+ data?: Record<string, unknown>;
10
+ }
11
+
12
+ function rootWithText(text: string): MdNode {
13
+ return { type: 'root', children: [{ type: 'text', value: text }] };
14
+ }
15
+
16
+ // `remarkCitations` is typed as a unified `Plugin` (it expects a `Processor`
17
+ // `this`). In these unit tests we call it as a plain factory, so cast away the
18
+ // `this` context to invoke it directly on a hand-built tree.
19
+ const makeTransformer = remarkCitations as unknown as (options: {
20
+ indices?: ReadonlySet<number>;
21
+ }) => (tree: MdNode) => void;
22
+
23
+ function run(text: string, indices?: ReadonlySet<number>): MdNode[] {
24
+ const tree = rootWithText(text);
25
+ makeTransformer({ indices })(tree);
26
+ return tree.children!;
27
+ }
28
+
29
+ describe('remarkCitations', () => {
30
+ it('replaces a known [n] marker with a citation placeholder node', () => {
31
+ const out = run('see this [1] here', new Set([1]));
32
+ expect(out.map((n) => n.type)).toEqual([
33
+ 'text',
34
+ 'citationReference',
35
+ 'text',
36
+ ]);
37
+ expect(out[0]).toEqual({ type: 'text', value: 'see this ' });
38
+ expect(out[2]).toEqual({ type: 'text', value: ' here' });
39
+ });
40
+
41
+ it('emits a span placeholder with the index as a data attribute', () => {
42
+ const [citation] = run('[1]', new Set([1]));
43
+ expect(citation.data).toEqual({
44
+ hName: 'span',
45
+ hProperties: { 'data-citation-index': 1 },
46
+ hChildren: [{ type: 'text', value: '1' }],
47
+ });
48
+ });
49
+
50
+ it('handles adjacent markers', () => {
51
+ const out = run('claim [1][2] end', new Set([1, 2]));
52
+ expect(out.map((n) => n.type)).toEqual([
53
+ 'text',
54
+ 'citationReference',
55
+ 'citationReference',
56
+ 'text',
57
+ ]);
58
+ expect(
59
+ (out[1].data!.hProperties as Record<string, number>)[
60
+ 'data-citation-index'
61
+ ],
62
+ ).toBe(1);
63
+ expect(
64
+ (out[2].data!.hProperties as Record<string, number>)[
65
+ 'data-citation-index'
66
+ ],
67
+ ).toBe(2);
68
+ });
69
+
70
+ it('leaves unsupported markers (no matching source) as literal text', () => {
71
+ const out = run('real [1] but [9] is not', new Set([1]));
72
+ expect(out.map((n) => n.type)).toEqual([
73
+ 'text',
74
+ 'citationReference',
75
+ 'text',
76
+ ]);
77
+ // The [9] survives verbatim in the trailing text node.
78
+ expect(out[2].value).toBe(' but [9] is not');
79
+ });
80
+
81
+ it('treats every [n] as a citation when no index set is given', () => {
82
+ const out = run('a [1] b [2]');
83
+ expect(out.filter((n) => n.type === 'citationReference')).toHaveLength(2);
84
+ });
85
+
86
+ it('never converts [0] — citations are 1-based', () => {
87
+ const out = run('the value arr[0] here'); // no allow-list: every [n] except 0
88
+ expect(out.some((n) => n.type === 'citationReference')).toBe(false);
89
+ expect(out.map((n) => n.value ?? '').join('')).toContain('[0]');
90
+ });
91
+
92
+ it('leaves text without markers untouched', () => {
93
+ const out = run('no citations here', new Set([1]));
94
+ expect(out).toEqual([{ type: 'text', value: 'no citations here' }]);
95
+ });
96
+
97
+ it('does not descend into code/inlineCode value nodes', () => {
98
+ const tree: MdNode = {
99
+ type: 'root',
100
+ children: [
101
+ { type: 'inlineCode', value: 'arr[1]' },
102
+ { type: 'text', value: ' and [1]' },
103
+ ],
104
+ };
105
+ makeTransformer({ indices: new Set([1]) })(tree);
106
+ // inlineCode is untouched; only the text node's [1] becomes a citation.
107
+ expect(tree.children![0]).toEqual({ type: 'inlineCode', value: 'arr[1]' });
108
+ expect(tree.children!.some((n) => n.type === 'citationReference')).toBe(
109
+ true,
110
+ );
111
+ });
112
+ });
@@ -0,0 +1,55 @@
1
+ import type { Plugin } from 'unified';
2
+ import type { PhrasingContent } from 'mdast';
3
+ import { findAndReplace } from 'mdast-util-find-and-replace';
4
+
5
+ export interface RemarkCitationsOptions {
6
+ /**
7
+ * When provided, only `[n]` markers whose number is in this set are turned
8
+ * into citation placeholders; any other `[n]` is left as literal text
9
+ * (an "unsupported" marker with no backing source). When omitted, every
10
+ * `[n]` is treated as a citation.
11
+ */
12
+ indices?: ReadonlySet<number>;
13
+ }
14
+
15
+ // Matches a bare `[n]` reference, e.g. `[1]`. Because this runs on the parsed
16
+ // mdast tree (via `findAndReplace`, which only visits `text` nodes), `[n]`
17
+ // inside code spans/blocks and bracketed links never reaches it — only a
18
+ // literal `[n]` typed in prose does.
19
+ const CITATION_RE = /\[(\d+)\]/g;
20
+
21
+ /**
22
+ * Replaces inline `[n]` citation references in the parsed markdown with a
23
+ * placeholder element (`<span data-citation-index="n">n</span>`) that survives
24
+ * stringify + sanitisation (`data-*` attributes are allowed by default).
25
+ */
26
+ // The tree type is left as unified's default (a unist `Node`) so the public
27
+ // type signature — and therefore the published `.d.ts` — doesn't depend on
28
+ // `mdast` types; the cast below keeps the internal `findAndReplace` call typed.
29
+ export const remarkCitations: Plugin<[RemarkCitationsOptions?]> = (
30
+ options = {},
31
+ ) => {
32
+ const { indices } = options;
33
+
34
+ return (tree) => {
35
+ findAndReplace(tree as Parameters<typeof findAndReplace>[0], [
36
+ CITATION_RE,
37
+ (_match: string, digits: string) => {
38
+ const index = Number(digits);
39
+ if (index < 1) return false; // citations are 1-based
40
+ // Leave unsupported markers (no backing source) as literal text.
41
+ if (indices && !indices.has(index)) return false;
42
+ return {
43
+ type: 'citationReference',
44
+ data: {
45
+ hName: 'span',
46
+ // Use the literal attribute name so the placeholder contract matches
47
+ // the renderer's `getAttribute('data-citation-index')` lookup exactly.
48
+ hProperties: { 'data-citation-index': index },
49
+ hChildren: [{ type: 'text', value: String(index) }],
50
+ },
51
+ } as unknown as PhrasingContent;
52
+ },
53
+ ]);
54
+ };
55
+ };
package/src/index.ts CHANGED
@@ -63,6 +63,8 @@ export type {
63
63
  // Components
64
64
  export {
65
65
  CkAgentMessage,
66
+ CkCitation,
67
+ CkCitationCard,
66
68
  CkConversation,
67
69
  CkConversationLayout,
68
70
  CkCopyMessageActionButton,
@@ -87,6 +89,11 @@ export {
87
89
  CkUserMessage,
88
90
  } from './components';
89
91
  export type {
92
+ CkCitationProps,
93
+ CkCitationEmits,
94
+ CkCitationSource,
95
+ CkCitationCardProps,
96
+ CkCitationCardEmits,
90
97
  CkConversationLayoutProps,
91
98
  CkConversationProps,
92
99
  CkDropZoneOverlayProps,
@@ -110,6 +117,7 @@ export type {
110
117
  CkHistoryWrapperProps,
111
118
  CkMarkdownElementClasses,
112
119
  CkMarkdownRendererProps,
120
+ CkMarkdownRendererEmits,
113
121
  CkModelOption,
114
122
  CkModelSelectorProps,
115
123
  CkModelSelectorEmits,