@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,6 @@
1
1
  import CkAgentMessage from './CkAgentMessage.vue';
2
+ import { CkMarkdownRenderer } from '../CkMarkdownRenderer';
3
+ import type { CkCitationSource } from '../CkCitation';
2
4
  import type { Meta, StoryObj } from '@storybook/vue3-vite';
3
5
 
4
6
  const meta: Meta<typeof CkAgentMessage> = {
@@ -213,3 +215,113 @@ export const WithoutIcon: Story = {
213
215
  }),
214
216
  parameters: { docs: { source: { code: withoutIconTemplateSource } } },
215
217
  };
218
+
219
+ const CITATIONS: CkCitationSource[] = [
220
+ {
221
+ index: 1,
222
+ fileName: 'Q2_Business_Plan.pdf',
223
+ snippet:
224
+ 'Departments that received customized training saw a 25% higher adoption rate of the new tools compared to those with generic training.',
225
+ locationLabel: 'Page 3',
226
+ url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
227
+ },
228
+ {
229
+ index: 2,
230
+ fileName: 'Support-Metrics-Q1.csv',
231
+ snippet:
232
+ 'Ticket volume fell 18% quarter-over-quarter for the tailored-training cohorts versus the control group.',
233
+ locationLabel: 'Row 42',
234
+ url: '/api/v1/search-stores/ss_abc/files/file_456/public-urls',
235
+ },
236
+ ];
237
+
238
+ const CITED_MARKDOWN = [
239
+ 'Based on the internal documents, customized training drove measurably better outcomes.',
240
+ '',
241
+ 'Departments with tailored sessions saw a **25% higher adoption rate** [1],',
242
+ 'and reported markedly fewer support tickets in the first quarter [2].',
243
+ ].join('\n');
244
+
245
+ const withCitationsTemplateSource = `<script setup lang="ts">
246
+ import { CkAgentMessage, CkMarkdownRenderer } from '@aleph-alpha/chat-kit';
247
+ import type { CkCitationSource } from '@aleph-alpha/chat-kit';
248
+
249
+ // The reply body is markdown that contains inline [n] references.
250
+ const content = [
251
+ 'Based on the internal documents, customized training drove measurably better outcomes.',
252
+ '',
253
+ 'Departments with tailored sessions saw a **25% higher adoption rate** [1],',
254
+ 'and reported markedly fewer support tickets in the first quarter [2].',
255
+ ].join('\\n');
256
+
257
+ // The numbered sources, mapped from the backend citations manifest.
258
+ const citations: CkCitationSource[] = [
259
+ {
260
+ index: 1,
261
+ fileName: 'Q2_Business_Plan.pdf',
262
+ snippet: 'Departments that received customized training saw a 25% higher adoption rate…',
263
+ locationLabel: 'Page 3',
264
+ url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
265
+ },
266
+ {
267
+ index: 2,
268
+ fileName: 'Support-Metrics-Q1.csv',
269
+ snippet: 'Ticket volume fell 18% quarter-over-quarter for the tailored-training cohorts.',
270
+ locationLabel: 'Row 42',
271
+ url: '/api/v1/search-stores/ss_abc/files/file_456/public-urls',
272
+ },
273
+ ];
274
+
275
+ function openSource(url: string) {
276
+ // Source URLs are auth-proxied; the host performs the navigation.
277
+ window.open(url, '_blank', 'noopener');
278
+ }
279
+ </script>
280
+
281
+ <template>
282
+ <CkAgentMessage>
283
+ <template #icon>
284
+ <div class="bg-core-bg-tertiary flex size-6 items-center justify-center rounded-full text-xs">
285
+ AI
286
+ </div>
287
+ </template>
288
+ <CkMarkdownRenderer
289
+ :content="content"
290
+ :citations="citations"
291
+ @open-source="openSource"
292
+ />
293
+ </CkAgentMessage>
294
+ </template>`;
295
+
296
+ /**
297
+ * An assistant reply whose body is markdown containing inline `[n]` citations,
298
+ * rendered through `CkMarkdownRenderer` inside the message. Each `[n]` becomes
299
+ * an interactive `CkCitation` — hover a marker to preview its source. This is
300
+ * the end-to-end shape of a cited answer in a conversation.
301
+ */
302
+ export const WithCitations: Story = {
303
+ render: () => ({
304
+ components: { CkAgentMessage, CkMarkdownRenderer },
305
+ setup() {
306
+ function openSource(url: string) {
307
+ window.open(url, '_blank', 'noopener');
308
+ }
309
+ return { content: CITED_MARKDOWN, citations: CITATIONS, openSource };
310
+ },
311
+ template: `
312
+ <CkAgentMessage>
313
+ <template #icon>
314
+ <div class="bg-core-bg-tertiary flex size-6 items-center justify-center rounded-full text-xs">
315
+ AI
316
+ </div>
317
+ </template>
318
+ <CkMarkdownRenderer
319
+ :content="content"
320
+ :citations="citations"
321
+ @open-source="openSource"
322
+ />
323
+ </CkAgentMessage>
324
+ `,
325
+ }),
326
+ parameters: { docs: { source: { code: withCitationsTemplateSource } } },
327
+ };
@@ -0,0 +1,79 @@
1
+ import CkCitation from './CkCitation.vue';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { render, screen } from '@testing-library/vue';
4
+
5
+ const base = {
6
+ index: 1,
7
+ fileName: 'q3-report.pdf',
8
+ snippet: 'The cited chunk text.',
9
+ locationLabel: 'Page 3',
10
+ url: '/api/v1/.../public-urls',
11
+ };
12
+
13
+ describe('CkCitation', () => {
14
+ it('renders a marker button showing the source number', () => {
15
+ render(CkCitation, { props: base });
16
+
17
+ const marker = screen.getByRole('button', { name: 'Source 1' });
18
+ expect(marker).toHaveTextContent('1');
19
+ });
20
+
21
+ it('derives the accessible name from the index by default', () => {
22
+ render(CkCitation, { props: { ...base, index: 7 } });
23
+
24
+ expect(
25
+ screen.getByRole('button', { name: 'Source 7' }),
26
+ ).toBeInTheDocument();
27
+ });
28
+
29
+ it('uses a custom ariaLabel when provided', () => {
30
+ render(CkCitation, { props: { ...base, ariaLabel: 'Quelle 1' } });
31
+
32
+ expect(
33
+ screen.getByRole('button', { name: 'Quelle 1' }),
34
+ ).toBeInTheDocument();
35
+ });
36
+
37
+ it('reveals the source card on activation and re-emits open-source', async () => {
38
+ const user = userEvent.setup();
39
+ const { emitted } = render(CkCitation, { props: base });
40
+
41
+ // The marker reveals its card on hover, matching the design.
42
+ await user.hover(screen.getByRole('button', { name: 'Source 1' }));
43
+
44
+ // The card content is rendered (in a popover portal) once open.
45
+ expect(await screen.findByText('q3-report.pdf')).toBeInTheDocument();
46
+ expect(screen.getByText('The cited chunk text.')).toBeInTheDocument();
47
+
48
+ await user.click(screen.getByRole('button', { name: 'Open source' }));
49
+
50
+ expect(emitted('open-source')).toEqual([['/api/v1/.../public-urls']]);
51
+ });
52
+
53
+ it('opens the card on keyboard activation of the marker', async () => {
54
+ const user = userEvent.setup();
55
+ render(CkCitation, { props: base });
56
+
57
+ const marker = screen.getByRole('button', { name: 'Source 1' });
58
+ marker.focus();
59
+ await user.keyboard('{Enter}');
60
+
61
+ expect(await screen.findByText('q3-report.pdf')).toBeInTheDocument();
62
+ });
63
+
64
+ it('renders the default (amber) marker state by default', () => {
65
+ render(CkCitation, { props: base });
66
+
67
+ const marker = screen.getByRole('button', { name: 'Source 1' });
68
+ expect(marker.className).toContain('bg-background-accent-yellow');
69
+ expect(marker.className).not.toContain('bg-background-accent-soft');
70
+ });
71
+
72
+ it('renders the selected (teal) marker state when `selected`', () => {
73
+ render(CkCitation, { props: { ...base, selected: true } });
74
+
75
+ const marker = screen.getByRole('button', { name: 'Source 1' });
76
+ expect(marker.className).toContain('bg-background-accent-soft');
77
+ expect(marker.className).not.toContain('bg-background-accent-yellow');
78
+ });
79
+ });
@@ -0,0 +1,136 @@
1
+ import CkCitation from './CkCitation.vue';
2
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
3
+
4
+ const SNIPPET =
5
+ 'Departments that received customized training saw a 25% higher adoption rate of the new tools compared to those with generic training.';
6
+
7
+ /**
8
+ * CkCitation is the inline marker rendered in place of a `[n]` reference in an
9
+ * assistant answer — a numbered chip that reveals a `CkCitationCard` describing
10
+ * the source on hover or keyboard activation (Enter/Space). Drive it through `CkMarkdownRenderer`'s
11
+ * `citations` prop to render cited answers; use `CkCitationCard` on its own for
12
+ * a standalone source preview.
13
+ */
14
+ const meta: Meta<typeof CkCitation> = {
15
+ title: 'Base/CkCitation',
16
+ component: CkCitation,
17
+ tags: ['autodocs'],
18
+ argTypes: {
19
+ index: { control: 'number' },
20
+ selected: { control: 'boolean' },
21
+ fileName: { control: 'text' },
22
+ snippet: { control: 'text' },
23
+ locationLabel: { control: 'text' },
24
+ url: { control: 'text' },
25
+ ariaLabel: { control: 'text' },
26
+ openSourceLabel: { control: 'text' },
27
+ },
28
+ args: {
29
+ index: 1,
30
+ fileName: 'Q2_Business_Plan.pdf',
31
+ snippet: SNIPPET,
32
+ locationLabel: 'Page 3',
33
+ url: '/api/v1/search-stores/ss_abc/files/file_123/public-urls',
34
+ },
35
+ decorators: [
36
+ () => ({
37
+ template:
38
+ '<div style="max-width: 640px; margin: 0 auto; padding: 96px 64px;"><story /></div>',
39
+ }),
40
+ ],
41
+ };
42
+
43
+ export default meta;
44
+ type Story = StoryObj<typeof meta>;
45
+
46
+ /**
47
+ * A single marker in its default (amber) state. Hover the chip (or focus it and
48
+ * press Enter) to reveal the source card.
49
+ */
50
+ export const Default: Story = {};
51
+
52
+ /**
53
+ * The selected (teal) state — set `selected` when the citation's source is the
54
+ * one currently open/highlighted, so the active marker stands out.
55
+ */
56
+ export const Selected: Story = {
57
+ args: { selected: true },
58
+ };
59
+
60
+ /**
61
+ * How markers read inside a sentence — two sources cited next to each other,
62
+ * the way they appear in an assistant answer.
63
+ */
64
+ export const InlineInText: Story = {
65
+ render: (args) => ({
66
+ components: { CkCitation },
67
+ setup() {
68
+ return { args };
69
+ },
70
+ template: `
71
+ <p style="font-size: 14px; line-height: 20px; max-width: 32rem;">
72
+ Departments that received customized training saw a 25% higher adoption
73
+ rate of the new tools compared to those with generic training.<CkCitation
74
+ v-bind="args"
75
+ :index="1"
76
+ /><CkCitation
77
+ v-bind="args"
78
+ :index="2"
79
+ file-name="Goal-planning-26.pdf"
80
+ location-label="Page 12"
81
+ />
82
+ </p>
83
+ `,
84
+ }),
85
+ };
86
+
87
+ /**
88
+ * Multi-digit indices keep the chip legible — the marker grows with its
89
+ * number while staying aligned to the surrounding text.
90
+ */
91
+ export const MultiDigitIndex: Story = {
92
+ args: { index: 12 },
93
+ };
94
+
95
+ /**
96
+ * No `url` — the card still appears on hover but without the external-link
97
+ * action (e.g. an internal chunk with no servable file).
98
+ */
99
+ export const WithoutLink: Story = {
100
+ args: { url: undefined },
101
+ };
102
+
103
+ /**
104
+ * Non-paginated source — the card omits the page footer.
105
+ */
106
+ export const WithoutLocation: Story = {
107
+ args: { locationLabel: undefined },
108
+ };
109
+
110
+ /**
111
+ * A row of markers with different file types — the card's badge tone follows
112
+ * each source's extension.
113
+ */
114
+ export const SourceTypes: Story = {
115
+ render: () => ({
116
+ components: { CkCitation },
117
+ setup() {
118
+ const sources = [
119
+ { index: 1, fileName: 'q3-report.pdf', locationLabel: 'Page 3' },
120
+ { index: 2, fileName: 'README.md' },
121
+ { index: 3, fileName: 'sales-2026.csv', locationLabel: 'Row 42' },
122
+ ];
123
+ return { sources, snippet: SNIPPET };
124
+ },
125
+ template: `
126
+ <p style="font-size: 14px; line-height: 20px;">
127
+ Multiple sources back this claim.<CkCitation
128
+ v-for="s in sources"
129
+ :key="s.index"
130
+ v-bind="s"
131
+ :snippet="snippet"
132
+ />
133
+ </p>
134
+ `,
135
+ }),
136
+ };
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref } from 'vue';
3
+ import {
4
+ UiPopover,
5
+ UiPopoverTrigger,
6
+ UiPopoverContent,
7
+ } from '@aleph-alpha/ui-library';
8
+ import {
9
+ defaultChatKitLabels,
10
+ useChatKitLabels,
11
+ } from '../../../handlers/useChatKitLabels';
12
+ import { CkCitationCard } from '../CkCitationCard';
13
+ import type { CkCitationProps } from './types';
14
+
15
+ defineOptions({
16
+ name: 'CkCitation',
17
+ });
18
+
19
+ const props = withDefaults(defineProps<CkCitationProps>(), {
20
+ selected: false,
21
+ locationLabel: undefined,
22
+ url: undefined,
23
+ ariaLabel: undefined,
24
+ openSourceLabel: undefined,
25
+ });
26
+
27
+ const emit = defineEmits<{
28
+ 'open-source': [url: string];
29
+ }>();
30
+
31
+ const labels = useChatKitLabels();
32
+
33
+ // Resolution order: per-instance prop, then `"{citationSourceLabel} {index}"`
34
+ // from the injected label, then the English default.
35
+ const triggerLabel = computed(
36
+ () =>
37
+ props.ariaLabel ??
38
+ `${labels.value.citationSourceLabel ?? defaultChatKitLabels.citationSourceLabel!} ${props.index}`,
39
+ );
40
+
41
+ // Close delay so the pointer can cross the gap from the marker into the card
42
+ // without it disappearing on the way.
43
+ const CLOSE_DELAY_MS = 80;
44
+
45
+ const open = ref(false);
46
+ // Tracks whether the current open was driven by the pointer (hover) rather than
47
+ // the keyboard, so we can suppress the popover's focus management for hover only.
48
+ const isPointerInteraction = ref(false);
49
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
50
+
51
+ function show(): void {
52
+ if (closeTimer) {
53
+ clearTimeout(closeTimer);
54
+ closeTimer = undefined;
55
+ }
56
+ open.value = true;
57
+ }
58
+
59
+ function scheduleHide(): void {
60
+ if (closeTimer) clearTimeout(closeTimer);
61
+ closeTimer = setTimeout(() => {
62
+ open.value = false;
63
+ closeTimer = undefined;
64
+ }, CLOSE_DELAY_MS);
65
+ }
66
+
67
+ function onPointerEnter(): void {
68
+ isPointerInteraction.value = true;
69
+ show();
70
+ }
71
+
72
+ function onPointerLeave(): void {
73
+ isPointerInteraction.value = false;
74
+ scheduleHide();
75
+ }
76
+
77
+ // Suppress auto-focus for hover opens only (so hovering never steals focus or
78
+ // scrolls). Keyboard opens keep it, moving focus into the card so the source
79
+ // action is reachable.
80
+ function onOpenAutoFocus(event: Event): void {
81
+ if (isPointerInteraction.value) event.preventDefault();
82
+ }
83
+
84
+ // Close once focus leaves the card entirely (keyboard users tabbing out). Moving
85
+ // focus between elements inside the card keeps it open.
86
+ function onContentFocusOut(event: FocusEvent): void {
87
+ const nextTarget = event.relatedTarget as Node | null;
88
+ const content = event.currentTarget as HTMLElement;
89
+ if (!nextTarget || !content.contains(nextTarget)) {
90
+ scheduleHide();
91
+ }
92
+ }
93
+
94
+ onBeforeUnmount(() => {
95
+ if (closeTimer) clearTimeout(closeTimer);
96
+ });
97
+ </script>
98
+
99
+ <template>
100
+ <UiPopover v-model:open="open">
101
+ <UiPopoverTrigger as-child>
102
+ <button
103
+ type="button"
104
+ :aria-label="triggerLabel"
105
+ :class="[
106
+ 'ck-citation mx-px inline-flex h-5 min-w-5 select-none items-center justify-center rounded-sm px-1 align-middle text-sm leading-4 tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
107
+ selected
108
+ ? 'bg-background-accent-soft text-black'
109
+ : 'bg-background-accent-yellow text-background-status-warning',
110
+ ]"
111
+ @mouseenter="onPointerEnter"
112
+ @mouseleave="onPointerLeave"
113
+ >
114
+ {{ index }}
115
+ </button>
116
+ </UiPopoverTrigger>
117
+ <UiPopoverContent
118
+ align="start"
119
+ :side-offset="8"
120
+ class="w-auto border-0 bg-transparent p-0 shadow-none"
121
+ @open-auto-focus="onOpenAutoFocus"
122
+ @mouseenter="onPointerEnter"
123
+ @mouseleave="onPointerLeave"
124
+ @focusin="show"
125
+ @focusout="onContentFocusOut"
126
+ >
127
+ <CkCitationCard
128
+ :file-name="fileName"
129
+ :snippet="snippet"
130
+ :location-label="locationLabel"
131
+ :url="url"
132
+ :open-source-label="openSourceLabel"
133
+ @open-source="(u: string) => emit('open-source', u)"
134
+ />
135
+ </UiPopoverContent>
136
+ </UiPopover>
137
+ </template>
138
+
139
+ <style scoped>
140
+ @media (prefers-reduced-motion: reduce) {
141
+ .ck-citation {
142
+ transition-duration: 0.01ms;
143
+ }
144
+ }
145
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as CkCitation } from './CkCitation.vue';
2
+ export type * from './types';
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A single citation source: the data needed to render a marker and its card.
3
+ * This is the per-source shape the host maps each backend citation into (the
4
+ * `index` matches the `[n]` reference in the answer text) and the element type
5
+ * of `CkMarkdownRenderer`'s `citations` prop.
6
+ *
7
+ * @category Data Display
8
+ * @useCases describe a cited source, build the citations map for an assistant reply
9
+ * @keywords citation, source, reference, file, page, snippet
10
+ * @related CkCitation, CkCitationCard, CkMarkdownRenderer
11
+ */
12
+ export interface CkCitationSource {
13
+ /**
14
+ * 1-based source number rendered in the marker. Matches the `[n]`
15
+ * reference it replaces in the answer text.
16
+ */
17
+ index: number;
18
+ /**
19
+ * Source file name. Title of the card and basis for the file-type badge
20
+ * (pass the original name with its extension).
21
+ */
22
+ fileName: string;
23
+ /** The cited chunk text, shown in the card body. */
24
+ snippet: string;
25
+ /**
26
+ * Location label for the card footer, e.g. `"Page 3"`. Omit for
27
+ * non-paginated sources.
28
+ */
29
+ locationLabel?: string;
30
+ /**
31
+ * Source URL for the card's external-link action. Activating it emits
32
+ * `open-source`. Omit to hide the action.
33
+ */
34
+ url?: string;
35
+ }
36
+
37
+ /**
38
+ * An inline citation marker — the numbered chip rendered in place of a `[n]`
39
+ * reference inside an assistant answer. On hover, or when activated by
40
+ * keyboard (Enter/Space) or click, it reveals a `CkCitationCard` describing
41
+ * the source.
42
+ *
43
+ * The marker shows the bare source number (no brackets). Activating the
44
+ * card's external-link action re-emits `open-source` so the host can open
45
+ * the (possibly auth-proxied) source URL.
46
+ *
47
+ * @category Data Display
48
+ * @useCases inline citation in assistant reply, numbered source reference, hover to preview the cited file/page
49
+ * @keywords citation, source, reference, inline, marker, footnote, number, hover, popover
50
+ * @related CkCitationCard, CkMarkdownRenderer, CkAgentMessage
51
+ */
52
+ export interface CkCitationProps extends CkCitationSource {
53
+ /**
54
+ * Marks the citation as selected/active (e.g. the source it points to is
55
+ * currently open or highlighted). Renders the marker in its selected (teal)
56
+ * state instead of the default amber.
57
+ * @default false
58
+ */
59
+ selected?: boolean;
60
+ /**
61
+ * Accessible label for the marker trigger. When omitted, resolves to
62
+ * `"{citationSourceLabel} {index}"` from `useChatKitLabels` (English default
63
+ * `"Source {index}"`). Set this to override per instance.
64
+ */
65
+ ariaLabel?: string;
66
+ /**
67
+ * Accessible label for the card's external-link action. When omitted,
68
+ * resolves from `useChatKitLabels().citationOpenSource` (English default
69
+ * `"Open source"`).
70
+ */
71
+ openSourceLabel?: string;
72
+ }
73
+
74
+ export interface CkCitationEmits {
75
+ /**
76
+ * Re-emitted from the card when the user opens the source. The payload is
77
+ * `url`.
78
+ */
79
+ (e: 'open-source', url: string): void;
80
+ }
@@ -0,0 +1,77 @@
1
+ import CkCitationCard from './CkCitationCard.vue';
2
+ import { provideChatKitLabels } from '../../../handlers/useChatKitLabels';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { render, screen } from '@testing-library/vue';
5
+ import { defineComponent, h } from 'vue';
6
+
7
+ const base = {
8
+ fileName: 'q3-report.pdf',
9
+ snippet: 'The cited chunk text.',
10
+ };
11
+
12
+ describe('CkCitationCard', () => {
13
+ it('renders the file name and snippet', () => {
14
+ render(CkCitationCard, { props: { ...base, locationLabel: 'Page 3' } });
15
+
16
+ expect(screen.getByText('q3-report.pdf')).toBeInTheDocument();
17
+ expect(screen.getByText('The cited chunk text.')).toBeInTheDocument();
18
+ expect(screen.getByText('Page 3')).toBeInTheDocument();
19
+ });
20
+
21
+ it('hides the location footer when no locationLabel is given', () => {
22
+ render(CkCitationCard, { props: base });
23
+
24
+ expect(screen.queryByText('Page 3')).not.toBeInTheDocument();
25
+ });
26
+
27
+ it('hides the external-link action when no url is provided', () => {
28
+ render(CkCitationCard, { props: base });
29
+
30
+ expect(
31
+ screen.queryByRole('button', { name: 'Open source' }),
32
+ ).not.toBeInTheDocument();
33
+ });
34
+
35
+ it('shows the external-link action and emits open-source with the url', async () => {
36
+ const user = userEvent.setup();
37
+ const { emitted } = render(CkCitationCard, {
38
+ props: { ...base, url: '/api/v1/.../public-urls' },
39
+ });
40
+
41
+ await user.click(screen.getByRole('button', { name: 'Open source' }));
42
+
43
+ expect(emitted('open-source')).toEqual([['/api/v1/.../public-urls']]);
44
+ });
45
+
46
+ it('uses a custom openSourceLabel for the action', () => {
47
+ render(CkCitationCard, {
48
+ props: { ...base, url: '/u', openSourceLabel: 'Quelle öffnen' },
49
+ });
50
+
51
+ expect(
52
+ screen.getByRole('button', { name: 'Quelle öffnen' }),
53
+ ).toBeInTheDocument();
54
+ });
55
+
56
+ it('wraps a long unbroken snippet so it cannot overflow the fixed-width card', () => {
57
+ const snippet = 'x'.repeat(200);
58
+ render(CkCitationCard, { props: { ...base, snippet } });
59
+
60
+ expect(screen.getByText(snippet).className).toContain('break-words');
61
+ });
62
+
63
+ it('uses the injected citationOpenSource label via provideChatKitLabels', () => {
64
+ const Wrapper = defineComponent({
65
+ setup() {
66
+ provideChatKitLabels(() => ({ citationOpenSource: 'Quelle öffnen' }));
67
+ return () => h(CkCitationCard, { ...base, url: '/u' });
68
+ },
69
+ });
70
+
71
+ render(Wrapper);
72
+
73
+ expect(
74
+ screen.getByRole('button', { name: 'Quelle öffnen' }),
75
+ ).toBeInTheDocument();
76
+ });
77
+ });