@seed-ship/mcp-ui-solid 5.5.1 → 5.7.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/CHANGELOG.md +86 -0
- package/dist/components/FormRenderer.cjs +13 -2
- package/dist/components/FormRenderer.cjs.map +1 -1
- package/dist/components/FormRenderer.d.ts.map +1 -1
- package/dist/components/FormRenderer.js +13 -2
- package/dist/components/FormRenderer.js.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.cjs +11 -0
- package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.d.ts.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.js +11 -0
- package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
- package/dist/components/StreamingUIRenderer.cjs +49 -3
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +51 -5
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +102 -14
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts +36 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +104 -16
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUITelemetryContext.cjs +25 -0
- package/dist/context/MCPUITelemetryContext.cjs.map +1 -0
- package/dist/context/MCPUITelemetryContext.d.ts +36 -0
- package/dist/context/MCPUITelemetryContext.d.ts.map +1 -0
- package/dist/context/MCPUITelemetryContext.js +25 -0
- package/dist/context/MCPUITelemetryContext.js.map +1 -0
- package/dist/index.cjs +7 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +25 -6
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +25 -6
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/services/telemetry.cjs +56 -0
- package/dist/services/telemetry.cjs.map +1 -0
- package/dist/services/telemetry.d.ts +87 -0
- package/dist/services/telemetry.d.ts.map +1 -0
- package/dist/services/telemetry.js +56 -0
- package/dist/services/telemetry.js.map +1 -0
- package/dist/services/validation.cjs +25 -24
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +26 -25
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +26 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +26 -0
- package/dist/types.d.ts +26 -0
- package/docs/briefs/BRIEF-citations-in-table-cells.md +365 -0
- package/package.json +3 -3
- package/src/components/FormRenderer.tsx +14 -0
- package/src/components/GenerativeUIErrorBoundary.tsx +17 -1
- package/src/components/StreamingUIRenderer.tsx +55 -3
- package/src/components/TableRenderer.citation.test.tsx +157 -0
- package/src/components/UIResourceRenderer.tsx +212 -15
- package/src/context/MCPUITelemetryContext.test.tsx +119 -0
- package/src/context/MCPUITelemetryContext.tsx +71 -0
- package/src/index.ts +20 -0
- package/src/services/telemetry.test.ts +134 -0
- package/src/services/telemetry.ts +149 -0
- package/src/services/validation.ts +43 -41
- package/src/types/index.ts +30 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { Show, For, createSignal, onMount } from 'solid-js'
|
|
24
|
+
import { Show, For, createSignal, onMount, onCleanup } from 'solid-js'
|
|
25
25
|
import { useStreamingUI, type UseStreamingUIOptions } from '../hooks/useStreamingUI'
|
|
26
26
|
import type { UIComponent, RendererError } from '../types'
|
|
27
27
|
import { validateComponent } from '../services/validation'
|
|
28
28
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
29
|
-
import { markRenderStart, markRenderEnd } from '../utils/perf'
|
|
29
|
+
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
30
|
+
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
30
31
|
import type { ValidationErrorMode } from './UIResourceRenderer'
|
|
31
32
|
|
|
32
33
|
export interface StreamingUIRendererProps extends UseStreamingUIOptions {
|
|
@@ -53,7 +54,47 @@ function StreamingComponentRenderer(props: {
|
|
|
53
54
|
}) {
|
|
54
55
|
// Performance marks (v5.4.0) — see utils/perf.ts
|
|
55
56
|
markRenderStart(props.component.id)
|
|
56
|
-
|
|
57
|
+
|
|
58
|
+
// Telemetry sink (B.5 — v5.6.0). Same wiring as ComponentRenderer in
|
|
59
|
+
// UIResourceRenderer.tsx — null when no Provider, no-op everywhere then.
|
|
60
|
+
const telemetry = useTelemetry()
|
|
61
|
+
|
|
62
|
+
onMount(() => {
|
|
63
|
+
markRenderEnd(props.component.id)
|
|
64
|
+
if (telemetry) {
|
|
65
|
+
const ts = Date.now()
|
|
66
|
+
telemetry.dispatch({
|
|
67
|
+
type: 'component:mounted',
|
|
68
|
+
id: props.component.id,
|
|
69
|
+
componentType: props.component.type,
|
|
70
|
+
ts,
|
|
71
|
+
})
|
|
72
|
+
if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
|
|
73
|
+
const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
|
|
74
|
+
const last = entries[entries.length - 1]
|
|
75
|
+
if (last) {
|
|
76
|
+
telemetry.dispatch({
|
|
77
|
+
type: 'component:rendered',
|
|
78
|
+
id: props.component.id,
|
|
79
|
+
componentType: props.component.type,
|
|
80
|
+
durationMs: last.duration,
|
|
81
|
+
ts,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
onCleanup(() => {
|
|
89
|
+
if (telemetry) {
|
|
90
|
+
telemetry.dispatch({
|
|
91
|
+
type: 'component:unmounted',
|
|
92
|
+
id: props.component.id,
|
|
93
|
+
componentType: props.component.type,
|
|
94
|
+
ts: Date.now(),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
})
|
|
57
98
|
|
|
58
99
|
// Validate component before rendering
|
|
59
100
|
const validation = validateComponent(props.component)
|
|
@@ -65,6 +106,17 @@ function StreamingComponentRenderer(props: {
|
|
|
65
106
|
details: validation.errors,
|
|
66
107
|
})
|
|
67
108
|
|
|
109
|
+
if (telemetry) {
|
|
110
|
+
telemetry.dispatch({
|
|
111
|
+
type: 'validation:failed',
|
|
112
|
+
id: props.component.id,
|
|
113
|
+
componentType: props.component.type,
|
|
114
|
+
errorCount: validation.errors?.length ?? 0,
|
|
115
|
+
firstErrorCode: validation.errors?.[0]?.code ?? null,
|
|
116
|
+
ts: Date.now(),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
68
120
|
const mode: ValidationErrorMode = props.errorMode ?? 'block'
|
|
69
121
|
const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
|
|
70
122
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation chip rendering inside `<TableRenderer>` cells (v5.7.0).
|
|
3
|
+
*
|
|
4
|
+
* Spec: `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
|
|
5
|
+
*
|
|
6
|
+
* Tests are split between the pure `renderCellValue(value, citationCtx)`
|
|
7
|
+
* helper (fast, no DOM) and a couple of integration assertions on a real
|
|
8
|
+
* `<UIResourceRenderer>` mount to catch wiring bugs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
12
|
+
import { render, cleanup } from '@solidjs/testing-library'
|
|
13
|
+
import { renderCellValue, UIResourceRenderer } from './UIResourceRenderer'
|
|
14
|
+
import type { CitationCtx } from './UIResourceRenderer'
|
|
15
|
+
import type { UIComponent, TableComponentParams } from '../types'
|
|
16
|
+
|
|
17
|
+
const baseMap: CitationCtx['map'] = {
|
|
18
|
+
'1': { page: 5, file: 'A.pdf' },
|
|
19
|
+
'2': { page: 12, file: 'B.pdf' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('renderCellValue — citation transform (v5.7.0)', () => {
|
|
23
|
+
it('NO citationCtx → cell text is unchanged (regression)', () => {
|
|
24
|
+
expect(renderCellValue('[1] ; [4]')).toBe('[1] ; [4]')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('citationCtx with mapped id → cell HTML carries data-citation-page + data-citation-doc', () => {
|
|
28
|
+
const html = renderCellValue('[1]', { map: baseMap })
|
|
29
|
+
expect(html).toContain('data-citation-page="5"')
|
|
30
|
+
expect(html).toContain('data-citation-doc="A.pdf"')
|
|
31
|
+
expect(html).toContain('data-citation-verified="true"')
|
|
32
|
+
expect(html).toContain('class="citation-ref')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('multi-citation cell → multiple chips emitted', () => {
|
|
36
|
+
const html = renderCellValue('[1] ; [2]', { map: baseMap })
|
|
37
|
+
const matches = html.match(/data-citation-page="(\d+)"/g) ?? []
|
|
38
|
+
expect(matches).toHaveLength(2)
|
|
39
|
+
expect(html).toContain('data-citation-page="5"')
|
|
40
|
+
expect(html).toContain('data-citation-page="12"')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('unresolved id with NON-EMPTY map → marker dropped silently (likely hallucination)', () => {
|
|
44
|
+
const html = renderCellValue('[99]', { map: baseMap })
|
|
45
|
+
expect(html).not.toContain('99')
|
|
46
|
+
expect(html).not.toContain('citation-ref')
|
|
47
|
+
expect(html).not.toContain('réf')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('unresolved id with EMPTY map → human-visible `[réf. N]` placeholder', () => {
|
|
51
|
+
const html = renderCellValue('[99]', { map: {} })
|
|
52
|
+
expect(html).toContain('[réf. 99]')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('citationRender override → wins over default chip shape', () => {
|
|
56
|
+
const html = renderCellValue('[1]', {
|
|
57
|
+
map: baseMap,
|
|
58
|
+
render: (id, mapping) => `<a class="custom-chip" data-id="${id}">${mapping?.file ?? '?'}</a>`,
|
|
59
|
+
})
|
|
60
|
+
expect(html).toContain('class="custom-chip"')
|
|
61
|
+
expect(html).toContain('data-id="1"')
|
|
62
|
+
expect(html).not.toContain('data-citation-verified')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('`[p.5]` page form → NOT touched (negative lookbehind)', () => {
|
|
66
|
+
const html = renderCellValue('See [p.5]', { map: baseMap })
|
|
67
|
+
expect(html).toContain('[p.5]')
|
|
68
|
+
expect(html).not.toContain('data-citation-page')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('`[text](url)` markdown link → NOT touched (citation regex skips parens)', () => {
|
|
72
|
+
const html = renderCellValue('[click](https://example.com)', { map: baseMap })
|
|
73
|
+
expect(html).toContain('href="https://example.com"')
|
|
74
|
+
expect(html).not.toContain('data-citation-page')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('mixed `**bold** [1]` → bold becomes <strong> AND chip is rendered (compose)', () => {
|
|
78
|
+
const html = renderCellValue('**MSP** [1]', { map: baseMap })
|
|
79
|
+
expect(html).toContain('<strong>MSP</strong>')
|
|
80
|
+
expect(html).toContain('data-citation-page="5"')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('canonical `[📄 CITATION 1]` marker → chip emitted directly (no normalize step needed)', () => {
|
|
84
|
+
const html = renderCellValue('[📄 CITATION 1]', { map: baseMap })
|
|
85
|
+
expect(html).toContain('data-citation-page="5"')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('all 3 data-citation-* attrs survive DOMPurify (whitelist intact)', () => {
|
|
89
|
+
const html = renderCellValue('[1]', { map: baseMap })
|
|
90
|
+
expect(html).toContain('data-citation-page')
|
|
91
|
+
expect(html).toContain('data-citation-doc')
|
|
92
|
+
expect(html).toContain('data-citation-verified')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('chip emits a button element (host click delegation target)', () => {
|
|
96
|
+
const html = renderCellValue('[1]', { map: baseMap })
|
|
97
|
+
expect(html).toContain('<button')
|
|
98
|
+
expect(html).toMatch(/<button[^>]*data-citation-page="5"/)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('<TableRenderer> — citationMap wiring (v5.7.0)', () => {
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
cleanup()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function tableComponent(params: Partial<TableComponentParams>): UIComponent {
|
|
108
|
+
return {
|
|
109
|
+
id: 'tbl-cit',
|
|
110
|
+
type: 'table',
|
|
111
|
+
position: { colStart: 1, colSpan: 12 },
|
|
112
|
+
params: {
|
|
113
|
+
columns: [
|
|
114
|
+
{ key: 'name', label: 'Name' },
|
|
115
|
+
{ key: 'cites', label: 'Citations' },
|
|
116
|
+
],
|
|
117
|
+
rows: [
|
|
118
|
+
{ name: 'MSP', cites: '[1] ; [2]' },
|
|
119
|
+
{ name: 'Other', cites: '[1]' },
|
|
120
|
+
],
|
|
121
|
+
...params,
|
|
122
|
+
} as TableComponentParams,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('NO citationMap → cells render plain text (regression)', () => {
|
|
127
|
+
const { container } = render(() => (
|
|
128
|
+
<UIResourceRenderer content={tableComponent({})} />
|
|
129
|
+
))
|
|
130
|
+
const buttons = container.querySelectorAll('[data-citation-page]')
|
|
131
|
+
expect(buttons.length).toBe(0)
|
|
132
|
+
expect(container.textContent).toContain('[1] ; [2]')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('with citationMap → DOM has clickable chips per resolved marker', () => {
|
|
136
|
+
const { container } = render(() => (
|
|
137
|
+
<UIResourceRenderer content={tableComponent({ citationMap: baseMap })} />
|
|
138
|
+
))
|
|
139
|
+
const buttons = container.querySelectorAll('button[data-citation-page]')
|
|
140
|
+
// Row 1 has 2 markers, row 2 has 1 → 3 chips total
|
|
141
|
+
expect(buttons.length).toBe(3)
|
|
142
|
+
const pages = Array.from(buttons).map((b) => b.getAttribute('data-citation-page'))
|
|
143
|
+
expect(pages.sort()).toEqual(['12', '5', '5'])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('with citationRender override → custom chips replace defaults', () => {
|
|
147
|
+
const customRender = (id: number) => `<a class="my-chip" data-id="${id}">x</a>`
|
|
148
|
+
const { container } = render(() => (
|
|
149
|
+
<UIResourceRenderer
|
|
150
|
+
content={tableComponent({ citationMap: baseMap, citationRender: customRender })}
|
|
151
|
+
/>
|
|
152
|
+
))
|
|
153
|
+
const customs = container.querySelectorAll('a.my-chip')
|
|
154
|
+
expect(customs.length).toBe(3)
|
|
155
|
+
expect(container.querySelector('[data-citation-page]')).toBeNull()
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import DOMPurify from 'dompurify'
|
|
7
|
-
import { Component, createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js'
|
|
7
|
+
import { Component, createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js'
|
|
8
8
|
import { isServer } from 'solid-js/web'
|
|
9
9
|
import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } from '../types'
|
|
10
10
|
import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
|
|
11
11
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
12
|
-
import { markRenderStart, markRenderEnd } from '../utils/perf'
|
|
12
|
+
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
13
|
+
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* How `<UIResourceRenderer>` reacts when `validateComponent()` rejects a
|
|
@@ -286,7 +287,98 @@ export function highlightQuery(html: string, query: string): string {
|
|
|
286
287
|
})
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Citation context — opt-in input to `renderCellValue` (v5.7.0).
|
|
292
|
+
*
|
|
293
|
+
* When passed, `[N]`, `Citation [N]`, `[CITATION N]` and `[📄 CITATION N]`
|
|
294
|
+
* markers in the cell text are normalized then replaced with chip HTML.
|
|
295
|
+
* Chips carry `data-citation-page`, `data-citation-doc`, and
|
|
296
|
+
* `data-citation-verified` attributes (already in the DOMPurify whitelist
|
|
297
|
+
* since v5.6.0) so a host's `target.closest('[data-citation-page]')`
|
|
298
|
+
* delegated click handler routes the click to the source-doc panel.
|
|
299
|
+
*
|
|
300
|
+
* See `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
|
|
301
|
+
*/
|
|
302
|
+
export interface CitationCtx {
|
|
303
|
+
/**
|
|
304
|
+
* `Record<id, mapping>` keyed by the citation marker number (string-keyed
|
|
305
|
+
* because JSON serialization always produces strings; the runtime call
|
|
306
|
+
* sites accept either number or string ids and normalize internally).
|
|
307
|
+
*/
|
|
308
|
+
map: Record<string | number, { page: number | string; file?: string; file_id?: number | string }>
|
|
309
|
+
/**
|
|
310
|
+
* Optional override returning sanitized chip HTML for one marker. Wins
|
|
311
|
+
* over the default `defaultCitationChip` shape. Function inputs are
|
|
312
|
+
* intentionally `any`-loose so consumers can swap shapes (e.g. web
|
|
313
|
+
* citations vs doc citations) without subtyping the entry shape here.
|
|
314
|
+
*/
|
|
315
|
+
render?: (
|
|
316
|
+
id: number,
|
|
317
|
+
mapping: { page: number | string; file?: string; file_id?: number | string } | undefined
|
|
318
|
+
) => string
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Default chip HTML emitted by `transformCellCitations` when no
|
|
323
|
+
* `citationRender` override is supplied. Neutral Tailwind classes — hosts
|
|
324
|
+
* can override visual styling via the `.citation-ref` CSS class without
|
|
325
|
+
* passing a render override.
|
|
326
|
+
*/
|
|
327
|
+
function defaultCitationChip(
|
|
328
|
+
pageNum: number | string,
|
|
329
|
+
fileName: string,
|
|
330
|
+
verified = true
|
|
331
|
+
): string {
|
|
332
|
+
const safeDocName = encodeURIComponent(fileName || '')
|
|
333
|
+
const label = fileName ? `${fileName} - ${pageNum}` : `${pageNum}`
|
|
334
|
+
if (!verified) {
|
|
335
|
+
return `<span class="citation-ref inline-flex items-center gap-0.5 align-middle opacity-60"><span class="text-gray-500 line-through">[${label}]</span></span>`
|
|
336
|
+
}
|
|
337
|
+
return [
|
|
338
|
+
'<span class="citation-ref inline-flex items-center gap-0.5 align-middle">',
|
|
339
|
+
`<span class="text-gray-500">[${label}]</span>`,
|
|
340
|
+
'<button class="inline-flex items-center ml-0.5 px-1 py-0.5 text-xs bg-gray-800 hover:bg-gray-700 border border-gray-600 hover:border-teal-500 rounded cursor-pointer transition-colors align-middle"',
|
|
341
|
+
` data-citation-page="${pageNum}"`,
|
|
342
|
+
` data-citation-doc="${safeDocName}"`,
|
|
343
|
+
' data-citation-verified="true"',
|
|
344
|
+
` title="View source - ${label}">`,
|
|
345
|
+
'<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
|
|
346
|
+
'</button>',
|
|
347
|
+
'</span>',
|
|
348
|
+
].join('')
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Normalize bare `[N]`, `Citation [N]`, `[CITATION N]` markers to canonical
|
|
353
|
+
* `[📄 CITATION N]` then replace each canonical marker with chip HTML.
|
|
354
|
+
*
|
|
355
|
+
* Negative lookbehind `(?<![p.])` skips `[p.5]` (page form). Negative
|
|
356
|
+
* lookahead `(?!\()` skips `[text](url)` markdown links.
|
|
357
|
+
*/
|
|
358
|
+
function transformCellCitations(text: string, ctx: CitationCtx): string {
|
|
359
|
+
// 1. normalize bare [N] / Citation [N] / [CITATION N] → [📄 CITATION N]
|
|
360
|
+
let out = text.replace(/(?<![p.])\[(\d{1,2})\](?!\()/g, '[📄 CITATION $1]')
|
|
361
|
+
out = out.replace(/\bCitations?\s*\[(\d+)\]/gi, '[📄 CITATION $1]')
|
|
362
|
+
out = out.replace(/\[CITATION\s+(\d+)\]/gi, '[📄 CITATION $1]')
|
|
363
|
+
|
|
364
|
+
// 2. replace each canonical marker with chip HTML
|
|
365
|
+
return out.replace(
|
|
366
|
+
/[【[]\s*📄\s*CITATION\s*(\d+)\s*[】\]]/gi,
|
|
367
|
+
(_m, idStr) => {
|
|
368
|
+
const id = parseInt(idStr, 10)
|
|
369
|
+
const mapping = ctx.map[id] ?? ctx.map[String(id)]
|
|
370
|
+
if (ctx.render) return ctx.render(id, mapping)
|
|
371
|
+
if (mapping) return defaultCitationChip(mapping.page, mapping.file ?? '', true)
|
|
372
|
+
// Unresolved id: when the map is non-empty (consumer claims to know
|
|
373
|
+
// the citations), drop silently — it's likely an LLM hallucination.
|
|
374
|
+
// When the map is empty (consumer didn't supply one), preserve a
|
|
375
|
+
// human-visible placeholder so the marker isn't lost.
|
|
376
|
+
return Object.keys(ctx.map).length > 0 ? '' : `[réf. ${id}]`
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
|
|
290
382
|
// Handle null/undefined
|
|
291
383
|
if (value === null || value === undefined) {
|
|
292
384
|
return '-'
|
|
@@ -330,7 +422,9 @@ export function renderCellValue(value: any): string {
|
|
|
330
422
|
return '-'
|
|
331
423
|
}
|
|
332
424
|
|
|
333
|
-
// Detect and convert markdown links: [text](url)
|
|
425
|
+
// Detect and convert markdown links: [text](url) — runs FIRST because
|
|
426
|
+
// the citation transform's negative lookahead `(?!\()` would also skip
|
|
427
|
+
// these, but handling them here keeps the existing return path.
|
|
334
428
|
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g
|
|
335
429
|
if (markdownLinkRegex.test(strValue)) {
|
|
336
430
|
// Replace all markdown links with HTML links
|
|
@@ -341,8 +435,36 @@ export function renderCellValue(value: any): string {
|
|
|
341
435
|
return DOMPurify.sanitize(htmlValue, { ADD_ATTR: ['target', 'rel'] })
|
|
342
436
|
}
|
|
343
437
|
|
|
438
|
+
// v5.7.0 — citation transform (opt-in). Replaces `[N]` style markers
|
|
439
|
+
// with chip HTML carrying `data-citation-*` attrs. Runs BEFORE the
|
|
440
|
+
// hasHtml / hasMarkdown branches so the resulting string flows through
|
|
441
|
+
// them naturally (chips are inline HTML; surviving markdown like
|
|
442
|
+
// **bold** is preserved by marked.parse since marked passes inline HTML
|
|
443
|
+
// through unchanged).
|
|
444
|
+
if (citationCtx) {
|
|
445
|
+
strValue = transformCellCitations(strValue, citationCtx)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Markdown markers WITHOUT square brackets — `[` and `]` were excluded
|
|
449
|
+
// because chip labels (`[Doc - 5]`) and unresolved-marker fallbacks
|
|
450
|
+
// (`[réf. 12]`) would otherwise force a marked.parse for cells that
|
|
451
|
+
// have no actual markdown. The hasMarkdown check ALSO runs before
|
|
452
|
+
// hasHtml so that mixed cells (`**bold** [1]` → `**bold** <chip>`)
|
|
453
|
+
// get marked first; marked preserves the inline chip HTML, then
|
|
454
|
+
// DOMPurify keeps the citation attrs via the extended whitelist.
|
|
455
|
+
const hasMarkdown = /[*_`#]/.test(strValue)
|
|
456
|
+
if (hasMarkdown) {
|
|
457
|
+
const parsed = marked.parse(strValue, { async: false }) as string
|
|
458
|
+
return DOMPurify.sanitize(parsed, {
|
|
459
|
+
ALLOWED_TAGS: ['a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br', 'button', 'svg', 'path', 'p', 'ul', 'ol', 'li', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
|
460
|
+
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'data-citation-page', 'data-citation-source', 'data-citation-doc', 'data-citation-verified', 'title', 'fill', 'stroke', 'viewBox', 'stroke-linecap', 'stroke-linejoin', 'stroke-width', 'd'],
|
|
461
|
+
ADD_ATTR: ['target', 'rel'],
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
344
465
|
// Detect raw HTML in cell values (e.g. <a href="..." data-citation-page="5">text</a>)
|
|
345
466
|
// This handles cases where cell data comes from innerHTML extraction
|
|
467
|
+
// OR where the citation transform above injected chip HTML.
|
|
346
468
|
const hasHtml = /<[a-z][\s\S]*>/i.test(strValue)
|
|
347
469
|
if (hasHtml) {
|
|
348
470
|
return DOMPurify.sanitize(strValue, {
|
|
@@ -352,14 +474,6 @@ export function renderCellValue(value: any): string {
|
|
|
352
474
|
})
|
|
353
475
|
}
|
|
354
476
|
|
|
355
|
-
// Check if value contains markdown formatting (bold, italic, code, etc.)
|
|
356
|
-
const hasMarkdown = /[*_`[\]#]/.test(strValue)
|
|
357
|
-
if (hasMarkdown) {
|
|
358
|
-
// Parse with marked and sanitize
|
|
359
|
-
const parsed = marked.parse(strValue, { async: false }) as string
|
|
360
|
-
return DOMPurify.sanitize(parsed, { ADD_ATTR: ['target', 'rel'] })
|
|
361
|
-
}
|
|
362
|
-
|
|
363
477
|
// Plain text — sanitize to prevent XSS via innerHTML
|
|
364
478
|
return DOMPurify.sanitize(strValue)
|
|
365
479
|
}
|
|
@@ -375,6 +489,14 @@ function TableRenderer(props: {
|
|
|
375
489
|
const tableParams = props.component.params as any
|
|
376
490
|
let scrollContainerRef: HTMLDivElement | undefined
|
|
377
491
|
|
|
492
|
+
// v5.7.0 — opt-in citation chip rendering inside cells. When `citationMap`
|
|
493
|
+
// is present in params, build a CitationCtx once and thread it through
|
|
494
|
+
// every `renderCellValue` call below. Absent → undefined → cells render
|
|
495
|
+
// as before (regression-safe).
|
|
496
|
+
const citationCtx: CitationCtx | undefined = tableParams.citationMap
|
|
497
|
+
? { map: tableParams.citationMap, render: tableParams.citationRender }
|
|
498
|
+
: undefined
|
|
499
|
+
|
|
378
500
|
// ─── Client-side sorting (v4.0.5) ────────────────────────
|
|
379
501
|
const allRows = () => tableParams.rows || []
|
|
380
502
|
const columns = () => tableParams.columns || []
|
|
@@ -637,7 +759,7 @@ function TableRenderer(props: {
|
|
|
637
759
|
<For each={tableParams.columns}>
|
|
638
760
|
{(column: any) => (
|
|
639
761
|
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
|
|
640
|
-
<div innerHTML={highlightQuery(renderCellValue(row[column.key]), debouncedQuery())} />
|
|
762
|
+
<div innerHTML={highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
|
|
641
763
|
</td>
|
|
642
764
|
)}
|
|
643
765
|
</For>
|
|
@@ -676,7 +798,7 @@ function TableRenderer(props: {
|
|
|
676
798
|
<For each={tableParams.columns}>
|
|
677
799
|
{(column: any) => (
|
|
678
800
|
<td class="px-6 py-4 text-sm text-gray-700 dark:text-gray-200 whitespace-normal break-words leading-relaxed first:pl-6 last:pr-6">
|
|
679
|
-
<div innerHTML={highlightQuery(renderCellValue(row[column.key]), debouncedQuery())} />
|
|
801
|
+
<div innerHTML={highlightQuery(renderCellValue(row[column.key], citationCtx), debouncedQuery())} />
|
|
680
802
|
</td>
|
|
681
803
|
)}
|
|
682
804
|
</For>
|
|
@@ -1133,7 +1255,51 @@ function ComponentRenderer(props: {
|
|
|
1133
1255
|
// Performance marks — visible in Chrome DevTools "Performance" panel under
|
|
1134
1256
|
// user timings. Always-on, SSR-safe (see utils/perf.ts).
|
|
1135
1257
|
markRenderStart(props.component.id)
|
|
1136
|
-
|
|
1258
|
+
|
|
1259
|
+
// Telemetry sink (B.5 — v5.6.0). null when no MCPUITelemetryProvider is
|
|
1260
|
+
// mounted above; null-checked at every dispatch site so apps that don't
|
|
1261
|
+
// opt in see zero behavior change.
|
|
1262
|
+
const telemetry = useTelemetry()
|
|
1263
|
+
|
|
1264
|
+
onMount(() => {
|
|
1265
|
+
markRenderEnd(props.component.id)
|
|
1266
|
+
if (telemetry) {
|
|
1267
|
+
const ts = Date.now()
|
|
1268
|
+
telemetry.dispatch({
|
|
1269
|
+
type: 'component:mounted',
|
|
1270
|
+
id: props.component.id,
|
|
1271
|
+
componentType: props.component.type,
|
|
1272
|
+
ts,
|
|
1273
|
+
})
|
|
1274
|
+
// Read the perf measure we just emitted to surface durationMs without
|
|
1275
|
+
// double-measuring. The measure may be missing if `performance` is
|
|
1276
|
+
// unavailable (SSR) — skip rendered event in that case.
|
|
1277
|
+
if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
|
|
1278
|
+
const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
|
|
1279
|
+
const last = entries[entries.length - 1]
|
|
1280
|
+
if (last) {
|
|
1281
|
+
telemetry.dispatch({
|
|
1282
|
+
type: 'component:rendered',
|
|
1283
|
+
id: props.component.id,
|
|
1284
|
+
componentType: props.component.type,
|
|
1285
|
+
durationMs: last.duration,
|
|
1286
|
+
ts,
|
|
1287
|
+
})
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
onCleanup(() => {
|
|
1294
|
+
if (telemetry) {
|
|
1295
|
+
telemetry.dispatch({
|
|
1296
|
+
type: 'component:unmounted',
|
|
1297
|
+
id: props.component.id,
|
|
1298
|
+
componentType: props.component.type,
|
|
1299
|
+
ts: Date.now(),
|
|
1300
|
+
})
|
|
1301
|
+
}
|
|
1302
|
+
})
|
|
1137
1303
|
|
|
1138
1304
|
// Validate component before rendering
|
|
1139
1305
|
const validation = validateComponent(props.component)
|
|
@@ -1145,6 +1311,19 @@ function ComponentRenderer(props: {
|
|
|
1145
1311
|
details: validation.errors,
|
|
1146
1312
|
})
|
|
1147
1313
|
|
|
1314
|
+
// Privacy: only counts + first error code, NO error messages or paths
|
|
1315
|
+
// (which could leak payload data — §M.6.2 hard rule).
|
|
1316
|
+
if (telemetry) {
|
|
1317
|
+
telemetry.dispatch({
|
|
1318
|
+
type: 'validation:failed',
|
|
1319
|
+
id: props.component.id,
|
|
1320
|
+
componentType: props.component.type,
|
|
1321
|
+
errorCount: validation.errors?.length ?? 0,
|
|
1322
|
+
firstErrorCode: validation.errors?.[0]?.code ?? null,
|
|
1323
|
+
ts: Date.now(),
|
|
1324
|
+
})
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1148
1327
|
const mode: ValidationErrorMode = props.errorMode ?? 'block'
|
|
1149
1328
|
const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
|
|
1150
1329
|
|
|
@@ -1264,9 +1443,27 @@ function ComponentRenderer(props: {
|
|
|
1264
1443
|
function ActionRenderer(props: { component: UIComponent }) {
|
|
1265
1444
|
const params = props.component.params as any
|
|
1266
1445
|
const { execute, isExecuting } = useAction()
|
|
1446
|
+
const telemetry = useTelemetry()
|
|
1447
|
+
|
|
1448
|
+
// Telemetry: action:dispatched on click (B.5 — v5.6.0). Fires for every
|
|
1449
|
+
// click attempt (tool-call or link), regardless of execute success.
|
|
1450
|
+
// Privacy: actionName is `toolName` (tool-call) or the action kind
|
|
1451
|
+
// (link/submit) — NO `params.params` payload, NO URL.
|
|
1452
|
+
function dispatchTelemetry() {
|
|
1453
|
+
if (!telemetry) return
|
|
1454
|
+
const actionName: string = params.toolName ?? params.action ?? 'unknown'
|
|
1455
|
+
telemetry.dispatch({
|
|
1456
|
+
type: 'action:dispatched',
|
|
1457
|
+
id: props.component.id,
|
|
1458
|
+
componentType: 'action',
|
|
1459
|
+
actionName,
|
|
1460
|
+
ts: Date.now(),
|
|
1461
|
+
})
|
|
1462
|
+
}
|
|
1267
1463
|
|
|
1268
1464
|
// Handle click to execute tool via Context (falls back to CustomEvent if no provider)
|
|
1269
1465
|
const handleClick = async (e: MouseEvent) => {
|
|
1466
|
+
dispatchTelemetry()
|
|
1270
1467
|
if (params.action === 'tool-call' && params.toolName) {
|
|
1271
1468
|
e.preventDefault()
|
|
1272
1469
|
await execute(params.toolName, params.params || {})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for MCPUITelemetryProvider + dispatch wiring (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the Provider correctly wraps a dispatcher and that dispatches
|
|
5
|
+
* from real renderer components arrive at the consumer sink — without the
|
|
6
|
+
* Provider, no events should fire (existing tests prove this implicitly,
|
|
7
|
+
* but we lock it explicitly here).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
11
|
+
import { render, cleanup } from '@solidjs/testing-library'
|
|
12
|
+
import { UIResourceRenderer } from '../components/UIResourceRenderer'
|
|
13
|
+
import { MCPUITelemetryProvider } from './MCPUITelemetryContext'
|
|
14
|
+
import type { TelemetryEvent, TelemetrySink } from '../services/telemetry'
|
|
15
|
+
import type { UIComponent } from '../types'
|
|
16
|
+
|
|
17
|
+
const validMetric: UIComponent = {
|
|
18
|
+
id: 'metric-1',
|
|
19
|
+
type: 'metric',
|
|
20
|
+
position: { colStart: 1, colSpan: 6 },
|
|
21
|
+
params: { title: 'OK', value: 42 },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const invalidMetric: UIComponent = {
|
|
25
|
+
id: 'metric-bad',
|
|
26
|
+
type: 'metric',
|
|
27
|
+
position: { colStart: 1, colSpan: 6 },
|
|
28
|
+
params: {} as any, // missing title + value → validation:failed
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('MCPUITelemetryProvider — integration with renderers (v5.6.0)', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
cleanup()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('NO Provider = no events dispatched (renderer works as before)', () => {
|
|
37
|
+
// Render without Provider — should not throw, no events anywhere
|
|
38
|
+
expect(() => render(() => <UIResourceRenderer content={validMetric} />)).not.toThrow()
|
|
39
|
+
// No way to assert "no dispatch" without a sink; but the absence of
|
|
40
|
+
// throws + Provider's null-check semantics is what we lock here.
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('Provider receives component:mounted event for a valid component', async () => {
|
|
44
|
+
const events: TelemetryEvent[] = []
|
|
45
|
+
const sink: TelemetrySink = (batch) => {
|
|
46
|
+
events.push(...batch)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
render(() => (
|
|
50
|
+
<MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
|
|
51
|
+
<UIResourceRenderer content={validMetric} />
|
|
52
|
+
</MCPUITelemetryProvider>
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
const mounted = events.find((e) => e.type === 'component:mounted')
|
|
56
|
+
expect(mounted).toBeDefined()
|
|
57
|
+
expect(mounted?.id).toBe('metric-1')
|
|
58
|
+
expect(mounted?.componentType).toBe('metric')
|
|
59
|
+
expect(typeof mounted?.ts).toBe('number')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('Provider receives validation:failed event with errorCount + firstErrorCode (NO error message)', async () => {
|
|
63
|
+
const events: TelemetryEvent[] = []
|
|
64
|
+
const sink: TelemetrySink = (batch) => {
|
|
65
|
+
events.push(...batch)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render(() => (
|
|
69
|
+
<MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
|
|
70
|
+
<UIResourceRenderer content={invalidMetric} errorMode="silent" />
|
|
71
|
+
</MCPUITelemetryProvider>
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
const failed = events.find((e) => e.type === 'validation:failed')
|
|
75
|
+
expect(failed).toBeDefined()
|
|
76
|
+
expect(failed).toMatchObject({
|
|
77
|
+
type: 'validation:failed',
|
|
78
|
+
id: 'metric-bad',
|
|
79
|
+
componentType: 'metric',
|
|
80
|
+
firstErrorCode: 'INVALID_METRIC',
|
|
81
|
+
})
|
|
82
|
+
if (failed?.type === 'validation:failed') {
|
|
83
|
+
expect(failed.errorCount).toBeGreaterThan(0)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Privacy hard rule: NO `errorMessage`, NO `errors` array, NO payload
|
|
87
|
+
// fields anywhere on the validation:failed event.
|
|
88
|
+
const failedKeys = failed ? Object.keys(failed) : []
|
|
89
|
+
expect(failedKeys.sort()).toEqual(
|
|
90
|
+
['componentType', 'errorCount', 'firstErrorCode', 'id', 'ts', 'type'].sort()
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('FAIL-OPEN — sink that throws does NOT crash the renderer', () => {
|
|
95
|
+
const sink: TelemetrySink = () => {
|
|
96
|
+
throw new Error('sink down hard')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(() =>
|
|
100
|
+
render(() => (
|
|
101
|
+
<MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
|
|
102
|
+
<UIResourceRenderer content={validMetric} />
|
|
103
|
+
</MCPUITelemetryProvider>
|
|
104
|
+
))
|
|
105
|
+
).not.toThrow()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('options.sampleRate=0 results in no events at the sink (everything dropped)', () => {
|
|
109
|
+
const sink = vi.fn<TelemetrySink>()
|
|
110
|
+
|
|
111
|
+
render(() => (
|
|
112
|
+
<MCPUITelemetryProvider sink={sink} options={{ sampleRate: 0, bufferMs: 0 }}>
|
|
113
|
+
<UIResourceRenderer content={validMetric} />
|
|
114
|
+
</MCPUITelemetryProvider>
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
expect(sink).not.toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
})
|