@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,76 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { UiFileType, UiButton, UiIcon } from '@aleph-alpha/ui-library';
4
+ import {
5
+ defaultChatKitLabels,
6
+ useChatKitLabels,
7
+ } from '../../../handlers/useChatKitLabels';
8
+ import type { CkCitationCardProps } from './types';
9
+
10
+ defineOptions({
11
+ name: 'CkCitationCard',
12
+ });
13
+
14
+ const props = withDefaults(defineProps<CkCitationCardProps>(), {
15
+ locationLabel: undefined,
16
+ url: undefined,
17
+ openSourceLabel: undefined,
18
+ });
19
+
20
+ const emit = defineEmits<{
21
+ 'open-source': [url: string];
22
+ }>();
23
+
24
+ const labels = useChatKitLabels();
25
+
26
+ // Resolution order: per-instance prop, injected label, English default.
27
+ const openSourceAriaLabel = computed(
28
+ () =>
29
+ props.openSourceLabel ??
30
+ labels.value.citationOpenSource ??
31
+ defaultChatKitLabels.citationOpenSource!,
32
+ );
33
+
34
+ function handleOpen(): void {
35
+ if (props.url) {
36
+ emit('open-source', props.url);
37
+ }
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <div
43
+ class="ck-citation-card flex w-[22.5rem] max-w-[90vw] flex-col gap-2 rounded-md bg-background-surface-floating px-6 pb-6 pt-4 shadow-md"
44
+ >
45
+ <div class="flex items-center gap-3">
46
+ <UiFileType :filename="fileName" class="shrink-0" />
47
+ <p
48
+ class="min-w-0 flex-1 truncate text-sm font-medium text-content-on-surface-primary"
49
+ :title="fileName"
50
+ >
51
+ {{ fileName }}
52
+ </p>
53
+ <UiButton
54
+ v-if="url"
55
+ variant="ghost"
56
+ size="icon-sm"
57
+ :aria-label="openSourceAriaLabel"
58
+ class="shrink-0"
59
+ @click="handleOpen"
60
+ >
61
+ <UiIcon name="external-link" :size="16" aria-hidden="true" />
62
+ </UiButton>
63
+ </div>
64
+
65
+ <p class="break-words text-sm leading-5 text-content-on-surface-secondary">
66
+ {{ snippet }}
67
+ </p>
68
+
69
+ <p
70
+ v-if="locationLabel"
71
+ class="text-sm leading-5 text-content-on-surface-muted"
72
+ >
73
+ {{ locationLabel }}
74
+ </p>
75
+ </div>
76
+ </template>
@@ -0,0 +1,2 @@
1
+ export { default as CkCitationCard } from './CkCitationCard.vue';
2
+ export type * from './types';
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The floating card that describes a single citation source: the source file,
3
+ * the cited excerpt, an optional location (e.g. page), and an optional
4
+ * external-link action. Rendered as the hover/focus content of `CkCitation`,
5
+ * but usable on its own wherever a source preview is needed.
6
+ *
7
+ * Purely presentational and prop-driven. The external-link action is exposed
8
+ * as an `open-source` event rather than a plain link, because the source URL
9
+ * is often auth-proxied and cannot be navigated to directly.
10
+ *
11
+ * @category Data Display
12
+ * @useCases citation source preview, hover card for an inline reference, show the cited file/page/excerpt
13
+ * @keywords citation, source, card, popover, reference, file, page, excerpt, preview
14
+ * @related CkCitation, CkMarkdownRenderer, CkAgentMessage
15
+ */
16
+ export interface CkCitationCardProps {
17
+ /**
18
+ * Source file name. Rendered as the card title and used to derive the
19
+ * file-type badge icon/tone (pass the original name with its extension,
20
+ * e.g. `q3-report.pdf`).
21
+ */
22
+ fileName: string;
23
+ /** The cited chunk text, shown in the card body. */
24
+ snippet: string;
25
+ /**
26
+ * Location label shown in the footer, e.g. `"Page 3"`. Pass a fully
27
+ * formatted, localized string — the component does no number formatting.
28
+ * Omit for non-paginated sources to hide the footer.
29
+ */
30
+ locationLabel?: string;
31
+ /**
32
+ * When set, the external-link action is shown in the header. Activating it
33
+ * emits `open-source` with this value; the host opens it (it may be an
34
+ * auth-proxied URL that can't be a plain link). Omit to hide the action.
35
+ */
36
+ url?: string;
37
+ /**
38
+ * Accessible label for the external-link action. When omitted, resolves from
39
+ * `useChatKitLabels().citationOpenSource` (English default `"Open source"`).
40
+ * Set this to override per instance.
41
+ */
42
+ openSourceLabel?: string;
43
+ }
44
+
45
+ export interface CkCitationCardEmits {
46
+ /**
47
+ * Emitted when the user activates the header external-link action. The
48
+ * payload is `url`. Only emitted when `url` is set.
49
+ */
50
+ (e: 'open-source', url: string): void;
51
+ }
@@ -1,4 +1,5 @@
1
1
  import { render, screen } from '@testing-library/vue';
2
+ import userEvent from '@testing-library/user-event';
2
3
  import { h } from 'vue';
3
4
  import CkMarkdownRenderer from './CkMarkdownRenderer.vue';
4
5
  import { defaultMarkdownClasses } from './defaultMarkdownClasses';
@@ -345,4 +346,127 @@ describe('CkMarkdownRenderer', () => {
345
346
  expect(container.querySelector('p')?.textContent).toBe('PREFIX:HELLO');
346
347
  });
347
348
  });
