@seed-ship/mcp-ui-solid 5.6.0 → 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 +39 -0
- package/dist/components/UIResourceRenderer.cjs +40 -11
- 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 +40 -11
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/index.cjs +1 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +9 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +9 -1
- package/dist/mcp-ui-spec/dist/schemas.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/TableRenderer.citation.test.tsx +157 -0
- package/src/components/UIResourceRenderer.tsx +133 -12
- package/src/index.ts +5 -0
- package/src/types/index.ts +30 -0
- package/tsconfig.tsbuildinfo +1 -1
package/dist/types.d.cts
CHANGED
|
@@ -141,9 +141,20 @@ export interface TableVirtualizeOptions {
|
|
|
141
141
|
*/
|
|
142
142
|
threshold?: number;
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Citation map entry — source of a `[N]` citation marker rendered inline
|
|
146
|
+
* inside table cells (v5.7.0). See
|
|
147
|
+
* `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
|
|
148
|
+
*/
|
|
149
|
+
export interface CitationEntry {
|
|
150
|
+
page: number | string;
|
|
151
|
+
file?: string;
|
|
152
|
+
file_id?: number | string;
|
|
153
|
+
}
|
|
144
154
|
/**
|
|
145
155
|
* Table component parameters
|
|
146
156
|
* Updated Sprint Ultimate U.3: Added virtualization support
|
|
157
|
+
* Updated v5.7.0: Optional citationMap + citationRender for chip rendering
|
|
147
158
|
*/
|
|
148
159
|
export interface TableComponentParams {
|
|
149
160
|
title?: string;
|
|
@@ -179,6 +190,21 @@ export interface TableComponentParams {
|
|
|
179
190
|
* Custom CSS class (Sprint 7)
|
|
180
191
|
*/
|
|
181
192
|
className?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Opt-in citation chip rendering (v5.7.0). Maps marker id (e.g. `1` from
|
|
195
|
+
* `[1]` or `[📄 CITATION 1]` in cell text) to its source. When set,
|
|
196
|
+
* `<TableRenderer>` replaces markers in cell strings with clickable
|
|
197
|
+
* chips carrying `data-citation-page` / `data-citation-doc` /
|
|
198
|
+
* `data-citation-verified` attributes that a host's delegated click
|
|
199
|
+
* handler can route. JSON-serializable — safe to send from MCP servers.
|
|
200
|
+
*/
|
|
201
|
+
citationMap?: Record<string | number, CitationEntry>;
|
|
202
|
+
/**
|
|
203
|
+
* Optional override for the chip HTML (v5.7.0). When supplied, wins over
|
|
204
|
+
* the default chip shape. NOT JSON-serializable — must be wired by the
|
|
205
|
+
* consumer at render time, not from a server payload.
|
|
206
|
+
*/
|
|
207
|
+
citationRender?: (id: number, mapping: CitationEntry | undefined) => string;
|
|
182
208
|
}
|
|
183
209
|
/**
|
|
184
210
|
* Metric card component parameters
|
package/dist/types.d.ts
CHANGED
|
@@ -141,9 +141,20 @@ export interface TableVirtualizeOptions {
|
|
|
141
141
|
*/
|
|
142
142
|
threshold?: number;
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Citation map entry — source of a `[N]` citation marker rendered inline
|
|
146
|
+
* inside table cells (v5.7.0). See
|
|
147
|
+
* `mcp-ui-solid/docs/briefs/BRIEF-citations-in-table-cells.md`.
|
|
148
|
+
*/
|
|
149
|
+
export interface CitationEntry {
|
|
150
|
+
page: number | string;
|
|
151
|
+
file?: string;
|
|
152
|
+
file_id?: number | string;
|
|
153
|
+
}
|
|
144
154
|
/**
|
|
145
155
|
* Table component parameters
|
|
146
156
|
* Updated Sprint Ultimate U.3: Added virtualization support
|
|
157
|
+
* Updated v5.7.0: Optional citationMap + citationRender for chip rendering
|
|
147
158
|
*/
|
|
148
159
|
export interface TableComponentParams {
|
|
149
160
|
title?: string;
|
|
@@ -179,6 +190,21 @@ export interface TableComponentParams {
|
|
|
179
190
|
* Custom CSS class (Sprint 7)
|
|
180
191
|
*/
|
|
181
192
|
className?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Opt-in citation chip rendering (v5.7.0). Maps marker id (e.g. `1` from
|
|
195
|
+
* `[1]` or `[📄 CITATION 1]` in cell text) to its source. When set,
|
|
196
|
+
* `<TableRenderer>` replaces markers in cell strings with clickable
|
|
197
|
+
* chips carrying `data-citation-page` / `data-citation-doc` /
|
|
198
|
+
* `data-citation-verified` attributes that a host's delegated click
|
|
199
|
+
* handler can route. JSON-serializable — safe to send from MCP servers.
|
|
200
|
+
*/
|
|
201
|
+
citationMap?: Record<string | number, CitationEntry>;
|
|
202
|
+
/**
|
|
203
|
+
* Optional override for the chip HTML (v5.7.0). When supplied, wins over
|
|
204
|
+
* the default chip shape. NOT JSON-serializable — must be wired by the
|
|
205
|
+
* consumer at render time, not from a server payload.
|
|
206
|
+
*/
|
|
207
|
+
citationRender?: (id: number, mapping: CitationEntry | undefined) => string;
|
|
182
208
|
}
|
|
183
209
|
/**
|
|
184
210
|
* Metric card component parameters
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# Brief — Citation chips inside `TableRenderer` cells
|
|
2
|
+
|
|
3
|
+
> **Status** : drafted 2026-05-02. Validated end-to-end via a server-side
|
|
4
|
+
> bridge in deposium_MCPs (commit `7df433ae`). Brief proposes lifting the
|
|
5
|
+
> chip-rendering responsibility into `mcp-ui-solid` so consumers stop
|
|
6
|
+
> mirroring chip HTML byte-for-byte.
|
|
7
|
+
>
|
|
8
|
+
> **Audience** : `@seed-ship/mcp-ui-solid` maintainer.
|
|
9
|
+
>
|
|
10
|
+
> **Effort** : ~half a day (helper + one renderCellValue branch + tests).
|
|
11
|
+
> Backward compatible (opt-in via new `params.citationMap`).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. The user-facing problem
|
|
16
|
+
|
|
17
|
+
A table emitted via `ui_layout` `type: 'table'` with cells like
|
|
18
|
+
`"[1] ; [4] ; [6]"` (LLM citation markers) renders the markers as
|
|
19
|
+
**plain text** inside the MCP-UI grid. The host app (e.g. Solid chat
|
|
20
|
+
UI) has its own `transformCitationReferences` pipeline that turns
|
|
21
|
+
`[📄 CITATION N]` markers into clickable chips, but that pipeline
|
|
22
|
+
runs on `marked.parse(content)` HTML — it never reaches cells inside
|
|
23
|
+
`TableRenderer`, because `renderCellValue` operates on cell strings
|
|
24
|
+
independently.
|
|
25
|
+
|
|
26
|
+
Concretely (from a deposium chat answer with citations) :
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
| Rang | Entité | … | Citations |
|
|
30
|
+
| ---- | ----------------- | -- | -------------------- |
|
|
31
|
+
| 1 | MSP | … | [1] ; [4] ; [6] | ← plain text, not clickable
|
|
32
|
+
| 2 | Milieu Consulting | … | [3] ; [4] ; [8] |
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The host app's user clicks the citation expecting the source-doc
|
|
36
|
+
panel to open ; nothing happens. Same answer rendered as inline
|
|
37
|
+
markdown table (no `ui_layout`) DOES produce clickable chips because
|
|
38
|
+
the host `transformCitationReferences` walks `<td>` content of the
|
|
39
|
+
`marked.parse` output. So we have a feature gap : MCP-UI styling
|
|
40
|
+
(search inside, sort, export) costs us clickable citations.
|
|
41
|
+
|
|
42
|
+
## 2. What already works in MCP-UI v5.6.0
|
|
43
|
+
|
|
44
|
+
`renderCellValue` in `UIResourceRenderer.tsx` (L290 → L366) already
|
|
45
|
+
detects HTML in cell values and pipes through `DOMPurify.sanitize`
|
|
46
|
+
with this whitelist (L350-352) :
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
ALLOWED_TAGS: ['a', 'strong', 'em', 'b', 'i', 'code', 'span', 'br',
|
|
50
|
+
'button', 'svg', 'path'],
|
|
51
|
+
ALLOWED_ATTR: ['href', 'target', 'rel', 'class',
|
|
52
|
+
'data-citation-page', 'data-citation-source',
|
|
53
|
+
'data-citation-doc', 'data-citation-verified',
|
|
54
|
+
'title', 'fill', 'stroke', 'viewBox',
|
|
55
|
+
'stroke-linecap', 'stroke-linejoin', 'stroke-width', 'd'],
|
|
56
|
+
ADD_ATTR: ['target', 'rel'],
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Critically, `data-citation-page`, `data-citation-doc`, and
|
|
60
|
+
`data-citation-verified` are explicitly allowed. So if a cell value is
|
|
61
|
+
already valid chip HTML — `<span class="citation-ref"><button
|
|
62
|
+
data-citation-page="5" data-citation-doc="…">…</button></span>` —
|
|
63
|
+
it survives DOMPurify and renders as a clickable chip in the cell.
|
|
64
|
+
|
|
65
|
+
The host's click handler typically uses event delegation
|
|
66
|
+
(`target.closest('[data-citation-page]')`), so clicks inside MCP-UI
|
|
67
|
+
cells route to the same citation panel as inline markdown chips.
|
|
68
|
+
Confirmed working in deposium 2026-05-02.
|
|
69
|
+
|
|
70
|
+
## 3. The current bridge (what to replace)
|
|
71
|
+
|
|
72
|
+
deposium_MCPs (commit `7df433ae`) added two server-side helpers :
|
|
73
|
+
|
|
74
|
+
- `renderCitationChipHTML(pageNum, fileName, verified)` — emits the
|
|
75
|
+
exact chip HTML shape that Solid `createCitationButton`
|
|
76
|
+
produces, with the same Tailwind classes + the 3 `data-citation-*`
|
|
77
|
+
attributes.
|
|
78
|
+
- `replaceCitationsInCellHTML(cellText, citationMap)` — walks a cell
|
|
79
|
+
string, normalizes bare `[N]` / `Citation [N]` to canonical
|
|
80
|
+
`[📄 CITATION N]`, then replaces each marker with chip HTML.
|
|
81
|
+
|
|
82
|
+
Used in `rag.ts` at the point where markdown tables are extracted from
|
|
83
|
+
the LLM answer and emitted as `ui_layout` table components.
|
|
84
|
+
|
|
85
|
+
**Why this is fragile and should move upstream** :
|
|
86
|
+
|
|
87
|
+
1. **Byte-coupling** — the chip shape (CSS classes, attribute names,
|
|
88
|
+
SVG markup) lives twice : in `mcp-ui-solid`'s consumer (Solid host
|
|
89
|
+
`createCitationButton`) AND mirrored in deposium_MCPs. A change in
|
|
90
|
+
one site breaks visual consistency.
|
|
91
|
+
2. **Per-consumer reinvention** — every MCP server that wants
|
|
92
|
+
clickable cells will write the same mirror. Most won't.
|
|
93
|
+
3. **Sanitization risk** — server emits raw HTML. If a future
|
|
94
|
+
deposium_MCPs change forgets DOMPurify-allowed shape, cells break
|
|
95
|
+
silently.
|
|
96
|
+
4. **citationMap leakage** — server has the map already (it sent it
|
|
97
|
+
via SSE `citation_map`), but bridge consumers must thread the same
|
|
98
|
+
map through to every helper call. mcp-ui-solid could just take it
|
|
99
|
+
as a `params.citationMap` once.
|
|
100
|
+
|
|
101
|
+
## 4. Proposed API
|
|
102
|
+
|
|
103
|
+
### 4.1 New `params.citationMap` (opt-in)
|
|
104
|
+
|
|
105
|
+
`UIComponent` `type: 'table'` accepts an optional `citationMap` :
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
type TableParams = {
|
|
109
|
+
// existing :
|
|
110
|
+
title?: string
|
|
111
|
+
columns: Array<{ key: string; label: string; sortable?: boolean; … }>
|
|
112
|
+
rows: Array<Record<string, unknown>>
|
|
113
|
+
exportable?: boolean | { formats?: ('csv' | 'tsv' | 'json')[]; filename?: string }
|
|
114
|
+
searchable?: boolean | 'auto'
|
|
115
|
+
virtualize?: { enabled: boolean; rowHeight?: number; … }
|
|
116
|
+
pageSize?: number
|
|
117
|
+
// NEW :
|
|
118
|
+
citationMap?: Record<number, {
|
|
119
|
+
page: number | string
|
|
120
|
+
file?: string
|
|
121
|
+
file_id?: number | string
|
|
122
|
+
}>
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
When `citationMap` is provided, `renderCellValue` runs an additional
|
|
127
|
+
transform on cell strings : matching `[📄 CITATION N]` (and bare
|
|
128
|
+
`[N]`, `Citation [N]`) markers are replaced with chip HTML before the
|
|
129
|
+
existing DOMPurify pass.
|
|
130
|
+
|
|
131
|
+
### 4.2 Chip HTML shape
|
|
132
|
+
|
|
133
|
+
The chip shape is intentionally identical to what consumers commonly
|
|
134
|
+
ship for inline markdown chips, so a single CSS class graph styles
|
|
135
|
+
both. Default chip :
|
|
136
|
+
|
|
137
|
+
```html
|
|
138
|
+
<span class="citation-ref inline-flex items-center gap-0.5 align-middle">
|
|
139
|
+
<span class="text-gray-500">[Doc - 5]</span>
|
|
140
|
+
<button class="inline-flex items-center ml-0.5 px-1 py-0.5 text-xs
|
|
141
|
+
bg-gray-800 hover:bg-gray-700 border border-gray-600
|
|
142
|
+
hover:border-teal-500 rounded cursor-pointer
|
|
143
|
+
transition-colors align-middle"
|
|
144
|
+
data-citation-page="5"
|
|
145
|
+
data-citation-doc="<URI-encoded fileName>"
|
|
146
|
+
data-citation-verified="true"
|
|
147
|
+
title="View source - Doc - 5">
|
|
148
|
+
<svg class="w-3 h-3" …>…</svg>
|
|
149
|
+
</button>
|
|
150
|
+
</span>
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Open question : should mcp-ui-solid ship its own neutral chip class
|
|
154
|
+
graph (e.g. `mcp-ui-citation-chip`), or deliberately match the
|
|
155
|
+
deposium-style classes ? See §7.
|
|
156
|
+
|
|
157
|
+
### 4.3 Optional override : `params.citationRender`
|
|
158
|
+
|
|
159
|
+
For consumers that need a different chip shape (different button
|
|
160
|
+
text, different URL scheme, web-citation vs doc-citation), accept
|
|
161
|
+
an optional render function :
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
type TableParams = {
|
|
165
|
+
// …
|
|
166
|
+
citationMap?: Record<number, { page; file?; file_id? }>
|
|
167
|
+
citationRender?: (
|
|
168
|
+
id: number,
|
|
169
|
+
mapping: { page; file?; file_id? } | undefined
|
|
170
|
+
) => string // returns sanitized HTML string
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
When both are provided, `citationRender` wins. The default render
|
|
175
|
+
(used when only `citationMap` is set) is the chip shape in §4.2.
|
|
176
|
+
|
|
177
|
+
This keeps the common case zero-config (just pass `citationMap`)
|
|
178
|
+
while letting heavy users opt out of the default shape.
|
|
179
|
+
|
|
180
|
+
## 5. Implementation sketch
|
|
181
|
+
|
|
182
|
+
In `UIResourceRenderer.tsx`, extend `renderCellValue` with an
|
|
183
|
+
optional `citationCtx` arg :
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
type CitationCtx = {
|
|
187
|
+
map: Record<number, { page; file?; file_id? }>
|
|
188
|
+
render?: (id: number, mapping: any) => string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function defaultCitationChip(
|
|
192
|
+
pageNum: number | string,
|
|
193
|
+
fileName: string,
|
|
194
|
+
verified: boolean = true
|
|
195
|
+
): string {
|
|
196
|
+
const safeDocName = encodeURIComponent(fileName || '')
|
|
197
|
+
const label = fileName ? `${fileName} - ${pageNum}` : `${pageNum}`
|
|
198
|
+
if (!verified) {
|
|
199
|
+
return `<span class="citation-ref opacity-60">…line-through label…</span>`
|
|
200
|
+
}
|
|
201
|
+
return `<span class="citation-ref inline-flex …"><span>[${label}]</span><button data-citation-page="${pageNum}" data-citation-doc="${safeDocName}" data-citation-verified="true" …><svg …/></button></span>`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function transformCellCitations(text: string, ctx: CitationCtx): string {
|
|
205
|
+
// 1. normalize bare [N], `Citation [N]`, `[CITATION N]` → `[📄 CITATION N]`
|
|
206
|
+
let out = text.replace(/(?<![p.])\[(\d{1,2})\](?!\()/g, '[📄 CITATION $1]')
|
|
207
|
+
out = out.replace(/\bCitations?\s*\[(\d+)\]/gi, '[📄 CITATION $1]')
|
|
208
|
+
out = out.replace(/\[CITATION\s+(\d+)\]/gi, '[📄 CITATION $1]')
|
|
209
|
+
// 2. replace each marker with chip HTML
|
|
210
|
+
return out.replace(
|
|
211
|
+
/[【[]\s*📄\s*CITATION\s*(\d+)\s*[】\]]/gi,
|
|
212
|
+
(_m, idStr) => {
|
|
213
|
+
const id = parseInt(idStr, 10)
|
|
214
|
+
const mapping = ctx.map[id]
|
|
215
|
+
if (ctx.render) return ctx.render(id, mapping)
|
|
216
|
+
if (mapping) return defaultCitationChip(mapping.page, mapping.file ?? '', true)
|
|
217
|
+
// unresolved : drop silently when map populated (likely hallucination),
|
|
218
|
+
// else fallback placeholder
|
|
219
|
+
return Object.keys(ctx.map).length > 0 ? '' : `[réf. ${id}]`
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function renderCellValue(value: any, citationCtx?: CitationCtx): string {
|
|
225
|
+
// … existing body …
|
|
226
|
+
// After existing string conversion + cleanup, BEFORE the markdown-link /
|
|
227
|
+
// hasHtml / hasMarkdown branches, run the citation transform if ctx set :
|
|
228
|
+
if (citationCtx && typeof strValue === 'string') {
|
|
229
|
+
strValue = transformCellCitations(strValue, citationCtx)
|
|
230
|
+
}
|
|
231
|
+
// …rest unchanged (markdown link, hasHtml → DOMPurify with whitelist)…
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The `TableRenderer` body (L641 + L680) passes `citationCtx` from
|
|
236
|
+
`tableParams` :
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const citationCtx = tableParams.citationMap
|
|
240
|
+
? { map: tableParams.citationMap, render: tableParams.citationRender }
|
|
241
|
+
: undefined
|
|
242
|
+
|
|
243
|
+
// later in <td>…
|
|
244
|
+
<div innerHTML={highlightQuery(
|
|
245
|
+
renderCellValue(row[column.key], citationCtx),
|
|
246
|
+
debouncedQuery(),
|
|
247
|
+
)} />
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
The DOMPurify pass downstream (the existing `hasHtml` branch) keeps
|
|
251
|
+
the chip intact because the citation attributes are already
|
|
252
|
+
whitelisted (§2).
|
|
253
|
+
|
|
254
|
+
## 6. Test plan
|
|
255
|
+
|
|
256
|
+
### 6.1 Unit (`tests/components/TableRenderer.citation.test.tsx`)
|
|
257
|
+
|
|
258
|
+
- `citationMap` not set → cells render plain text (regression check)
|
|
259
|
+
- `citationMap = {1:{page:5, file:'A.pdf'}}`, cell `"[1]"` → cell HTML
|
|
260
|
+
contains `data-citation-page="5"`, `data-citation-doc="A.pdf"`, click
|
|
261
|
+
on the button bubbles a delegated event with the right attributes
|
|
262
|
+
- Multi-citation cell `"[1] ; [2]"` → 2 chips in cell
|
|
263
|
+
- Unresolved id `"[99]"` with non-empty map → cell empty (silently
|
|
264
|
+
dropped, mirror of typical host behavior on LLM hallucinations)
|
|
265
|
+
- Unresolved id with no map → cell has `[réf. 99]` placeholder
|
|
266
|
+
- `citationRender` override → wins over default shape
|
|
267
|
+
- `[p.5]` page-form → NOT touched (negative lookbehind)
|
|
268
|
+
- `[text](url)` markdown link → NOT touched (negative lookahead)
|
|
269
|
+
- Cell with mixed `**bold** [1]` → bold becomes `<strong>` AND chip
|
|
270
|
+
rendered (both transforms compose : citation first, then existing
|
|
271
|
+
marked-on-markdown branch ; the chip HTML survives marked because
|
|
272
|
+
it's `<span>` content, not markdown syntax)
|
|
273
|
+
- DOMPurify pass preserves all 3 `data-citation-*` attributes
|
|
274
|
+
|
|
275
|
+
### 6.2 Integration
|
|
276
|
+
|
|
277
|
+
- Render a table with 10 rows, 6 columns, 5 citations per row,
|
|
278
|
+
`citationMap` of 8 entries → DOM has 50 `[data-citation-page]`
|
|
279
|
+
buttons, click delegation works (jsdom event simulation)
|
|
280
|
+
- Search input filtering by entity name does not break chip HTML
|
|
281
|
+
(highlightQuery should skip `<span>`/`<button>` content — already
|
|
282
|
+
handled by L283 `(<[^>]+>)|([^<]+)` pattern)
|
|
283
|
+
- CSV export of a table with chips → chips serialized as plain text
|
|
284
|
+
fallback (e.g. `[Doc - 5]` from the visible label, NOT the raw
|
|
285
|
+
`[📄 CITATION 1]` marker) — see §7 open question
|
|
286
|
+
|
|
287
|
+
## 7. Open questions
|
|
288
|
+
|
|
289
|
+
1. **Chip CSS class neutrality** — the deposium chip uses
|
|
290
|
+
`bg-deposium-slate-800`, `text-deposium-teal-400` etc. (a
|
|
291
|
+
custom Tailwind palette). The default chip shape proposed here uses
|
|
292
|
+
generic `bg-gray-800 text-teal-400` — works in any Tailwind setup
|
|
293
|
+
but won't visually match a host with a custom palette. Options :
|
|
294
|
+
(a) ship neutral classes + let host override via CSS `.citation-ref`
|
|
295
|
+
selector ; (b) accept a `chipClasses?: { wrapper, label, button }`
|
|
296
|
+
prop on table params ; (c) use unprefixed semantic CSS variables.
|
|
297
|
+
Recommendation : (a) for v1 simplicity — hosts already style
|
|
298
|
+
`.citation-ref` for their own inline markdown chips, so neutral
|
|
299
|
+
tailwind classes layered with host CSS gives consistent rendering
|
|
300
|
+
for free.
|
|
301
|
+
|
|
302
|
+
2. **Export serialization** — when exporting a table with chips to
|
|
303
|
+
CSV, the chip HTML should NOT survive (commas in HTML, opaque
|
|
304
|
+
markup). Should `getTableCSV` (called by export menu) strip chips
|
|
305
|
+
to their visible label `[Doc - 5]`, or to the original
|
|
306
|
+
`[📄 CITATION 1]` marker, or to just the page number ? Probably
|
|
307
|
+
visible label ; tests should pin one choice.
|
|
308
|
+
|
|
309
|
+
3. **Server-side normalization scope** — the `[N]` → `[📄 CITATION N]`
|
|
310
|
+
normalize step uses negative lookbehind `(?<![p.])` to avoid
|
|
311
|
+
`[p.5]` (page form). This duplicates host normalization (e.g.
|
|
312
|
+
Solid `normalizeCitations`). Acceptable duplication or should we
|
|
313
|
+
require the host to send already-canonical markers ? Recommendation
|
|
314
|
+
: include the normalize step — LLMs emit bare `[N]` more often
|
|
315
|
+
than `[📄 CITATION N]`, and host normalization is a markdown-pipeline
|
|
316
|
+
step the table renderer doesn't share. Better DX to handle it
|
|
317
|
+
inside the table.
|
|
318
|
+
|
|
319
|
+
4. **Streaming / lazy citationMap** — during streaming, the
|
|
320
|
+
`citation_map` SSE event sometimes arrives AFTER token streaming
|
|
321
|
+
completes. ui_layout components are typically emitted at the end,
|
|
322
|
+
so by the time TableRenderer renders, citationMap should be in
|
|
323
|
+
place. But for hosts that show the table during streaming
|
|
324
|
+
(unlikely), reactivity through `params.citationMap` would matter.
|
|
325
|
+
For v1 — params is a snapshot at render time, no special-cased
|
|
326
|
+
reactivity. Hosts that need it can re-emit the ui_layout when the
|
|
327
|
+
map updates.
|
|
328
|
+
|
|
329
|
+
## 8. Migration
|
|
330
|
+
|
|
331
|
+
100% backward compatible. No `citationMap` set → old behavior. Hosts
|
|
332
|
+
opt in by adding `citationMap: gaResult.citation_map` to their table
|
|
333
|
+
params. Once landed, deposium_MCPs reverts commit `7df433ae`
|
|
334
|
+
(removes `renderCitationChipHTML` + `replaceCitationsInCellHTML`)
|
|
335
|
+
and the chat handler emits the raw `[📄 CITATION N]` markers in
|
|
336
|
+
cells with `params.citationMap` set.
|
|
337
|
+
|
|
338
|
+
## 9. Why not just rely on host-side rendering ?
|
|
339
|
+
|
|
340
|
+
We considered : have hosts render the chip HTML themselves and pass
|
|
341
|
+
that as cell content. That's exactly the deposium bridge — and the
|
|
342
|
+
reason this brief exists. The byte-coupling and per-consumer
|
|
343
|
+
reinvention costs outweigh the simplicity benefit.
|
|
344
|
+
|
|
345
|
+
We also considered : `cellMode: 'markdown' | 'html' | 'plain'` with
|
|
346
|
+
`'markdown'` running marked.parse on cells. That solves bold/italic
|
|
347
|
+
but NOT citations — `marked.parse('[📄 CITATION 1]')` doesn't
|
|
348
|
+
produce a chip ; you'd still need a citation transform on top. So
|
|
349
|
+
this brief is basically that, with the citation transform first-class.
|
|
350
|
+
|
|
351
|
+
## 10. References
|
|
352
|
+
|
|
353
|
+
- `mcp-ui-solid/src/components/UIResourceRenderer.tsx` :
|
|
354
|
+
- L290-366 `renderCellValue` (where the new citation branch goes)
|
|
355
|
+
- L345-353 DOMPurify whitelist (already allows our attributes)
|
|
356
|
+
- L372+ `TableRenderer` (where citationCtx flows from params to cells)
|
|
357
|
+
- `mcp-ui-solid/CHANGELOG.md` v5.6.0 — last stable shipped
|
|
358
|
+
- deposium_MCPs commit `7df433ae` — server-side bridge that this
|
|
359
|
+
brief obsoletes
|
|
360
|
+
- deposium_solid `ChatInterfaceStreaming.tsx`
|
|
361
|
+
- L253 `createCitationButton` — chip shape canonical
|
|
362
|
+
- L291-409 `transformCitationReferences` — the host pipeline
|
|
363
|
+
that this brief replicates inside MCP-UI for cell content
|
|
364
|
+
- L2360 `target.closest('[data-citation-page]')` — host click
|
|
365
|
+
delegation that catches both inline AND cell chips
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seed-ship/mcp-ui-solid",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"description": "SolidJS components for rendering MCP-generated UI resources",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -139,11 +139,11 @@
|
|
|
139
139
|
}
|
|
140
140
|
},
|
|
141
141
|
"dependencies": {
|
|
142
|
+
"@seed-ship/mcp-ui-spec": "^5.0.3",
|
|
142
143
|
"@types/dompurify": "^3.0.5",
|
|
143
144
|
"dompurify": "^3.4.1",
|
|
144
145
|
"marked": "^16.3.0",
|
|
145
|
-
"zod": "^3.22.4"
|
|
146
|
-
"@seed-ship/mcp-ui-spec": "^5.0.2"
|
|
146
|
+
"zod": "^3.22.4"
|
|
147
147
|
},
|
|
148
148
|
"devDependencies": {
|
|
149
149
|
"@size-limit/esbuild": "^12.0.0",
|
|
@@ -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
|
+
})
|