@seed-ship/mcp-ui-solid 6.5.0 → 6.6.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 +130 -0
- package/README.md +37 -0
- package/dist/adapters/connector.cjs +112 -0
- package/dist/adapters/connector.cjs.map +1 -0
- package/dist/adapters/connector.d.ts +71 -0
- package/dist/adapters/connector.d.ts.map +1 -0
- package/dist/adapters/connector.js +112 -0
- package/dist/adapters/connector.js.map +1 -0
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters.cjs +6 -0
- package/dist/adapters.cjs.map +1 -0
- package/dist/adapters.d.cts +18 -0
- package/dist/adapters.d.ts +18 -0
- package/dist/adapters.js +6 -0
- package/dist/adapters.js.map +1 -0
- package/dist/components/ExpandableWrapper.cjs +24 -6
- package/dist/components/ExpandableWrapper.cjs.map +1 -1
- package/dist/components/ExpandableWrapper.d.ts.map +1 -1
- package/dist/components/ExpandableWrapper.js +24 -6
- package/dist/components/ExpandableWrapper.js.map +1 -1
- package/dist/components/FeedbackInline.cjs +6 -2
- package/dist/components/FeedbackInline.cjs.map +1 -1
- package/dist/components/FeedbackInline.d.ts +2 -2
- package/dist/components/FeedbackInline.d.ts.map +1 -1
- package/dist/components/FeedbackInline.js +7 -3
- package/dist/components/FeedbackInline.js.map +1 -1
- package/dist/components/PresentationFeedback.cjs +207 -0
- package/dist/components/PresentationFeedback.cjs.map +1 -0
- package/dist/components/PresentationFeedback.d.ts +113 -0
- package/dist/components/PresentationFeedback.d.ts.map +1 -0
- package/dist/components/PresentationFeedback.js +207 -0
- package/dist/components/PresentationFeedback.js.map +1 -0
- package/dist/components/StreamingUIRenderer.cjs +82 -195
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts +25 -5
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +84 -197
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components.cjs +3 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.cts +2 -0
- package/dist/components.d.ts +2 -0
- package/dist/components.js +3 -0
- package/dist/components.js.map +1 -1
- package/dist/context/MCPUIStringsContext.cjs +38 -0
- package/dist/context/MCPUIStringsContext.cjs.map +1 -0
- package/dist/context/MCPUIStringsContext.d.ts +95 -0
- package/dist/context/MCPUIStringsContext.d.ts.map +1 -0
- package/dist/context/MCPUIStringsContext.js +38 -0
- package/dist/context/MCPUIStringsContext.js.map +1 -0
- package/dist/index.cjs +8 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +103 -0
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +103 -0
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/docs/briefs/ROADMAP-opendata-macro-mcpui.md +912 -0
- package/package.json +17 -5
- package/src/adapters/connector.test.ts +165 -0
- package/src/adapters/connector.ts +234 -0
- package/src/adapters/index.ts +24 -0
- package/src/components/ExpandableWrapper.test.tsx +5 -2
- package/src/components/ExpandableWrapper.tsx +8 -6
- package/src/components/FeedbackInline.test.tsx +6 -3
- package/src/components/FeedbackInline.tsx +8 -6
- package/src/components/PresentationFeedback.test.tsx +163 -0
- package/src/components/PresentationFeedback.tsx +326 -0
- package/src/components/StreamingUIRenderer.parity.test.tsx +158 -0
- package/src/components/StreamingUIRenderer.tsx +42 -166
- package/src/components/index.ts +10 -0
- package/src/context/MCPUIStringsContext.test.tsx +116 -0
- package/src/context/MCPUIStringsContext.tsx +128 -0
- package/src/index.ts +27 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.6.0 — PresentationFeedback (R3 / D9 of ROADMAP-opendata-macro-mcpui).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* 1. Resting step shows the verdict buttons
|
|
6
|
+
* 2. "Clear" submits a `readable` verdict immediately
|
|
7
|
+
* 3. "Not clear" opens the detail step (does NOT submit yet)
|
|
8
|
+
* 4. Detail step assembles problems + preferredLayout + comment into the payload
|
|
9
|
+
* 5. base fields (connectorId/toolName/queryHash/renderKind/layoutType) are carried
|
|
10
|
+
* 6. Submission is best-effort — a rejected onSubmit promise does not throw
|
|
11
|
+
* 7. Labels are overridable via the `labels` prop
|
|
12
|
+
* 8. The layout picker is hidden when no options are given
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
16
|
+
import { render, cleanup, fireEvent } from '@solidjs/testing-library'
|
|
17
|
+
import { PresentationFeedback } from './PresentationFeedback'
|
|
18
|
+
import type { ConnectorRenderFeedback } from '@seed-ship/mcp-ui-spec'
|
|
19
|
+
|
|
20
|
+
const BASE = {
|
|
21
|
+
connectorId: 'datagouv',
|
|
22
|
+
toolName: 'datagouv.search',
|
|
23
|
+
queryHash: 'a1b2c3d4',
|
|
24
|
+
renderKind: 'primary',
|
|
25
|
+
layoutType: 'table',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('PresentationFeedback (v6.6.0)', () => {
|
|
29
|
+
beforeEach(() => cleanup())
|
|
30
|
+
|
|
31
|
+
it('resting step shows both verdict buttons', () => {
|
|
32
|
+
const { container } = render(() => (
|
|
33
|
+
<PresentationFeedback {...BASE} onSubmit={() => {}} />
|
|
34
|
+
))
|
|
35
|
+
expect(container.querySelector('[data-presentation-feedback-verdict="readable"]')).toBeTruthy()
|
|
36
|
+
expect(
|
|
37
|
+
container.querySelector('[data-presentation-feedback-verdict="not_readable"]')
|
|
38
|
+
).toBeTruthy()
|
|
39
|
+
expect(container.querySelector('[data-presentation-feedback-step="idle"]')).toBeTruthy()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('"Clear" submits a readable verdict immediately', () => {
|
|
43
|
+
const onSubmit = vi.fn()
|
|
44
|
+
const { container } = render(() => (
|
|
45
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
46
|
+
))
|
|
47
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
48
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
49
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
50
|
+
expect(payload.verdict).toBe('readable')
|
|
51
|
+
expect(payload.connectorId).toBe('datagouv')
|
|
52
|
+
expect(payload.queryHash).toBe('a1b2c3d4')
|
|
53
|
+
expect(payload.renderKind).toBe('primary')
|
|
54
|
+
expect(payload.layoutType).toBe('table')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('"Not clear" opens the detail step without submitting', () => {
|
|
58
|
+
const onSubmit = vi.fn()
|
|
59
|
+
const { container } = render(() => (
|
|
60
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
61
|
+
))
|
|
62
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
63
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
64
|
+
expect(container.querySelector('[data-presentation-feedback-step="detail"]')).toBeTruthy()
|
|
65
|
+
expect(container.querySelector('[data-presentation-feedback-submit]')).toBeTruthy()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('detail step assembles problems + preferredLayout + comment', () => {
|
|
69
|
+
const onSubmit = vi.fn()
|
|
70
|
+
const { container } = render(() => (
|
|
71
|
+
<PresentationFeedback
|
|
72
|
+
{...BASE}
|
|
73
|
+
onSubmit={onSubmit}
|
|
74
|
+
preferredLayoutOptions={['table', 'bar', 'map']}
|
|
75
|
+
/>
|
|
76
|
+
))
|
|
77
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
78
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-problem="too_raw"]')!)
|
|
79
|
+
fireEvent.click(
|
|
80
|
+
container.querySelector('[data-presentation-feedback-problem="wrong_columns"]')!
|
|
81
|
+
)
|
|
82
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-layout="bar"]')!)
|
|
83
|
+
const textarea = container.querySelector('[data-presentation-feedback-comment]') as HTMLTextAreaElement
|
|
84
|
+
fireEvent.input(textarea, { target: { value: 'prefer a chart' } })
|
|
85
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-submit]')!)
|
|
86
|
+
|
|
87
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
88
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
89
|
+
expect(payload.verdict).toBe('not_readable')
|
|
90
|
+
expect(payload.problems).toEqual(['too_raw', 'wrong_columns'])
|
|
91
|
+
expect(payload.preferredLayout).toBe('bar')
|
|
92
|
+
expect(payload.comment).toBe('prefer a chart')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('deselecting a problem chip removes it from the payload', () => {
|
|
96
|
+
const onSubmit = vi.fn()
|
|
97
|
+
const { container } = render(() => (
|
|
98
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
99
|
+
))
|
|
100
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
101
|
+
const chip = container.querySelector('[data-presentation-feedback-problem="wrong_chart"]')!
|
|
102
|
+
fireEvent.click(chip) // select
|
|
103
|
+
fireEvent.click(chip) // deselect
|
|
104
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-submit]')!)
|
|
105
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
106
|
+
expect(payload.problems).toBeUndefined()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('flips to the ack step after submitting', () => {
|
|
110
|
+
const { container } = render(() => (
|
|
111
|
+
<PresentationFeedback {...BASE} onSubmit={() => {}} />
|
|
112
|
+
))
|
|
113
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
114
|
+
expect(container.querySelector('[data-presentation-feedback-ack]')).toBeTruthy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('submission is best-effort — a rejected onSubmit promise does not throw', () => {
|
|
118
|
+
const onSubmit = vi.fn(() => Promise.reject(new Error('network down')))
|
|
119
|
+
const { container } = render(() => (
|
|
120
|
+
<PresentationFeedback {...BASE} onSubmit={onSubmit} />
|
|
121
|
+
))
|
|
122
|
+
expect(() =>
|
|
123
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
124
|
+
).not.toThrow()
|
|
125
|
+
expect(container.querySelector('[data-presentation-feedback-ack]')).toBeTruthy()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('labels are overridable via the labels prop', () => {
|
|
129
|
+
const { container } = render(() => (
|
|
130
|
+
<PresentationFeedback
|
|
131
|
+
{...BASE}
|
|
132
|
+
onSubmit={() => {}}
|
|
133
|
+
labels={{ readable: 'Lisible', notReadable: 'Pas lisible' }}
|
|
134
|
+
/>
|
|
135
|
+
))
|
|
136
|
+
const readableBtn = container.querySelector(
|
|
137
|
+
'[data-presentation-feedback-verdict="readable"]'
|
|
138
|
+
)
|
|
139
|
+
expect(readableBtn?.textContent).toBe('Lisible')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('hides the layout picker when no options are given', () => {
|
|
143
|
+
const { container } = render(() => (
|
|
144
|
+
<PresentationFeedback {...BASE} onSubmit={() => {}} />
|
|
145
|
+
))
|
|
146
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="not_readable"]')!)
|
|
147
|
+
expect(container.querySelector('[data-presentation-feedback-layout]')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('omits queryHash from the payload when not provided', () => {
|
|
151
|
+
const onSubmit = vi.fn()
|
|
152
|
+
const { container } = render(() => (
|
|
153
|
+
<PresentationFeedback
|
|
154
|
+
connectorId="c"
|
|
155
|
+
toolName="t"
|
|
156
|
+
onSubmit={onSubmit}
|
|
157
|
+
/>
|
|
158
|
+
))
|
|
159
|
+
fireEvent.click(container.querySelector('[data-presentation-feedback-verdict="readable"]')!)
|
|
160
|
+
const payload = onSubmit.mock.calls[0][0] as ConnectorRenderFeedback
|
|
161
|
+
expect(payload.queryHash).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PresentationFeedback — feedback on how a connector result was *presented*.
|
|
3
|
+
*
|
|
4
|
+
* @experimental
|
|
5
|
+
* @since v6.6.0 (R3 / D9 of ROADMAP-opendata-macro-mcpui)
|
|
6
|
+
*
|
|
7
|
+
* ## A separate axis from `FeedbackInline`
|
|
8
|
+
*
|
|
9
|
+
* MCP-UI has two distinct feedback widgets, kept separate on purpose
|
|
10
|
+
* (cf. R3) :
|
|
11
|
+
*
|
|
12
|
+
* - **`FeedbackInline`** — was the *answer* good? (response quality)
|
|
13
|
+
* - **`PresentationFeedback`** (this) — was the answer *shown well*?
|
|
14
|
+
* (layout / readability)
|
|
15
|
+
*
|
|
16
|
+
* They are separate components, separate exports, separate payloads — so
|
|
17
|
+
* the two axes never collapse into one in the UX or in the logs.
|
|
18
|
+
*
|
|
19
|
+
* ## Stateless — host owns persistence and re-render
|
|
20
|
+
*
|
|
21
|
+
* On submit the component calls `onSubmit(feedback)` with a
|
|
22
|
+
* `ConnectorRenderFeedback` payload and flips to its acknowledgement state.
|
|
23
|
+
* It does NOT persist anything and does NOT re-render the result itself
|
|
24
|
+
* (cf. D1 : adapter pure + host-owned state). The host persists the
|
|
25
|
+
* feedback and, if it wants to "close the loop", re-runs its adapter with
|
|
26
|
+
* the corrected `preferredLayout`.
|
|
27
|
+
*
|
|
28
|
+
* Submission is best-effort : a rejected `onSubmit` promise is swallowed,
|
|
29
|
+
* the UI still flips.
|
|
30
|
+
*
|
|
31
|
+
* ## Localization
|
|
32
|
+
*
|
|
33
|
+
* All labels ship in English and are overridable via the `labels` prop
|
|
34
|
+
* (partial). This component carries its own label bag rather than routing
|
|
35
|
+
* through `MCPUIStringsProvider` — its label set is large and specific to
|
|
36
|
+
* this one widget.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* <PresentationFeedback
|
|
41
|
+
* connectorId="datagouv"
|
|
42
|
+
* toolName="datagouv.search"
|
|
43
|
+
* queryHash={result.queryHash}
|
|
44
|
+
* layoutType="table"
|
|
45
|
+
* preferredLayoutOptions={['table', 'bar', 'map']}
|
|
46
|
+
* onSubmit={(fb) => fetch('/api/render-feedback', {
|
|
47
|
+
* method: 'POST', body: JSON.stringify(fb),
|
|
48
|
+
* })}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { Component, Show, For, createSignal } from 'solid-js'
|
|
54
|
+
import type {
|
|
55
|
+
ConnectorRenderFeedback,
|
|
56
|
+
ConnectorRenderProblem,
|
|
57
|
+
ConnectorPreferredLayout,
|
|
58
|
+
} from '@seed-ship/mcp-ui-spec'
|
|
59
|
+
|
|
60
|
+
/** The full set of problem tags, in display order. */
|
|
61
|
+
const PROBLEM_ORDER: ConnectorRenderProblem[] = [
|
|
62
|
+
'too_raw',
|
|
63
|
+
'wrong_columns',
|
|
64
|
+
'wrong_chart',
|
|
65
|
+
'missing_context',
|
|
66
|
+
'wrong_unit',
|
|
67
|
+
'bad_grouping',
|
|
68
|
+
'missing_dataset_context',
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
export interface PresentationFeedbackLabels {
|
|
72
|
+
/** Resting-state question. */
|
|
73
|
+
prompt: string
|
|
74
|
+
/** Positive verdict button. */
|
|
75
|
+
readable: string
|
|
76
|
+
/** Negative verdict button — opens the detail step. */
|
|
77
|
+
notReadable: string
|
|
78
|
+
/** Heading of the detail step. */
|
|
79
|
+
problemsPrompt: string
|
|
80
|
+
/** Per-problem chip labels. */
|
|
81
|
+
problemTooRaw: string
|
|
82
|
+
problemWrongColumns: string
|
|
83
|
+
problemWrongChart: string
|
|
84
|
+
problemMissingContext: string
|
|
85
|
+
problemWrongUnit: string
|
|
86
|
+
problemBadGrouping: string
|
|
87
|
+
problemMissingDatasetContext: string
|
|
88
|
+
/** Heading of the optional layout picker. */
|
|
89
|
+
preferLayoutPrompt: string
|
|
90
|
+
/** Free-text comment placeholder. */
|
|
91
|
+
commentPlaceholder: string
|
|
92
|
+
/** Submit button of the detail step. */
|
|
93
|
+
submit: string
|
|
94
|
+
/** Acknowledgement shown after submission. */
|
|
95
|
+
ack: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** English defaults. Override via the `labels` prop for other locales. */
|
|
99
|
+
export const DEFAULT_PRESENTATION_FEEDBACK_LABELS: PresentationFeedbackLabels = {
|
|
100
|
+
prompt: 'Is this shown clearly?',
|
|
101
|
+
readable: 'Clear',
|
|
102
|
+
notReadable: 'Not clear',
|
|
103
|
+
problemsPrompt: "What's off?",
|
|
104
|
+
problemTooRaw: 'Too raw',
|
|
105
|
+
problemWrongColumns: 'Wrong columns',
|
|
106
|
+
problemWrongChart: 'Wrong chart',
|
|
107
|
+
problemMissingContext: 'Missing context',
|
|
108
|
+
problemWrongUnit: 'Wrong unit',
|
|
109
|
+
problemBadGrouping: 'Bad grouping',
|
|
110
|
+
problemMissingDatasetContext: 'Missing dataset context',
|
|
111
|
+
preferLayoutPrompt: 'Better shown as',
|
|
112
|
+
commentPlaceholder: 'Anything else? (optional)',
|
|
113
|
+
submit: 'Send feedback',
|
|
114
|
+
ack: 'Thanks — noted.',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const PROBLEM_LABEL_KEY: Record<ConnectorRenderProblem, keyof PresentationFeedbackLabels> = {
|
|
118
|
+
too_raw: 'problemTooRaw',
|
|
119
|
+
wrong_columns: 'problemWrongColumns',
|
|
120
|
+
wrong_chart: 'problemWrongChart',
|
|
121
|
+
missing_context: 'problemMissingContext',
|
|
122
|
+
wrong_unit: 'problemWrongUnit',
|
|
123
|
+
bad_grouping: 'problemBadGrouping',
|
|
124
|
+
missing_dataset_context: 'problemMissingDatasetContext',
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface PresentationFeedbackProps {
|
|
128
|
+
/** Connector whose result is being rated. */
|
|
129
|
+
connectorId: string
|
|
130
|
+
/** Tool that produced the result. */
|
|
131
|
+
toolName: string
|
|
132
|
+
/** Stable key tying feedback to a `ConnectorDynamicResultV1.queryHash`. */
|
|
133
|
+
queryHash?: string
|
|
134
|
+
/** What is being rated (e.g. `'primary'`). Passed through to the payload. */
|
|
135
|
+
renderKind?: string
|
|
136
|
+
/** The layout type currently shown (e.g. `'table'`). Passed through. */
|
|
137
|
+
layoutType?: string
|
|
138
|
+
/**
|
|
139
|
+
* Called once on submit with the assembled `ConnectorRenderFeedback`.
|
|
140
|
+
* Persistence + any re-render are the host's responsibility.
|
|
141
|
+
*/
|
|
142
|
+
onSubmit: (feedback: ConnectorRenderFeedback) => void | Promise<void>
|
|
143
|
+
/**
|
|
144
|
+
* Layout alternatives offered in the detail step. Omit (or pass an empty
|
|
145
|
+
* array) to hide the layout picker entirely.
|
|
146
|
+
*/
|
|
147
|
+
preferredLayoutOptions?: ConnectorPreferredLayout[]
|
|
148
|
+
/** Partial label override (English defaults otherwise). */
|
|
149
|
+
labels?: Partial<PresentationFeedbackLabels>
|
|
150
|
+
/** Extra Tailwind classes on the container. */
|
|
151
|
+
class?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @experimental
|
|
156
|
+
* Presentation-quality feedback widget (readable / not-readable + detail).
|
|
157
|
+
*/
|
|
158
|
+
export const PresentationFeedback: Component<PresentationFeedbackProps> = (props) => {
|
|
159
|
+
const [step, setStep] = createSignal<'idle' | 'detail' | 'done'>('idle')
|
|
160
|
+
const [problems, setProblems] = createSignal<Set<ConnectorRenderProblem>>(new Set())
|
|
161
|
+
const [preferred, setPreferred] = createSignal<ConnectorPreferredLayout | undefined>(undefined)
|
|
162
|
+
const [comment, setComment] = createSignal('')
|
|
163
|
+
|
|
164
|
+
const label = (key: keyof PresentationFeedbackLabels): string =>
|
|
165
|
+
props.labels?.[key] ?? DEFAULT_PRESENTATION_FEEDBACK_LABELS[key]
|
|
166
|
+
|
|
167
|
+
const emit = (feedback: ConnectorRenderFeedback) => {
|
|
168
|
+
try {
|
|
169
|
+
// Fire-and-forget — feedback is best-effort, a rejection must not
|
|
170
|
+
// break the UI (mirrors FeedbackInline).
|
|
171
|
+
const result = props.onSubmit(feedback)
|
|
172
|
+
if (result && typeof (result as Promise<void>).catch === 'function') {
|
|
173
|
+
;(result as Promise<void>).catch(() => {
|
|
174
|
+
/* non-blocking */
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
/* non-blocking */
|
|
179
|
+
}
|
|
180
|
+
setStep('done')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const base = (): Pick<
|
|
184
|
+
ConnectorRenderFeedback,
|
|
185
|
+
'connectorId' | 'toolName' | 'queryHash' | 'renderKind' | 'layoutType'
|
|
186
|
+
> => ({
|
|
187
|
+
connectorId: props.connectorId,
|
|
188
|
+
toolName: props.toolName,
|
|
189
|
+
...(props.queryHash ? { queryHash: props.queryHash } : {}),
|
|
190
|
+
...(props.renderKind ? { renderKind: props.renderKind } : {}),
|
|
191
|
+
...(props.layoutType ? { layoutType: props.layoutType } : {}),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const submitReadable = () => emit({ ...base(), verdict: 'readable' })
|
|
195
|
+
|
|
196
|
+
const submitNotReadable = () => {
|
|
197
|
+
const picked = [...problems()]
|
|
198
|
+
const text = comment().trim()
|
|
199
|
+
emit({
|
|
200
|
+
...base(),
|
|
201
|
+
verdict: 'not_readable',
|
|
202
|
+
...(picked.length > 0 ? { problems: picked } : {}),
|
|
203
|
+
...(preferred() ? { preferredLayout: preferred() } : {}),
|
|
204
|
+
...(text ? { comment: text } : {}),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const toggleProblem = (p: ConnectorRenderProblem) => {
|
|
209
|
+
setProblems((prev) => {
|
|
210
|
+
const next = new Set(prev)
|
|
211
|
+
if (next.has(p)) next.delete(p)
|
|
212
|
+
else next.add(p)
|
|
213
|
+
return next
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<div
|
|
219
|
+
class={`text-xs ${props.class ?? ''}`.trim()}
|
|
220
|
+
data-presentation-feedback-step={step()}
|
|
221
|
+
>
|
|
222
|
+
{/* ── Step 1 : verdict ─────────────────────────────── */}
|
|
223
|
+
<Show when={step() === 'idle'}>
|
|
224
|
+
<div class="flex items-center gap-2">
|
|
225
|
+
<span class="text-deposium-slate-500">{label('prompt')}</span>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={submitReadable}
|
|
229
|
+
class="px-2 py-0.5 rounded border border-gray-200 dark:border-gray-600 text-deposium-slate-600 dark:text-gray-300 hover:border-green-500 hover:text-green-600 transition-colors"
|
|
230
|
+
data-presentation-feedback-verdict="readable"
|
|
231
|
+
>
|
|
232
|
+
{label('readable')}
|
|
233
|
+
</button>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={() => setStep('detail')}
|
|
237
|
+
class="px-2 py-0.5 rounded border border-gray-200 dark:border-gray-600 text-deposium-slate-600 dark:text-gray-300 hover:border-amber-500 hover:text-amber-600 transition-colors"
|
|
238
|
+
data-presentation-feedback-verdict="not_readable"
|
|
239
|
+
>
|
|
240
|
+
{label('notReadable')}
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
</Show>
|
|
244
|
+
|
|
245
|
+
{/* ── Step 2 : detail ──────────────────────────────── */}
|
|
246
|
+
<Show when={step() === 'detail'}>
|
|
247
|
+
<div class="flex flex-col gap-2 p-2 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
248
|
+
<span class="font-medium text-deposium-slate-600 dark:text-gray-300">
|
|
249
|
+
{label('problemsPrompt')}
|
|
250
|
+
</span>
|
|
251
|
+
|
|
252
|
+
{/* Problem chips */}
|
|
253
|
+
<div class="flex flex-wrap gap-1">
|
|
254
|
+
<For each={PROBLEM_ORDER}>
|
|
255
|
+
{(p) => (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={() => toggleProblem(p)}
|
|
259
|
+
aria-pressed={problems().has(p)}
|
|
260
|
+
data-presentation-feedback-problem={p}
|
|
261
|
+
class={`px-2 py-0.5 rounded-full border transition-colors ${
|
|
262
|
+
problems().has(p)
|
|
263
|
+
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200'
|
|
264
|
+
: 'border-gray-200 dark:border-gray-600 text-deposium-slate-500 hover:border-amber-400'
|
|
265
|
+
}`}
|
|
266
|
+
>
|
|
267
|
+
{label(PROBLEM_LABEL_KEY[p])}
|
|
268
|
+
</button>
|
|
269
|
+
)}
|
|
270
|
+
</For>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Optional layout picker */}
|
|
274
|
+
<Show when={(props.preferredLayoutOptions?.length ?? 0) > 0}>
|
|
275
|
+
<div class="flex flex-wrap items-center gap-1">
|
|
276
|
+
<span class="text-deposium-slate-500">{label('preferLayoutPrompt')}</span>
|
|
277
|
+
<For each={props.preferredLayoutOptions}>
|
|
278
|
+
{(opt) => (
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={() => setPreferred((cur) => (cur === opt ? undefined : opt))}
|
|
282
|
+
aria-pressed={preferred() === opt}
|
|
283
|
+
data-presentation-feedback-layout={opt}
|
|
284
|
+
class={`px-2 py-0.5 rounded border transition-colors ${
|
|
285
|
+
preferred() === opt
|
|
286
|
+
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200'
|
|
287
|
+
: 'border-gray-200 dark:border-gray-600 text-deposium-slate-500 hover:border-blue-400'
|
|
288
|
+
}`}
|
|
289
|
+
>
|
|
290
|
+
{opt}
|
|
291
|
+
</button>
|
|
292
|
+
)}
|
|
293
|
+
</For>
|
|
294
|
+
</div>
|
|
295
|
+
</Show>
|
|
296
|
+
|
|
297
|
+
{/* Free-text comment */}
|
|
298
|
+
<textarea
|
|
299
|
+
value={comment()}
|
|
300
|
+
onInput={(e) => setComment(e.currentTarget.value)}
|
|
301
|
+
placeholder={label('commentPlaceholder')}
|
|
302
|
+
rows={2}
|
|
303
|
+
class="w-full rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 p-1.5 text-xs resize-y"
|
|
304
|
+
data-presentation-feedback-comment
|
|
305
|
+
/>
|
|
306
|
+
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
onClick={submitNotReadable}
|
|
310
|
+
class="self-end px-3 py-1 rounded bg-amber-500 text-white font-medium hover:bg-amber-600 transition-colors"
|
|
311
|
+
data-presentation-feedback-submit
|
|
312
|
+
>
|
|
313
|
+
{label('submit')}
|
|
314
|
+
</button>
|
|
315
|
+
</div>
|
|
316
|
+
</Show>
|
|
317
|
+
|
|
318
|
+
{/* ── Step 3 : acknowledgement ─────────────────────── */}
|
|
319
|
+
<Show when={step() === 'done'}>
|
|
320
|
+
<span class="text-deposium-slate-500" data-presentation-feedback-ack>
|
|
321
|
+
{label('ack')}
|
|
322
|
+
</span>
|
|
323
|
+
</Show>
|
|
324
|
+
</div>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v6.6.0 — Streaming/static rendering parity (Gap 1 of ROADMAP-opendata-macro-mcpui).
|
|
3
|
+
*
|
|
4
|
+
* Before v6.6.0, `StreamingUIRenderer` used an inline simplified renderer
|
|
5
|
+
* (`StreamingComponentRenderer`) that only showed a component's type label +
|
|
6
|
+
* title — a streamed `table` did NOT render a real `<table>`. v6.6.0 deletes
|
|
7
|
+
* that and delegates each streamed `UIComponent` to the real
|
|
8
|
+
* `<UIResourceRenderer>`.
|
|
9
|
+
*
|
|
10
|
+
* These tests pin the parity : a component rendered through the streaming
|
|
11
|
+
* path produces the SAME functional DOM as the static path, and the legacy
|
|
12
|
+
* "type + title placeholder" markers are gone.
|
|
13
|
+
*
|
|
14
|
+
* `useStreamingUI` is mocked so components can be injected directly, without
|
|
15
|
+
* standing up an SSE endpoint.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
19
|
+
import { render, cleanup } from '@solidjs/testing-library'
|
|
20
|
+
|
|
21
|
+
// ── Mock the SSE hook : inject a fixed component list ─────────────
|
|
22
|
+
const streamedComponents = vi.fn<() => unknown[]>(() => [])
|
|
23
|
+
|
|
24
|
+
vi.mock('../hooks/useStreamingUI', () => ({
|
|
25
|
+
useStreamingUI: () => ({
|
|
26
|
+
components: streamedComponents,
|
|
27
|
+
isLoading: () => false,
|
|
28
|
+
isStreaming: () => false,
|
|
29
|
+
error: () => null,
|
|
30
|
+
progress: () => ({ message: '', receivedCount: 0, totalCount: null }),
|
|
31
|
+
metadata: () => null,
|
|
32
|
+
startStreaming: () => {},
|
|
33
|
+
stopStreaming: () => {},
|
|
34
|
+
}),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
// Imported AFTER the mock declaration so the mock is in effect.
|
|
38
|
+
const { StreamingUIRenderer } = await import('./StreamingUIRenderer')
|
|
39
|
+
const { UIResourceRenderer } = await import('./UIResourceRenderer')
|
|
40
|
+
|
|
41
|
+
const TABLE_COMPONENT = {
|
|
42
|
+
id: 'tbl-1',
|
|
43
|
+
type: 'table' as const,
|
|
44
|
+
position: { colStart: 1, colSpan: 12 },
|
|
45
|
+
params: {
|
|
46
|
+
title: 'Prix au m2',
|
|
47
|
+
columns: [
|
|
48
|
+
{ key: 'ville', label: 'Ville' },
|
|
49
|
+
{ key: 'prix', label: 'Prix/m2' },
|
|
50
|
+
],
|
|
51
|
+
rows: [
|
|
52
|
+
{ ville: 'Toulouse', prix: 3200 },
|
|
53
|
+
{ ville: 'Montpellier', prix: 3600 },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const METRIC_COMPONENT = {
|
|
59
|
+
id: 'mtr-1',
|
|
60
|
+
type: 'metric' as const,
|
|
61
|
+
position: { colStart: 1, colSpan: 4 },
|
|
62
|
+
params: { title: 'Prix median', value: 3400, unit: 'EUR/m2' },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ACTION_GROUP_COMPONENT = {
|
|
66
|
+
id: 'ag-1',
|
|
67
|
+
type: 'action-group' as const,
|
|
68
|
+
position: { colStart: 1, colSpan: 12 },
|
|
69
|
+
params: {
|
|
70
|
+
actions: [
|
|
71
|
+
{ label: 'Comparer avec Paris', action: 'tool-call', toolName: 'datagouv.search' },
|
|
72
|
+
{ label: 'Charger le dataset', action: 'tool-call', toolName: 'datagouv.load' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('StreamingUIRenderer parity (v6.6.0)', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
cleanup()
|
|
80
|
+
streamedComponents.mockReturnValue([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('streams a real <table>, not a type-label placeholder', () => {
|
|
84
|
+
streamedComponents.mockReturnValue([TABLE_COMPONENT])
|
|
85
|
+
const { container } = render(() => (
|
|
86
|
+
<StreamingUIRenderer query="immobilier toulouse" spaceIds={[]} />
|
|
87
|
+
))
|
|
88
|
+
// Real TableRenderer output — an actual <table> with the rows.
|
|
89
|
+
const table = container.querySelector('table')
|
|
90
|
+
expect(table).toBeTruthy()
|
|
91
|
+
expect(container.textContent).toContain('Toulouse')
|
|
92
|
+
expect(container.textContent).toContain('Montpellier')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('does NOT emit the legacy simplified-renderer markers', () => {
|
|
96
|
+
streamedComponents.mockReturnValue([TABLE_COMPONENT])
|
|
97
|
+
const { container } = render(() => (
|
|
98
|
+
<StreamingUIRenderer query="immobilier toulouse" spaceIds={[]} />
|
|
99
|
+
))
|
|
100
|
+
// The pre-v6.6.0 inline renderer printed "Component ID: xxxxxxxx..."
|
|
101
|
+
expect(container.textContent).not.toContain('Component ID:')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('streamed table DOM matches the static UIResourceRenderer path', () => {
|
|
105
|
+
streamedComponents.mockReturnValue([TABLE_COMPONENT])
|
|
106
|
+
const streamed = render(() => (
|
|
107
|
+
<StreamingUIRenderer query="q" spaceIds={[]} />
|
|
108
|
+
))
|
|
109
|
+
const streamedTableCells = streamed.container.querySelectorAll('table td, table th').length
|
|
110
|
+
|
|
111
|
+
cleanup()
|
|
112
|
+
|
|
113
|
+
const staticRender = render(() => <UIResourceRenderer content={TABLE_COMPONENT} />)
|
|
114
|
+
const staticTableCells = staticRender.container.querySelectorAll('table td, table th').length
|
|
115
|
+
|
|
116
|
+
expect(streamedTableCells).toBeGreaterThan(0)
|
|
117
|
+
expect(streamedTableCells).toBe(staticTableCells)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('streams a real metric value', () => {
|
|
121
|
+
streamedComponents.mockReturnValue([METRIC_COMPONENT])
|
|
122
|
+
const { container } = render(() => (
|
|
123
|
+
<StreamingUIRenderer query="prix" spaceIds={[]} />
|
|
124
|
+
))
|
|
125
|
+
expect(container.textContent).toContain('3400')
|
|
126
|
+
expect(container.textContent).toContain('Prix median')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('streams a real action-group with clickable buttons', () => {
|
|
130
|
+
streamedComponents.mockReturnValue([ACTION_GROUP_COMPONENT])
|
|
131
|
+
const { container } = render(() => (
|
|
132
|
+
<StreamingUIRenderer query="immobilier" spaceIds={[]} />
|
|
133
|
+
))
|
|
134
|
+
const buttons = Array.from(container.querySelectorAll('button'))
|
|
135
|
+
const labels = buttons.map((b) => b.textContent?.trim())
|
|
136
|
+
expect(labels).toContain('Comparer avec Paris')
|
|
137
|
+
expect(labels).toContain('Charger le dataset')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('renders every streamed component (multi-component layout)', () => {
|
|
141
|
+
streamedComponents.mockReturnValue([TABLE_COMPONENT, METRIC_COMPONENT, ACTION_GROUP_COMPONENT])
|
|
142
|
+
const { container } = render(() => (
|
|
143
|
+
<StreamingUIRenderer query="dashboard" spaceIds={[]} />
|
|
144
|
+
))
|
|
145
|
+
expect(container.querySelector('table')).toBeTruthy()
|
|
146
|
+
expect(container.textContent).toContain('Prix median')
|
|
147
|
+
expect(container.textContent).toContain('Comparer avec Paris')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('carries data-mcp-ui-component-id on streamed components (v6.5.0 identity)', () => {
|
|
151
|
+
streamedComponents.mockReturnValue([TABLE_COMPONENT])
|
|
152
|
+
const { container } = render(() => (
|
|
153
|
+
<StreamingUIRenderer query="q" spaceIds={[]} />
|
|
154
|
+
))
|
|
155
|
+
// Delegation to UIResourceRenderer means the v6.5.0 identity attrs apply.
|
|
156
|
+
expect(container.querySelector('[data-mcp-ui-component-id="tbl-1"]')).toBeTruthy()
|
|
157
|
+
})
|
|
158
|
+
})
|