349
+
350
+ describe('citations', () => {
351
+ const citations = [
352
+ {
353
+ index: 1,
354
+ fileName: 'q3-report.pdf',
355
+ snippet: 'the cited chunk text',
356
+ locationLabel: 'Page 3',
357
+ url: '/api/v1/.../public-urls',
358
+ },
359
+ ];
360
+
361
+ it('still parses markdown and renders [n] as an interactive citation', () => {
362
+ const { container } = render(CkMarkdownRenderer, {
363
+ props: { content: 'Adoption rose **25%** [1].', citations },
364
+ });
365
+
366
+ // Markdown is parsed (bold becomes <strong>, not literal asterisks)...
367
+ expect(container.querySelector('strong')?.textContent).toBe('25%');
368
+ expect(container.textContent).not.toContain('**');
369
+ // ...and [1] becomes a citation marker.
370
+ expect(
371
+ screen.getByRole('button', { name: 'Source 1' }),
372
+ ).toBeInTheDocument();
373
+ });
374
+
375
+ it('leaves an unsupported [n] (no matching source) as plain text', () => {
376
+ const { container } = render(CkMarkdownRenderer, {
377
+ props: { content: 'No source for this [9].', citations },
378
+ });
379
+
380
+ expect(container.textContent).toContain('[9]');
381
+ expect(screen.queryByRole('button', { name: 'Source 9' })).toBeNull();
382
+ });
383
+
384
+ it('emits open-source end-to-end when a citation card action is used', async () => {
385
+ const user = userEvent.setup();
386
+ const { emitted } = render(CkMarkdownRenderer, {
387
+ props: { content: 'See the report [1].', citations },
388
+ });
389
+
390
+ await user.hover(screen.getByRole('button', { name: 'Source 1' }));
391
+ await user.click(
392
+ await screen.findByRole('button', { name: 'Open source' }),
393
+ );
394
+
395
+ expect(emitted('open-source')).toEqual([['/api/v1/.../public-urls']]);
396
+ });
397
+
398
+ it('renders the `citation` slot instead of the default marker when provided', async () => {
399
+ const user = userEvent.setup();
400
+ const { emitted } = render(CkMarkdownRenderer, {
401
+ props: { content: 'See the report [1].', citations },
402
+ slots: {
403
+ citation: ({
404
+ source,
405
+ openSource,
406
+ }: {
407
+ source: { index: number; url?: string };
408
+ openSource: (url: string) => void;
409
+ }) =>
410
+ h(
411
+ 'button',
412
+ { onClick: () => source.url && openSource(source.url) },
413
+ `custom-${source.index}`,
414
+ ),
415
+ },
416
+ });
417
+
418
+ const custom = screen.getByRole('button', { name: 'custom-1' });
419
+ expect(custom).toBeInTheDocument();
420
+ // The default chip is replaced, not added alongside.
421
+ expect(screen.queryByRole('button', { name: 'Source 1' })).toBeNull();
422
+
423
+ await user.click(custom);
424
+ expect(emitted('open-source')).toEqual([['/api/v1/.../public-urls']]);
425
+ });
426
+
427
+ describe('numeric marker conversion across markdown contexts', () => {
428
+ it('does not convert [n] inside an inline code span', () => {
429
+ const { container } = render(CkMarkdownRenderer, {
430
+ props: { content: 'use `arr[1]` here', citations },
431
+ });
432
+ expect(container.querySelector('code')?.textContent).toBe('arr[1]');
433
+ expect(screen.queryByRole('button', { name: 'Source 1' })).toBeNull();
434
+ });
435
+
436
+ it('does not convert [n] inside a fenced code block', () => {
437
+ const { container } = render(CkMarkdownRenderer, {
438
+ props: { content: '```\n[1]\n```', citations },
439
+ });
440
+ expect(container.querySelector('pre')?.textContent).toContain('[1]');
441
+ expect(screen.queryByRole('button', { name: 'Source 1' })).toBeNull();
442
+ });
443
+
444
+ it('does not convert a non-numeric [note] marker', () => {
445
+ const { container } = render(CkMarkdownRenderer, {
446
+ props: { content: 'see [note] here', citations },
447
+ });
448
+ expect(container.textContent).toContain('[note]');
449
+ });
450
+
451
+ it('converts a marker at the very start of the content', () => {
452
+ render(CkMarkdownRenderer, {
453
+ props: { content: '[1] leads the paragraph', citations },
454
+ });
455
+ expect(
456
+ screen.getByRole('button', { name: 'Source 1' }),
457
+ ).toBeInTheDocument();
458
+ });
459
+
460
+ it('converts markers inside a heading and a list item', () => {
461
+ const { container } = render(CkMarkdownRenderer, {
462
+ props: { content: '# Title [1]\n\n- item [1]', citations },
463
+ });
464
+ expect(container.querySelector('h1')).toBeInTheDocument();
465
+ expect(container.querySelector('li')).toBeInTheDocument();
466
+ expect(
467
+ screen.getAllByRole('button', { name: 'Source 1' }),
468
+ ).toHaveLength(2);
469
+ });
470
+ });
471
+ });
348
472
  });
