@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
@@ -0,0 +1,144 @@
1
+ import CkCitationCard from './CkCitationCard.vue';
2
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
3
+
4
+ const LOREM =
5
+ 'Lorem ipsum dolor sit amet consectetur. Phasellus ipsum nunc pellentesque diam donec id fermentum. Morbi amet lacus cras sagittis sagittis libero. Elit netus sodales in risus diam ut.';
6
+
7
+ /**
8
+ * CkCitationCard is the floating card that describes a single citation source:
9
+ * the file, the cited excerpt, an optional location (e.g. page), and an
10
+ * optional external-link action. It is the hover/focus content of `CkCitation`,
11
+ * but can be used on its own wherever a source preview is needed. The
12
+ * external-link action is exposed as an `open-source` event rather than a plain
13
+ * link, because the source URL is often auth-proxied.
14
+ */
15
+ const meta: Meta<typeof CkCitationCard> = {
16
+ title: 'Base/CkCitationCard',
17
+ component: CkCitationCard,
18
+ tags: ['autodocs'],
19
+ argTypes: {
20
+ fileName: { control: 'text' },
21
+ snippet: { control: 'text' },
22
+ locationLabel: { control: 'text' },
23
+ url: { control: 'text' },
24
+ openSourceLabel: { control: 'text' },
25
+ },
26
+ args: {
27
+ fileName: 'Q2_Business_Plan.pdf',
28
+ snippet: LOREM,
29
+ locationLabel: 'Page 3',
30
+ url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
31
+ },
32
+ decorators: [
33
+ () => ({
34
+ template:
35
+ '<div style="max-width: 640px; margin: 0 auto; padding: 24px;"><story /></div>',
36
+ }),
37
+ ],
38
+ };
39
+
40
+ export default meta;
41
+ type Story = StoryObj<typeof meta>;
42
+
43
+ const defaultTemplateSource = `<script setup lang="ts">
44
+ import { CkCitationCard } from '@aleph-alpha/chat-kit';
45
+
46
+ function openSource(url: string) {
47
+ // The source URL is often auth-proxied — the host opens it.
48
+ window.open(url, '_blank', 'noopener');
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <CkCitationCard
54
+ file-name="Q2_Business_Plan.pdf"
55
+ snippet="Lorem ipsum dolor sit amet consectetur…"
56
+ location-label="Page 3"
57
+ url="/api/v1/search-stores/ss_abc/files/file_123/public-urls"
58
+ @open-source="openSource"
59
+ />
60
+ </template>`;
61
+
62
+ /**
63
+ * Full card: file-type badge, title, external-link action, excerpt, and a
64
+ * page footer. Matches the Figma `citation card` frame.
65
+ */
66
+ export const Default: Story = {
67
+ parameters: { docs: { source: { code: defaultTemplateSource } } },
68
+ };
69
+
70
+ /**
71
+ * No `url` — the external-link action is hidden. Use for sources that can't
72
+ * be opened (e.g. an internal chunk with no servable file).
73
+ */
74
+ export const WithoutLink: Story = {
75
+ args: { url: undefined },
76
+ };
77
+
78
+ /**
79
+ * No `locationLabel` — the footer is hidden. Use for non-paginated sources
80
+ * such as a plain web page or a whole-document citation.
81
+ */
82
+ export const WithoutLocation: Story = {
83
+ args: { locationLabel: undefined },
84
+ };
85
+
86
+ /**
87
+ * Minimal: title + excerpt only (no link, no location). The leanest source a
88
+ * card can describe.
89
+ */
90
+ export const MinimalSnippetOnly: Story = {
91
+ args: { url: undefined, locationLabel: undefined },
92
+ };
93
+
94
+ /**
95
+ * A long file name truncates with an ellipsis (the badge and external-link
96
+ * action stay pinned, the card keeps its width); the full name stays available
97
+ * on hover via the `title` attribute. The `truncate` behaviour holds for any
98
+ * length, including a single unbroken token with no separators.
99
+ */
100
+ export const LongFileName: Story = {
101
+ args: {
102
+ fileName:
103
+ 'Annual-Strategy-And-Operational-Review-And-Lessons-Learned-Digital-Transformation-Phase-1-FY2026-Final-APPROVED-v3.pdf',
104
+ },
105
+ };
106
+
107
+ /**
108
+ * A long excerpt wraps freely — the card grows vertically while keeping its
109
+ * fixed width.
110
+ */
111
+ export const LongSnippet: Story = {
112
+ args: {
113
+ snippet: `${LOREM} ${LOREM}`,
114
+ },
115
+ };
116
+
117
+ /**
118
+ * The file-type badge derives its icon and tone from the file extension:
119
+ * `.pdf`/`.docx` → pink, `.md`/`.html` → soft, `.csv`/`.xlsx` → yellow.
120
+ */
121
+ export const FileTypeVariants: Story = {
122
+ render: (args) => ({
123
+ components: { CkCitationCard },
124
+ setup() {
125
+ const files = [
126
+ 'q3-report.pdf',
127
+ 'README.md',
128
+ 'sales-2026.csv',
129
+ 'notes.txt',
130
+ ];
131
+ return { args, files };
132
+ },
133
+ template: `
134
+ <div style="display: flex; flex-direction: column; gap: 16px;">
135
+ <CkCitationCard
136
+ v-for="f in files"
137
+ :key="f"
138
+ v-bind="args"
139
+ :file-name="f"
140
+ />
141
+ </div>
142
+ `,
143
+ }),
144
+ };
@@ -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';