@seed-ship/mcp-ui-solid 4.0.0 → 4.0.2
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/components/DataPreviewSection.cjs +145 -104
- package/dist/components/DataPreviewSection.cjs.map +1 -1
- package/dist/components/DataPreviewSection.d.ts +1 -1
- package/dist/components/DataPreviewSection.d.ts.map +1 -1
- package/dist/components/DataPreviewSection.js +147 -106
- package/dist/components/DataPreviewSection.js.map +1 -1
- package/dist/components/ScratchpadPanel.cjs +54 -34
- package/dist/components/ScratchpadPanel.cjs.map +1 -1
- package/dist/components/ScratchpadPanel.js +54 -34
- package/dist/components/ScratchpadPanel.js.map +1 -1
- package/package.json +1 -1
- package/src/components/DataPreviewSection.tsx +148 -103
- package/src/components/ScratchpadPanel.tsx +35 -17
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DataPreviewSection — paginated data table with export
|
|
3
|
-
*
|
|
3
|
+
* v4.0.1: Fixed rendering — defensive guards for store proxy content
|
|
4
4
|
*
|
|
5
5
|
* @experimental
|
|
6
6
|
*
|
|
@@ -22,7 +22,6 @@ export interface DataPreviewSectionProps {
|
|
|
22
22
|
/** Format a number for display (French locale) */
|
|
23
23
|
function formatNumber(value: unknown, format?: string): string {
|
|
24
24
|
if (typeof value !== 'number' || !isFinite(value)) return String(value ?? '')
|
|
25
|
-
// Simple formatting: use locale
|
|
26
25
|
if (format === 'percent') return `${(value * 100).toFixed(1)}%`
|
|
27
26
|
if (format === 'currency') return `${value.toLocaleString('fr-FR')} EUR`
|
|
28
27
|
if (Number.isInteger(value)) return value.toLocaleString('fr-FR')
|
|
@@ -31,7 +30,7 @@ function formatNumber(value: unknown, format?: string): string {
|
|
|
31
30
|
|
|
32
31
|
/** Format a cell value based on column type */
|
|
33
32
|
function formatCell(value: unknown, col: DataPreviewColumn): string {
|
|
34
|
-
if (value == null) return '
|
|
33
|
+
if (value == null) return '\u2014'
|
|
35
34
|
if (col.type === 'number') return formatNumber(value, col.format)
|
|
36
35
|
if (col.type === 'date' && typeof value === 'string') {
|
|
37
36
|
try {
|
|
@@ -68,26 +67,64 @@ function downloadFile(content: string, filename: string, mimeType: string) {
|
|
|
68
67
|
URL.revokeObjectURL(url)
|
|
69
68
|
}
|
|
70
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Extract a valid DataPreviewContent from props.content.
|
|
72
|
+
* Handles: direct DataPreviewContent, or wrapped in an extra layer.
|
|
73
|
+
*/
|
|
74
|
+
function resolveContent(raw: unknown): DataPreviewContent | null {
|
|
75
|
+
if (!raw || typeof raw !== 'object') return null
|
|
76
|
+
const obj = raw as Record<string, unknown>
|
|
77
|
+
|
|
78
|
+
// Direct shape: { columns: [...], rows: [...] }
|
|
79
|
+
if (Array.isArray(obj.columns) && Array.isArray(obj.rows)) {
|
|
80
|
+
return obj as unknown as DataPreviewContent
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Wrapped shape: { content: { columns: [...], rows: [...] } }
|
|
84
|
+
if (obj.content && typeof obj.content === 'object') {
|
|
85
|
+
const inner = obj.content as Record<string, unknown>
|
|
86
|
+
if (Array.isArray(inner.columns) && Array.isArray(inner.rows)) {
|
|
87
|
+
return inner as unknown as DataPreviewContent
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
export function DataPreviewSection(props: DataPreviewSectionProps) {
|
|
72
|
-
const content = () =>
|
|
73
|
-
|
|
95
|
+
const content = createMemo(() => {
|
|
96
|
+
const resolved = resolveContent(props.content)
|
|
97
|
+
if (!resolved) {
|
|
98
|
+
console.warn(
|
|
99
|
+
'[MCP-UI] DataPreviewSection: invalid content — expected { columns: [...], rows: [...] }, got:',
|
|
100
|
+
props.content
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
return resolved
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const columns = () => content()?.columns || []
|
|
107
|
+
const rows = () => content()?.rows || []
|
|
108
|
+
const pageSize = () => content()?.pageSize || 25
|
|
74
109
|
const [page, setPage] = createSignal(0)
|
|
75
110
|
|
|
76
|
-
const totalRows = () =>
|
|
111
|
+
const totalRows = () => rows().length
|
|
77
112
|
const totalPages = () => Math.max(1, Math.ceil(totalRows() / pageSize()))
|
|
78
113
|
|
|
79
114
|
const pagedRows = createMemo(() => {
|
|
80
115
|
const start = page() * pageSize()
|
|
81
|
-
return
|
|
116
|
+
return rows().slice(start, start + pageSize())
|
|
82
117
|
})
|
|
83
118
|
|
|
84
119
|
const handleExportCSV = () => {
|
|
85
|
-
const
|
|
120
|
+
const c = content()
|
|
121
|
+
if (!c) return
|
|
122
|
+
const csv = toCSV(c.columns, c.rows)
|
|
86
123
|
downloadFile(csv, 'data-export.csv', 'text/csv;charset=utf-8')
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
const handleExportJSON = () => {
|
|
90
|
-
const json = JSON.stringify(
|
|
127
|
+
const json = JSON.stringify(rows(), null, 2)
|
|
91
128
|
downloadFile(json, 'data-export.json', 'application/json')
|
|
92
129
|
}
|
|
93
130
|
|
|
@@ -98,109 +135,117 @@ export function DataPreviewSection(props: DataPreviewSectionProps) {
|
|
|
98
135
|
}
|
|
99
136
|
|
|
100
137
|
return (
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
105
|
-
<Show when={content().source}>
|
|
106
|
-
<span class="font-medium">{content().source}</span>
|
|
107
|
-
</Show>
|
|
108
|
-
<Show when={content().freshness}>
|
|
109
|
-
<span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
|
110
|
-
{content().freshness}
|
|
111
|
-
</span>
|
|
112
|
-
</Show>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<Show when={content().exportable !== false}>
|
|
116
|
-
<div class="flex items-center gap-1">
|
|
117
|
-
<button
|
|
118
|
-
class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
119
|
-
onClick={handleExportCSV}
|
|
120
|
-
title="Export CSV"
|
|
121
|
-
>
|
|
122
|
-
CSV
|
|
123
|
-
</button>
|
|
124
|
-
<button
|
|
125
|
-
class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
126
|
-
onClick={handleExportJSON}
|
|
127
|
-
title="Export JSON"
|
|
128
|
-
>
|
|
129
|
-
JSON
|
|
130
|
-
</button>
|
|
131
|
-
</div>
|
|
132
|
-
</Show>
|
|
138
|
+
<Show when={content()} fallback={
|
|
139
|
+
<div class="text-xs text-amber-600 dark:text-amber-400 p-2">
|
|
140
|
+
[DataPreviewSection] Invalid content format
|
|
133
141
|
</div>
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
{(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
142
|
+
}>
|
|
143
|
+
{(c) => (
|
|
144
|
+
<div class="data-preview-section">
|
|
145
|
+
{/* Header with source + export */}
|
|
146
|
+
<div class="flex items-center justify-between mb-2">
|
|
147
|
+
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
148
|
+
<Show when={c().source}>
|
|
149
|
+
<span class="font-medium">{c().source}</span>
|
|
150
|
+
</Show>
|
|
151
|
+
<Show when={c().freshness}>
|
|
152
|
+
<span class="px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
|
153
|
+
{c().freshness}
|
|
154
|
+
</span>
|
|
155
|
+
</Show>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<Show when={c().exportable !== false}>
|
|
159
|
+
<div class="flex items-center gap-1">
|
|
160
|
+
<button
|
|
161
|
+
class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
162
|
+
onClick={handleExportCSV}
|
|
163
|
+
title="Export CSV"
|
|
164
|
+
>
|
|
165
|
+
CSV
|
|
166
|
+
</button>
|
|
167
|
+
<button
|
|
168
|
+
class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
169
|
+
onClick={handleExportJSON}
|
|
170
|
+
title="Export JSON"
|
|
158
171
|
>
|
|
159
|
-
|
|
172
|
+
JSON
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
</Show>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Table */}
|
|
179
|
+
<div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-700">
|
|
180
|
+
<table class="w-full text-sm">
|
|
181
|
+
<thead>
|
|
182
|
+
<tr class="bg-gray-50 dark:bg-gray-800">
|
|
183
|
+
<For each={columns()}>
|
|
160
184
|
{(col) => (
|
|
161
|
-
<
|
|
162
|
-
class="px-3 py-2 text-gray-
|
|
185
|
+
<th
|
|
186
|
+
class="px-3 py-2 font-medium text-xs text-gray-600 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700"
|
|
163
187
|
style={{ "text-align": columnAlign(col) }}
|
|
164
188
|
>
|
|
165
|
-
{
|
|
166
|
-
</
|
|
189
|
+
{col.label}
|
|
190
|
+
</th>
|
|
167
191
|
)}
|
|
168
192
|
</For>
|
|
169
193
|
</tr>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
194
|
+
</thead>
|
|
195
|
+
<tbody>
|
|
196
|
+
<For each={pagedRows()}>
|
|
197
|
+
{(row, i) => (
|
|
198
|
+
<tr
|
|
199
|
+
class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
|
200
|
+
classList={{ 'bg-gray-25 dark:bg-gray-850': i() % 2 === 1 }}
|
|
201
|
+
>
|
|
202
|
+
<For each={columns()}>
|
|
203
|
+
{(col) => (
|
|
204
|
+
<td
|
|
205
|
+
class="px-3 py-2 text-gray-800 dark:text-gray-200"
|
|
206
|
+
style={{ "text-align": columnAlign(col) }}
|
|
207
|
+
>
|
|
208
|
+
{formatCell(row[col.key], col)}
|
|
209
|
+
</td>
|
|
210
|
+
)}
|
|
211
|
+
</For>
|
|
212
|
+
</tr>
|
|
213
|
+
)}
|
|
214
|
+
</For>
|
|
215
|
+
</tbody>
|
|
216
|
+
</table>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Footer: pagination + row count */}
|
|
220
|
+
<div class="flex items-center justify-between mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
221
|
+
<span>
|
|
222
|
+
{c().totalRows
|
|
223
|
+
? `${totalRows()} / ${c().totalRows!.toLocaleString('fr-FR')} rows`
|
|
224
|
+
: `${totalRows()} row${totalRows() !== 1 ? 's' : ''}`}
|
|
225
|
+
</span>
|
|
175
226
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
196
|
-
disabled={page() >= totalPages() - 1}
|
|
197
|
-
onClick={() => setPage(p => p + 1)}
|
|
198
|
-
>
|
|
199
|
-
»
|
|
200
|
-
</button>
|
|
227
|
+
<Show when={totalPages() > 1}>
|
|
228
|
+
<div class="flex items-center gap-1">
|
|
229
|
+
<button
|
|
230
|
+
class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
231
|
+
disabled={page() === 0}
|
|
232
|
+
onClick={() => setPage(p => p - 1)}
|
|
233
|
+
>
|
|
234
|
+
«
|
|
235
|
+
</button>
|
|
236
|
+
<span>{page() + 1} / {totalPages()}</span>
|
|
237
|
+
<button
|
|
238
|
+
class="px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 transition-colors"
|
|
239
|
+
disabled={page() >= totalPages() - 1}
|
|
240
|
+
onClick={() => setPage(p => p + 1)}
|
|
241
|
+
>
|
|
242
|
+
»
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</Show>
|
|
201
246
|
</div>
|
|
202
|
-
</
|
|
203
|
-
|
|
204
|
-
</
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</Show>
|
|
205
250
|
)
|
|
206
251
|
}
|
|
@@ -342,6 +342,16 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
|
|
|
342
342
|
)
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/** Parse content that may arrive as a JSON string from SSE transport */
|
|
348
|
+
function parseContent(content: unknown): unknown {
|
|
349
|
+
if (typeof content === 'string') {
|
|
350
|
+
try { return JSON.parse(content) } catch { return content }
|
|
351
|
+
}
|
|
352
|
+
return content
|
|
353
|
+
}
|
|
354
|
+
|
|
345
355
|
// ─── Section Renderer ────────────────────────────────────────
|
|
346
356
|
|
|
347
357
|
const SectionRenderer: Component<{
|
|
@@ -356,23 +366,23 @@ const SectionRenderer: Component<{
|
|
|
356
366
|
<div class="px-4 py-3">
|
|
357
367
|
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">{props.section.title}</h4>
|
|
358
368
|
<Switch>
|
|
359
|
-
<Match when={props.section.type === 'data'}><DataSection content={props.section.content} /></Match>
|
|
360
|
-
<Match when={props.section.type === 'filter'}><InteractiveFilterSection content={props.section.content} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
|
|
369
|
+
<Match when={props.section.type === 'data'}><DataSection content={parseContent(props.section.content)} /></Match>
|
|
370
|
+
<Match when={props.section.type === 'filter'}><InteractiveFilterSection content={parseContent(props.section.content)} filters={props.filters} onFilterChange={props.onFilterChange} /></Match>
|
|
361
371
|
<Match when={props.section.type === 'message'}><p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p></Match>
|
|
362
|
-
<Match when={props.section.type === 'action'}><ActionSection content={props.section.content} onAction={props.onAction} /></Match>
|
|
363
|
-
<Match when={props.section.type === 'steps'}><EnrichedStepsSection content={props.section.content} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
|
|
364
|
-
<Match when={props.section.type === 'form'}><EmbeddedFormSection content={props.section.content} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
|
|
365
|
-
<Match when={props.section.type === 'understanding'}><UnderstandingSection content={props.section.content} /></Match>
|
|
366
|
-
<Match when={props.section.type === 'feedback'}><FeedbackSection content={props.section.content} onAction={props.onAction} /></Match>
|
|
367
|
-
<Match when={props.section.type === 'prompt'}><PromptSection content={props.section.content} onAction={props.onAction} /></Match>
|
|
368
|
-
<Match when={props.section.type === 'stepper'}><StepperProgressSection content={props.section.content} /></Match>
|
|
369
|
-
<Match when={props.section.type === 'error'}><ErrorSectionRenderer content={props.section.content} onAction={props.onAction} /></Match>
|
|
370
|
-
<Match when={props.section.type === 'source_card'}><SourceCardSection content={props.section.content} /></Match>
|
|
371
|
-
<Match when={props.section.type === 'diff'}><DiffSection content={props.section.content} /></Match>
|
|
372
|
-
<Match when={props.section.type === 'verified_text'}><VerifiedText {...(props.section.content as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
|
|
373
|
-
<Match when={props.section.type === 'data_preview'}><DataPreviewSection content={props.section.content as DataPreviewContent} /></Match>
|
|
374
|
-
<Match when={props.section.type === 'map'}>{(() => { const c = props.section.content as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
|
|
375
|
-
<Match when={props.section.type === 'chart'}
|
|
372
|
+
<Match when={props.section.type === 'action'}><ActionSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
373
|
+
<Match when={props.section.type === 'steps'}><EnrichedStepsSection content={parseContent(props.section.content)} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
|
|
374
|
+
<Match when={props.section.type === 'form'}><EmbeddedFormSection content={parseContent(props.section.content)} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
|
|
375
|
+
<Match when={props.section.type === 'understanding'}><UnderstandingSection content={parseContent(props.section.content)} /></Match>
|
|
376
|
+
<Match when={props.section.type === 'feedback'}><FeedbackSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
377
|
+
<Match when={props.section.type === 'prompt'}><PromptSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
378
|
+
<Match when={props.section.type === 'stepper'}><StepperProgressSection content={parseContent(props.section.content)} /></Match>
|
|
379
|
+
<Match when={props.section.type === 'error'}><ErrorSectionRenderer content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
|
|
380
|
+
<Match when={props.section.type === 'source_card'}><SourceCardSection content={parseContent(props.section.content)} /></Match>
|
|
381
|
+
<Match when={props.section.type === 'diff'}><DiffSection content={parseContent(props.section.content)} /></Match>
|
|
382
|
+
<Match when={props.section.type === 'verified_text'}><VerifiedText {...(parseContent(props.section.content) as VerifiedTextContent)} onHallucinationClick={(h) => props.onAction?.('hallucination_click', h)} /></Match>
|
|
383
|
+
<Match when={props.section.type === 'data_preview'}><DataPreviewSection content={parseContent(props.section.content) as DataPreviewContent} /></Match>
|
|
384
|
+
<Match when={props.section.type === 'map'}>{(() => { const c = parseContent(props.section.content) as MapSectionContent; return <MapRenderer params={{ geojson: c.geojson, center: c.center, zoom: c.zoom, geojsonStyle: c.style, popup: c.popup, layers: c.layers, height: c.height || '300px', fitBounds: true }} /> })()}</Match>
|
|
385
|
+
<Match when={props.section.type === 'chart'}>{(() => { const c = parseContent(props.section.content) as ChartComponentParams; return <ChartJSRenderer component={{ id: props.section.id, type: 'chart', position: { colStart: 1, colSpan: 12 }, params: { ...c, renderer: 'native', height: (c as any)?.height || '250px' } }} /> })()}</Match>
|
|
376
386
|
<Match when={true}><pre class="text-xs text-gray-500 overflow-auto">{JSON.stringify(props.section.content, null, 2)}</pre></Match>
|
|
377
387
|
</Switch>
|
|
378
388
|
</div>
|
|
@@ -644,7 +654,15 @@ const ActionSection: Component<{
|
|
|
644
654
|
content: unknown
|
|
645
655
|
onAction?: (action: string, data?: unknown) => void
|
|
646
656
|
}> = (props) => {
|
|
647
|
-
const actions = () =>
|
|
657
|
+
const actions = () => {
|
|
658
|
+
if (Array.isArray(props.content)) return props.content as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
|
|
659
|
+
const obj = props.content as Record<string, unknown> | null
|
|
660
|
+
if (obj && Array.isArray(obj.actions)) {
|
|
661
|
+
console.warn('[MCP-UI] ActionSection: content should be an array, got { actions: [...] }. Unwrapping automatically.')
|
|
662
|
+
return obj.actions as Array<{ label: string; value?: string; action?: string; variant?: string; icon?: string }>
|
|
663
|
+
}
|
|
664
|
+
return []
|
|
665
|
+
}
|
|
648
666
|
return (
|
|
649
667
|
<div class="flex flex-wrap gap-2">
|
|
650
668
|
<For each={actions()}>
|