@@ -430,3 +430,121 @@ export const WithCustomPlugin: Story = {
430
430
  }),
431
431
  parameters: { docs: { source: { code: withCustomPluginTemplateSource } } },
432
432
  };
433
+
434
+ const withCitationsTemplateSource = `<script setup lang="ts">
435
+ import { CkMarkdownRenderer } from '@aleph-alpha/chat-kit';
436
+ import type { CkCitationSource } from '@aleph-alpha/chat-kit';
437
+
438
+ // An assistant reply is markdown that may contain inline [n] references.
439
+ const content = [
440
+ 'Customized training drove measurably better outcomes.',
441
+ '',
442
+ 'Departments with tailored sessions saw a **25% higher adoption rate** [1],',
443
+ 'and reported fewer support tickets in the first quarter [2].',
444
+ ].join('\\n');
445
+
446
+ // The numbered sources, mapped from the backend citations manifest.
447
+ const citations: CkCitationSource[] = [
448
+ {
449
+ index: 1,
450
+ fileName: 'Q2_Business_Plan.pdf',
451
+ snippet: 'Departments that received customized training saw a 25% higher adoption rate…',
452
+ locationLabel: 'Page 3',
453
+ url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
454
+ },
455
+ {
456
+ index: 2,
457
+ fileName: 'Support-Metrics-Q1.csv',
458
+ snippet: 'Ticket volume fell 18% quarter-over-quarter for tailored-training cohorts.',
459
+ locationLabel: 'Row 42',
460
+ url: '/api/v1/search-stores/ss_abc/files/file_456/public-urls',
461
+ },
462
+ ];
463
+
464
+ function openSource(url: string) {
465
+ // Source URLs are auth-proxied; the host performs the navigation.
466
+ window.open(url, '_blank', 'noopener');
467
+ }
468
+ </script>
469
+
470
+ <template>
471
+ <CkMarkdownRenderer
472
+ :content="content"
473
+ :citations="citations"
474
+ @open-source="openSource"
475
+ />
476
+ </template>`;
477
+
478
+ const CITATIONS = [
479
+ {
480
+ index: 1,
481
+ fileName: 'Q2_Business_Plan.pdf',
482
+ snippet:
483
+ 'Departments that received customized training saw a 25% higher adoption rate of the new tools compared to those with generic training.',
484
+ locationLabel: 'Page 3',
485
+ url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
486
+ },
487
+ {
488
+ index: 2,
489
+ fileName: 'Support-Metrics-Q1.csv',
490
+ snippet:
491
+ 'Ticket volume fell 18% quarter-over-quarter for the tailored-training cohorts versus the control group.',
492
+ locationLabel: 'Row 42',
493
+ url: '/api/v1/search-stores/ss_abc/files/file_456/public-urls',
494
+ },
495
+ ];
496
+
497
+ /**
498
+ * The headline integration: an assistant reply is **markdown that contains
499
+ * inline `[n]` references**, and the `citations` prop turns each one into an
500
+ * interactive `CkCitation` (numbered marker + hover card) — handled as a
501
+ * dedicated case, distinct from ordinary markdown. Hover a marker to preview
502
+ * its source; the external-link action emits `open-source`.
503
+ */
504
+ export const WithCitations: Story = {
505
+ render: () => ({
506
+ components: { CkMarkdownRenderer },
507
+ setup() {
508
+ const content = [
509
+ '## Training impact',
510
+ '',
511
+ 'Customized training drove measurably better outcomes across teams.',
512
+ '',
513
+ 'Departments with tailored sessions saw a **25% higher adoption rate** [1],',
514
+ 'and reported markedly fewer support tickets in the first quarter [2].',
515
+ '',
516
+ '- Faster onboarding for new hires [1]',
517
+ '- Lower support load [2]',
518
+ ].join('\n');
519
+ function openSource(url: string) {
520
+ window.open(url, '_blank', 'noopener');
521
+ }
522
+ return { content, citations: CITATIONS, openSource };
523
+ },
524
+ template:
525
+ '<CkMarkdownRenderer :content="content" :citations="citations" @open-source="openSource" />',
526
+ }),
527
+ parameters: { docs: { source: { code: withCitationsTemplateSource } } },
528
+ };
529
+
530
+ /**
531
+ * Unsupported markers degrade gracefully: a `[n]` with no matching source
532
+ * (here `[9]`) is left as plain text rather than rendered as a citation, while
533
+ * the markers that do have sources still render. Citation handling never
534
+ * touches code spans — the `[3]` inside `` `arr[3]` `` stays literal.
535
+ */
536
+ export const WithUnsupportedAndCodeMarkers: Story = {
537
+ render: () => ({
538
+ components: { CkMarkdownRenderer },
539
+ setup() {
540
+ const content = [
541
+ 'Two real sources back this [1][2], but [9] has no source and stays as text.',
542
+ '',
543
+ 'A marker inside code such as `arr[3]` is never treated as a citation.',
544
+ ].join('\n');
545
+ return { content, citations: CITATIONS };
546
+ },
547
+ template:
548
+ '<CkMarkdownRenderer :content="content" :citations="citations" />',
549
+ }),
550
+ };
@@ -14,6 +14,8 @@ import {
14
14
  defaultChatKitLabels,
15
15
  useChatKitLabels,
16
16
  } from '../../../handlers/useChatKitLabels';
