@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.
- package/dist/{CkInput.vue_vue_type_script_setup_true_lang-LPrW7CSQ.js → CkInput.vue_vue_type_script_setup_true_lang-BAcBFs6I.js} +302 -71
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +63 -37
- package/dist/chat-kit.css +6 -0
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts +7 -0
- package/dist/components/base/CkAgentMessage/CkAgentMessage.stories.d.ts.map +1 -1
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts +47 -0
- package/dist/components/base/CkCitation/CkCitation.stories.d.ts.map +1 -0
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts +15 -0
- package/dist/components/base/CkCitation/CkCitation.vue.d.ts.map +1 -0
- package/dist/components/base/CkCitation/index.d.ts +3 -0
- package/dist/components/base/CkCitation/index.d.ts.map +1 -0
- package/dist/components/base/CkCitation/types.d.ts +79 -0
- package/dist/components/base/CkCitation/types.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts +51 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.stories.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts +13 -0
- package/dist/components/base/CkCitationCard/CkCitationCard.vue.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/index.d.ts +3 -0
- package/dist/components/base/CkCitationCard/index.d.ts.map +1 -0
- package/dist/components/base/CkCitationCard/types.d.ts +51 -0
- package/dist/components/base/CkCitationCard/types.d.ts.map +1 -0
- package/dist/components/base/CkDropZoneOverlay/CkDropZoneOverlay.vue.d.ts +1 -1
- package/dist/components/base/CkFeedbackDialog/CkFeedbackDialog.vue.d.ts +2 -2
- package/dist/components/base/CkFileItemsList/CkFileItemsList.vue.d.ts +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +15 -0
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +16 -2
- package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/index.d.ts +1 -1
- package/dist/components/base/CkMarkdownRenderer/index.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/types.d.ts +25 -0
- package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
- package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts +17 -0
- package/dist/components/base/CkMarkdownRenderer/useCitationRendering.d.ts.map +1 -0
- package/dist/components/base/CkPromptSendButton/CkPromptSendButton.vue.d.ts +1 -1
- package/dist/components/base/index.d.ts +5 -1
- package/dist/components/base/index.d.ts.map +1 -1
- package/dist/components/composed/CkInput/CkInput.vue.d.ts +1 -1
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +25 -23
- package/dist/composables/fileUpload/types.d.ts +3 -0
- package/dist/composables/fileUpload/types.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUpload.d.ts.map +1 -1
- package/dist/composables/fileUpload/useFileUploadWrapper.d.ts.map +1 -1
- package/dist/composables/index.js +1 -1
- package/dist/handlers/index.d.ts +3 -0
- package/dist/handlers/index.d.ts.map +1 -1
- package/dist/handlers/index.js +4 -1
- package/dist/handlers/useChatKitLabels.d.ts +11 -0
- package/dist/handlers/useChatKitLabels.d.ts.map +1 -1
- package/dist/handlers/useMarkdownParser.d.ts.map +1 -1
- package/dist/helpers/unified/remarkCitations.d.ts +17 -0
- package/dist/helpers/unified/remarkCitations.d.ts.map +1 -0
- package/dist/helpers/unified/remarkNormalizeMath.d.ts +24 -0
- package/dist/helpers/unified/remarkNormalizeMath.d.ts.map +1 -0
- package/dist/{index-DVxCNAs3.js → index-C6njJ8EB.js} +96 -49
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -26
- package/dist/{useChatKitLabels-Ctz1dHpx.js → useChatKitLabels-BM2I7-9o.js} +126 -3
- package/package.json +6 -3
- package/src/adapters/responses-api.spec.ts +156 -0
- package/src/adapters/responses-api.ts +125 -58
- package/src/components/base/CkAgentMessage/CkAgentMessage.stories.ts +112 -0
- package/src/components/base/CkCitation/CkCitation.spec.ts +79 -0
- package/src/components/base/CkCitation/CkCitation.stories.ts +136 -0
- package/src/components/base/CkCitation/CkCitation.vue +145 -0
- package/src/components/base/CkCitation/index.ts +2 -0
- package/src/components/base/CkCitation/types.ts +80 -0
- package/src/components/base/CkCitationCard/CkCitationCard.spec.ts +77 -0
- package/src/components/base/CkCitationCard/CkCitationCard.stories.ts +144 -0
- package/src/components/base/CkCitationCard/CkCitationCard.vue +76 -0
- package/src/components/base/CkCitationCard/index.ts +2 -0
- package/src/components/base/CkCitationCard/types.ts +51 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +124 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +118 -0
- package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +70 -28
- package/src/components/base/CkMarkdownRenderer/index.ts +1 -0
- package/src/components/base/CkMarkdownRenderer/types.ts +26 -0
- package/src/components/base/CkMarkdownRenderer/useCitationRendering.spec.ts +94 -0
- package/src/components/base/CkMarkdownRenderer/useCitationRendering.ts +52 -0
- package/src/components/base/index.ts +12 -0
- package/src/components/index.ts +8 -0
- package/src/composables/fileUpload/types.ts +6 -0
- package/src/composables/fileUpload/useFileUpload.spec.ts +2 -61
- package/src/composables/fileUpload/useFileUpload.ts +9 -10
- package/src/composables/fileUpload/useFileUploadWrapper.spec.ts +241 -10
- package/src/composables/fileUpload/useFileUploadWrapper.ts +126 -53
- package/src/handlers/index.ts +6 -0
- package/src/handlers/useChatKitLabels.ts +13 -0
- package/src/handlers/useMarkdownParser.spec.ts +34 -0
- package/src/handlers/useMarkdownParser.ts +15 -3
- package/src/helpers/unified/remarkCitations.spec.ts +112 -0
- package/src/helpers/unified/remarkCitations.ts +55 -0
- package/src/helpers/unified/remarkNormalizeMath.spec.ts +84 -0
- package/src/helpers/unified/remarkNormalizeMath.ts +134 -0
- 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,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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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,
|
|
47
|
-
.filter((c): c is
|
|
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
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
112
|
-
.filter((c): c is
|
|
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,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
|
+
});
|