@aleph-alpha/chat-kit 3.1.1 → 3.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/chat-kit",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Chat kit package for building chat interfaces with Vue",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -67,8 +67,11 @@
67
67
  "dependencies": {
68
68
  "@aleph-alpha/ui-library": "^2.8.3",
69
69
  "dompurify": "^3.3.2",
70
+ "katex": "^0.17.0",
71
+ "rehype-katex": "^7.0.1",
70
72
  "rehype-stringify": "^10.0.1",
71
73
  "remark-gfm": "^4.0.1",
74
+ "remark-math": "^6.0.0",
72
75
  "remark-parse": "^11.0.0",
73
76
  "remark-rehype": "^11.1.2",
74
77
  "unified": "^11.0.5"
@@ -95,9 +98,10 @@
95
98
  "unocss-preset-animations": "^1.3.0",
96
99
  "unocss-preset-shadcn": "^1.0.1",
97
100
  "vite": "^7.3.2",
98
- "vitest": "4.0.15",
101
+ "vitest": "3.2.6",
99
102
  "vue": "^3.5.27",
100
- "vue-tsc": "^3.2.4"
103
+ "vue-tsc": "^3.2.4",
104
+ "@aleph-alpha/tsconfig-frontend": "0.7.0"
101
105
  },
102
106
  "scripts": {
103
107
  "build": "vite build && vue-tsc --project tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
@@ -344,6 +344,56 @@ export const GfmFeatures: Story = {
344
344
  parameters: { docs: { source: { code: gfmFeaturesTemplateSource } } },
345
345
  };
346
346
 
347
+ const mathTemplateSource = `<script setup lang="ts">
348
+ import { CkMarkdownRenderer } from '@aleph-alpha/chat-kit';
349
+ const content = \`# Math Rendering
350
+
351
+ Inline math: $E = mc^2$
352
+
353
+ Display math:
354
+
355
+ $$
356
+ \\\\int_{-\\\\infty}^{\\\\infty} e^{-x^2} dx = \\\\sqrt{\\\\pi}
357
+ $$
358
+
359
+ The quadratic formula is $x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$.
360
+ \`;
361
+ </script>
362
+
363
+ <template>
364
+ <CkMarkdownRenderer :content="content" />
365
+ </template>`;
366
+
367
+ /**
368
+ * Renders inline and display math expressions using KaTeX.
369
+ * Inline math uses single dollar signs ($...$) and display math uses
370
+ * double dollar signs ($$...$$).
371
+ */
372
+ export const MathExpressions: Story = {
373
+ args: {
374
+ content: [
375
+ '# Math Rendering',
376
+ '',
377
+ 'Inline math: $E = mc^2$',
378
+ '',
379
+ 'Display math:',
380
+ '',
381
+ '$$',
382
+ '\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}',
383
+ '$$',
384
+ '',
385
+ 'The quadratic formula is $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$.',
386
+ '',
387
+ 'A matrix:',
388
+ '',
389
+ '$$',
390
+ '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}',
391
+ '$$',
392
+ ].join('\n'),
393
+ },
394
+ parameters: { docs: { source: { code: mathTemplateSource } } },
395
+ };
396
+
347
397
  function visitTextNodes(node: any, transform: (value: string) => string): void {
348
398
  if (node.type === 'text') {
349
399
  node.value = transform(node.value);
@@ -59,7 +59,16 @@ describe('useFileUploadWrapper handleFilesSelected', () => {
59
59
  const method = init?.method ?? 'GET';
60
60
 
61
61
  if (method === 'GET' && url.includes('search-stores')) {
62
- return createMockFetchResponse([{ id: 'store-1' }]);
62
+ return createMockFetchResponse({
63
+ items: [
64
+ {
65
+ id: 'store-1',
66
+ name: 'test-store',
67
+ models: [{ embedding_model: 'test-model' }],
68
+ },
69
+ ],
70
+ total: 1,
71
+ });
63
72
  }
64
73
  if (method === 'POST' && url.includes('/files')) {
65
74
  return createMockFetchResponse([{ id: 'remote-1' }]);
@@ -198,27 +198,43 @@ export function createDocumentUploadProvider(
198
198
  return newStoreId;
199
199
  }
200
200
 
201
- async function resolveSearchStore(): Promise<string> {
202
- if (resolvedStoreId) return resolvedStoreId;
203
- if (resolvePromise) return resolvePromise;
201
+ interface SearchStoreListResponse {
202
+ items: Array<{
203
+ id: string;
204
+ name: string;
205
+ models: Array<{ embedding_model: string }>;
206
+ }>;
207
+ total: number;
208
+ }
204
209
 
205
- resolvePromise = (async () => {
206
- try {
207
- const stores = await httpClient.get<{ id: string }[]>('search-stores');
210
+ async function fetchOrCreateStore(): Promise<string> {
211
+ try {
212
+ const response =
213
+ await httpClient.get<SearchStoreListResponse>('search-stores');
208
214
 
209
- if (stores.length > 0 && stores[0]) {
210
- resolvedStoreId = stores[0].id;
211
- return resolvedStoreId;
212
- }
215
+ const expectedModel = searchStoreConfig.models[0]?.embedding_model;
216
+ const match = response.items.find((store) =>
217
+ store.models.some((m) => m.embedding_model === expectedModel),
218
+ );
213
219
 
214
- resolvedStoreId = await createSearchStoreWithWorkflow();
220
+ if (match) {
221
+ resolvedStoreId = match.id;
215
222
  return resolvedStoreId;
216
- } catch (err) {
217
- resolvePromise = null;
218
- throw err;
219
223
  }
220
- })();
221
224
 
225
+ resolvedStoreId = await createSearchStoreWithWorkflow();
226
+ return resolvedStoreId;
227
+ } catch (err) {
228
+ resolvePromise = null;
229
+ throw err;
230
+ }
231
+ }
232
+
233
+ async function resolveSearchStore(): Promise<string> {
234
+ if (resolvedStoreId) return resolvedStoreId;
235
+ if (resolvePromise) return resolvePromise;
236
+
237
+ resolvePromise = fetchOrCreateStore();
222
238
  return resolvePromise;
223
239
  }
224
240
 
@@ -109,4 +109,35 @@ describe('useMarkdownParser', () => {
109
109
 
110
110
  expect(html.value).toBe('<p>Hello world</p>');
111
111
  });
112
+
113
+ it('renders inline math with single dollar signs', () => {
114
+ const source = ref('The formula $E = mc^2$ is famous.');
115
+ const { html } = useMarkdownParser(source, ref(undefined));
116
+
117
+ expect(html.value).toContain('class="katex"');
118
+ expect(html.value).toContain('E = mc^2');
119
+ });
120
+
121
+ it('renders display math with double dollar signs', () => {
122
+ const source = ref('$$\n\\int_0^1 x^2 dx\n$$');
123
+ const { html } = useMarkdownParser(source, ref(undefined));
124
+
125
+ expect(html.value).toContain('class="katex-display"');
126
+ });
127
+
128
+ it('preserves MathML elements through sanitization', () => {
129
+ const source = ref('$x^2$');
130
+ const { html } = useMarkdownParser(source, ref(undefined));
131
+
132
+ expect(html.value).toContain('<math');
133
+ expect(html.value).toContain('<semantics>');
134
+ });
135
+
136
+ it('does not treat a lone dollar sign as math', () => {
137
+ const source = ref('The price is $5.');
138
+ const { html } = useMarkdownParser(source, ref(undefined));
139
+
140
+ expect(html.value).not.toContain('class="katex"');
141
+ expect(html.value).toContain('$5');
142
+ });
112
143
  });
@@ -2,18 +2,82 @@ import { computed, type MaybeRefOrGetter, toValue } from 'vue';
2
2
  import { unified, type PluggableList } from 'unified';
3
3
  import remarkParse from 'remark-parse';
4
4
  import remarkGfm from 'remark-gfm';
5
+ import remarkMath from 'remark-math';
5
6
  import remarkRehype from 'remark-rehype';
7
+ import rehypeKatex from 'rehype-katex';
6
8
  import rehypeStringify from 'rehype-stringify';
7
- import DOMPurify from 'dompurify';
9
+ import DOMPurify, { type Config } from 'dompurify';
10
+ import 'katex/dist/katex.min.css';
8
11
 
9
12
  type SyncProcessor = { processSync: (value: string) => { toString(): string } };
10
13
 
14
+ const KATEX_MATHML_TAGS = [
15
+ 'math',
16
+ 'semantics',
17
+ 'annotation',
18
+ 'mrow',
19
+ 'mi',
20
+ 'mo',
21
+ 'mn',
22
+ 'ms',
23
+ 'mtext',
24
+ 'mspace',
25
+ 'msup',
26
+ 'msub',
27
+ 'msubsup',
28
+ 'mfrac',
29
+ 'mover',
30
+ 'munder',
31
+ 'munderover',
32
+ 'msqrt',
33
+ 'mroot',
34
+ 'mtable',
35
+ 'mtr',
36
+ 'mtd',
37
+ 'menclose',
38
+ 'mpadded',
39
+ 'mphantom',
40
+ ] as const;
41
+
42
+ const KATEX_MATHML_ATTRS = [
43
+ 'xmlns',
44
+ 'encoding',
45
+ 'mathvariant',
46
+ 'displaystyle',
47
+ 'scriptlevel',
48
+ 'fence',
49
+ 'separator',
50
+ 'stretchy',
51
+ 'symmetric',
52
+ 'lspace',
53
+ 'rspace',
54
+ 'movablelimits',
55
+ 'accent',
56
+ 'accentunder',
57
+ 'linethickness',
58
+ 'columnalign',
59
+ 'rowalign',
60
+ 'columnspan',
61
+ 'rowspan',
62
+ 'width',
63
+ 'height',
64
+ 'depth',
65
+ 'minsize',
66
+ 'maxsize',
67
+ 'aria-hidden',
68
+ ] as const;
69
+
70
+ const SANITIZE_CONFIG: Config = {
71
+ ADD_TAGS: [...KATEX_MATHML_TAGS],
72
+ ADD_ATTR: [...KATEX_MATHML_ATTRS],
73
+ };
74
+
11
75
  function buildProcessor(plugins: PluggableList): SyncProcessor {
12
- let proc = unified().use(remarkParse).use(remarkGfm);
76
+ let proc = unified().use(remarkParse).use(remarkGfm).use(remarkMath);
13
77
  for (const plugin of plugins) {
14
78
  proc = proc.use(plugin as any);
15
79
  }
16
- return proc.use(remarkRehype).use(rehypeStringify);
80
+ return proc.use(remarkRehype).use(rehypeKatex).use(rehypeStringify);
17
81
  }
18
82
 
19
83
  export function useMarkdownParser(
@@ -30,16 +94,16 @@ export function useMarkdownParser(
30
94
  return cachedProcessor;
31
95
  }
32
96
 
33
- const html = computed(() => {
97
+ const html = computed<string>(() => {
34
98
  const markdown = toValue(source);
35
99
  if (!markdown) return '';
36
100
  try {
37
101
  const raw = String(
38
102
  getProcessor(toValue(plugins) ?? []).processSync(markdown),
39
103
  );
40
- return DOMPurify.sanitize(raw);
104
+ return DOMPurify.sanitize(raw, SANITIZE_CONFIG) as string;
41
105
  } catch {
42
- return DOMPurify.sanitize(markdown);
106
+ return DOMPurify.sanitize(markdown, SANITIZE_CONFIG) as string;
43
107
  }
44
108
  });
45
109