17
+ import { useCitationRendering } from './useCitationRendering';
18
+ import type { CkCitationSource } from '../CkCitation';
17
19
  import type { CkMarkdownElementClasses } from './types';
18
20
  import { defaultMarkdownClasses } from './defaultMarkdownClasses';
19
21
 
@@ -21,30 +23,44 @@ import { defaultMarkdownClasses } from './defaultMarkdownClasses';
21
23
  // never forces the whole message column wider on narrow viewports.
22
24
  const TABLE_WRAPPER_CLASS = 'max-w-full overflow-x-auto';
23
25
 
24
- function domToVNode(
25
- node: Node,
26
- slots: Slots,
27
- classes: CkMarkdownElementClasses,
28
- tableLabel: string,
29
- ): VNode | string | null {
26
+ type RenderedNode = VNode | VNode[] | string | null;
27
+
28
+ interface DomToVNodeContext {
29
+ slots: Slots;
30
+ classes: CkMarkdownElementClasses;
31
+ tableLabel: string;
32
+ // Maps a citation placeholder element to a node, or null for other elements.
33
+ renderCitation: (el: Element) => VNode | VNode[] | null;
34
+ }
35
+
36
+ // Routes a fenced code block to the `code-block` slot when one is provided;
37
+ // otherwise returns null so it renders as the default `<pre><code>`.
38
+ function renderCodeBlock(el: Element, ctx: DomToVNodeContext): VNode[] | null {
39
+ if (el.tagName !== 'PRE' || el.firstElementChild?.tagName !== 'CODE') {
40
+ return null;
41
+ }
42
+ if (!ctx.slots['code-block']) return null;
43
+
44
+ const codeEl = el.firstElementChild;
45
+ const language = codeEl.className?.match(/language-(\S+)/)?.[1] ?? '';
46
+ const code = codeEl.textContent ?? '';
47
+ return ctx.slots['code-block']({ code, language });
48
+ }
49
+
50
+ function domToVNode(node: Node, ctx: DomToVNodeContext): RenderedNode {
30
51
  if (node.nodeType === Node.TEXT_NODE) return node.textContent;
31
52
  if (node.nodeType !== Node.ELEMENT_NODE) return null;
32
53
 
33
54
  const el = node as Element;
34
55
 
35
- if (el.tagName === 'PRE' && el.firstElementChild?.tagName === 'CODE') {
36
- const codeEl = el.firstElementChild;
37
- const language = codeEl.className?.match(/language-(\S+)/)?.[1] ?? '';
38
- const code = codeEl.textContent ?? '';
39
-
40
- if (slots['code-block']) {
41
- return slots['code-block']({ code, language }) as unknown as VNode;
42
- }
43
- }
56
+ // Explicit null check: a slot may legitimately render an empty string, which
57
+ // must still count as "handled" rather than falling through to the element.
58
+ const special = renderCodeBlock(el, ctx) ?? ctx.renderCitation(el);
59
+ if (special != null) return special;
44
60
 
45
61
  const children = Array.from(node.childNodes)
46
- .map((child) => domToVNode(child, slots, classes, tableLabel))
47
- .filter((c): c is VNode | string => c != null);
62
+ .map((child) => domToVNode(child, ctx))
63
+ .filter((c): c is NonNullable<RenderedNode> => c != null);
48
64
 
49
65
  const attrs: Record<string, string> = {};
50
66
  for (const attr of el.attributes) {
@@ -52,7 +68,7 @@ function domToVNode(
52
68
  }
53
69
 
54
70
  const tag = el.tagName.toLowerCase();
55
- const customClass = classes[tag as keyof CkMarkdownElementClasses];
71
+ const customClass = ctx.classes[tag as keyof CkMarkdownElementClasses];
56
72
  if (customClass) {
57
73
  attrs.class = attrs.class ? `${attrs.class} ${customClass}` : customClass;
58
74
  }
@@ -66,7 +82,7 @@ function domToVNode(
66
82
  class: TABLE_WRAPPER_CLASS,
67
83
  tabindex: '0',
68
84
  role: 'region',
69
- 'aria-label': tableLabel,
85
+ 'aria-label': ctx.tableLabel,
70
86
  },
71
87
  [vnode],
72
88
  );
@@ -84,12 +100,34 @@ export default defineComponent({
84
100
  default: () => ({}),
85
101
  },
86
102
  plugins: { type: Array as PropType<PluggableList>, default: undefined },
103
+ citations: {
104
+ type: Array as PropType<CkCitationSource[]>,
105
+ default: undefined,
106
+ },
107
+ },
108
+ emits: {
109
+ 'open-source': (url: string) => typeof url === 'string',
87
110
  },
88
- setup(props, { slots }) {
89
- const { content, plugins } = toRefs(props);
90
- const { html } = useMarkdownParser(content, plugins);
111
+ setup(props, { slots, emit }) {
112
+ const { content } = toRefs(props);
91
113
  const labels = useChatKitLabels();
92
114
 
115
+ function handleOpenSource(url: string): void {
116
+ emit('open-source', url);
117
+ }
118
+
119
+ const { citationPlugins, renderCitationNode } = useCitationRendering(
120
+ () => props.citations,
121
+ handleOpenSource,
122
+ );
123
+
124
+ const effectivePlugins = computed<PluggableList>(() => [
125
+ ...(props.plugins ?? []),
126
+ ...citationPlugins.value,
127
+ ]);
128
+
129
+ const { html } = useMarkdownParser(content, effectivePlugins);
130
+
93
131
  const parsedNodes = computed(() => {
94
132
  const raw = html.value;
95
133
  if (!raw) return [];
@@ -103,13 +141,17 @@ export default defineComponent({
103
141
  }));
104
142
 
105
143
  return () => {
106
- const elementClasses = mergedClasses.value;
107
- const tableLabel =
108
- labels.value.markdownTableLabel ??
109
- defaultChatKitLabels.markdownTableLabel!;
144
+ const ctx: DomToVNodeContext = {
145
+ slots,
146
+ classes: mergedClasses.value,
147
+ tableLabel:
148
+ labels.value.markdownTableLabel ??
149
+ defaultChatKitLabels.markdownTableLabel!,
150
+ renderCitation: (el) => renderCitationNode(el, slots.citation),
151
+ };
110
152
  const children = parsedNodes.value
111
- .map((node) => domToVNode(node, slots, elementClasses, tableLabel))
112
- .filter((c): c is VNode | string => c != null);
153
+ .map((node) => domToVNode(node, ctx))
154
+ .filter((c): c is NonNullable<RenderedNode> => c != null);
113
155
  return h(
114
156
  'div',
115
157
  { class: 'markdown-renderer min-w-0 break-words' },
@@ -1,5 +1,6 @@
1
1
  export { default as CkMarkdownRenderer } from './CkMarkdownRenderer.vue';
2
2
  export type {
3
3
  CkMarkdownRendererProps,
4
+ CkMarkdownRendererEmits,
4
5
  CkMarkdownElementClasses,
5
6
  } from './types';
@@ -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
+ });