@seed-ship/mcp-ui-solid 2.3.0 → 2.4.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/dist/components/ChatPrompt.cjs +271 -0
- package/dist/components/ChatPrompt.cjs.map +1 -0
- package/dist/components/ChatPrompt.d.ts +33 -0
- package/dist/components/ChatPrompt.d.ts.map +1 -0
- package/dist/components/ChatPrompt.js +271 -0
- package/dist/components/ChatPrompt.js.map +1 -0
- package/dist/hooks/useChatBus.cjs +28 -0
- package/dist/hooks/useChatBus.cjs.map +1 -0
- package/dist/hooks/useChatBus.d.ts +56 -0
- package/dist/hooks/useChatBus.d.ts.map +1 -0
- package/dist/hooks/useChatBus.js +28 -0
- package/dist/hooks/useChatBus.js.map +1 -0
- package/dist/index.cjs +9 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/services/chat-bus.cjs +118 -0
- package/dist/services/chat-bus.cjs.map +1 -0
- package/dist/services/chat-bus.d.ts +43 -0
- package/dist/services/chat-bus.d.ts.map +1 -0
- package/dist/services/chat-bus.js +118 -0
- package/dist/services/chat-bus.js.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/types/chat-bus.d.ts +286 -0
- package/dist/types/chat-bus.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/ChatPrompt.test.tsx +280 -0
- package/src/components/ChatPrompt.tsx +263 -0
- package/src/hooks/useChatBus.tsx +81 -0
- package/src/index.ts +34 -0
- package/src/services/chat-bus.test.ts +306 -0
- package/src/services/chat-bus.ts +183 -0
- package/src/services/index.ts +2 -0
- package/src/types/chat-bus.ts +320 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ChatPrompt component
|
|
3
|
+
* Phase 2: choice, confirm, form subtypes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
7
|
+
import { render, fireEvent, cleanup } from '@solidjs/testing-library'
|
|
8
|
+
import { ChatPrompt } from './ChatPrompt'
|
|
9
|
+
import type { ChatPromptConfig, ChatPromptResponse } from '../types/chat-bus'
|
|
10
|
+
|
|
11
|
+
describe('ChatPrompt', () => {
|
|
12
|
+
beforeEach(() => cleanup())
|
|
13
|
+
|
|
14
|
+
// ─── Choice ──────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe('type: choice', () => {
|
|
17
|
+
const choiceConfig: ChatPromptConfig = {
|
|
18
|
+
type: 'choice',
|
|
19
|
+
title: 'Select format',
|
|
20
|
+
config: {
|
|
21
|
+
options: [
|
|
22
|
+
{ value: 'pdf', label: 'PDF', icon: '📄' },
|
|
23
|
+
{ value: 'csv', label: 'CSV' },
|
|
24
|
+
{ value: 'json', label: 'JSON', description: 'Raw data' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
it('renders title and all options', () => {
|
|
30
|
+
const { getByText } = render(() => (
|
|
31
|
+
<ChatPrompt config={choiceConfig} onSubmit={() => {}} />
|
|
32
|
+
))
|
|
33
|
+
|
|
34
|
+
expect(getByText('Select format')).toBeDefined()
|
|
35
|
+
expect(getByText('PDF')).toBeDefined()
|
|
36
|
+
expect(getByText('CSV')).toBeDefined()
|
|
37
|
+
expect(getByText('JSON')).toBeDefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders option icons and descriptions', () => {
|
|
41
|
+
const { getByText } = render(() => (
|
|
42
|
+
<ChatPrompt config={choiceConfig} onSubmit={() => {}} />
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
expect(getByText('📄')).toBeDefined()
|
|
46
|
+
expect(getByText('Raw data')).toBeDefined()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('calls onSubmit with value and label when option clicked', () => {
|
|
50
|
+
const onSubmit = vi.fn()
|
|
51
|
+
const { getByText } = render(() => (
|
|
52
|
+
<ChatPrompt config={choiceConfig} onSubmit={onSubmit} />
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
fireEvent.click(getByText('CSV'))
|
|
56
|
+
|
|
57
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
58
|
+
type: 'choice',
|
|
59
|
+
value: 'csv',
|
|
60
|
+
label: 'CSV',
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('supports vertical layout', () => {
|
|
65
|
+
const verticalConfig: ChatPromptConfig = {
|
|
66
|
+
...choiceConfig,
|
|
67
|
+
config: { ...choiceConfig.config as any, layout: 'vertical' },
|
|
68
|
+
}
|
|
69
|
+
const { container } = render(() => (
|
|
70
|
+
<ChatPrompt config={verticalConfig} onSubmit={() => {}} />
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
const body = container.querySelector('.flex-col')
|
|
74
|
+
expect(body).not.toBeNull()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ─── Confirm ─────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('type: confirm', () => {
|
|
81
|
+
const confirmConfig: ChatPromptConfig = {
|
|
82
|
+
type: 'confirm',
|
|
83
|
+
title: 'Delete 47 documents?',
|
|
84
|
+
config: {
|
|
85
|
+
message: 'This action cannot be undone.',
|
|
86
|
+
confirmLabel: 'Delete',
|
|
87
|
+
cancelLabel: 'Keep',
|
|
88
|
+
variant: 'danger',
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
it('renders title, message, and buttons', () => {
|
|
93
|
+
const { getByText } = render(() => (
|
|
94
|
+
<ChatPrompt config={confirmConfig} onSubmit={() => {}} />
|
|
95
|
+
))
|
|
96
|
+
|
|
97
|
+
expect(getByText('Delete 47 documents?')).toBeDefined()
|
|
98
|
+
expect(getByText('This action cannot be undone.')).toBeDefined()
|
|
99
|
+
expect(getByText('Delete')).toBeDefined()
|
|
100
|
+
expect(getByText('Keep')).toBeDefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('calls onSubmit with confirmed on confirm click', () => {
|
|
104
|
+
const onSubmit = vi.fn()
|
|
105
|
+
const { getByText } = render(() => (
|
|
106
|
+
<ChatPrompt config={confirmConfig} onSubmit={onSubmit} />
|
|
107
|
+
))
|
|
108
|
+
|
|
109
|
+
fireEvent.click(getByText('Delete'))
|
|
110
|
+
|
|
111
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
112
|
+
type: 'confirm',
|
|
113
|
+
value: 'confirmed',
|
|
114
|
+
label: 'Delete',
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('calls onSubmit with dismissed on cancel click', () => {
|
|
119
|
+
const onSubmit = vi.fn()
|
|
120
|
+
const onDismiss = vi.fn()
|
|
121
|
+
const { getByText } = render(() => (
|
|
122
|
+
<ChatPrompt config={confirmConfig} onSubmit={onSubmit} onDismiss={onDismiss} />
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
fireEvent.click(getByText('Keep'))
|
|
126
|
+
|
|
127
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
128
|
+
type: 'confirm',
|
|
129
|
+
value: 'cancelled',
|
|
130
|
+
label: 'Keep',
|
|
131
|
+
dismissed: true,
|
|
132
|
+
})
|
|
133
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('uses default labels when not provided', () => {
|
|
137
|
+
const simpleConfig: ChatPromptConfig = {
|
|
138
|
+
type: 'confirm',
|
|
139
|
+
title: 'Are you sure?',
|
|
140
|
+
config: {},
|
|
141
|
+
}
|
|
142
|
+
const { getByText } = render(() => (
|
|
143
|
+
<ChatPrompt config={simpleConfig} onSubmit={() => {}} />
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
expect(getByText('Confirm')).toBeDefined()
|
|
147
|
+
expect(getByText('Cancel')).toBeDefined()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('uses danger styling for danger variant', () => {
|
|
151
|
+
const { getByText } = render(() => (
|
|
152
|
+
<ChatPrompt config={confirmConfig} onSubmit={() => {}} />
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
const deleteBtn = getByText('Delete')
|
|
156
|
+
expect(deleteBtn.className).toContain('bg-red-600')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── Form ────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
describe('type: form', () => {
|
|
163
|
+
const formConfig: ChatPromptConfig = {
|
|
164
|
+
type: 'form',
|
|
165
|
+
title: 'Additional info',
|
|
166
|
+
config: {
|
|
167
|
+
fields: [
|
|
168
|
+
{ name: 'title', label: 'Title', type: 'text', required: true, placeholder: 'Enter title' },
|
|
169
|
+
{ name: 'category', label: 'Category', type: 'select', options: [{ label: 'Report', value: 'report' }, { label: 'Invoice', value: 'invoice' }] },
|
|
170
|
+
{ name: 'notes', label: 'Notes', type: 'textarea' },
|
|
171
|
+
],
|
|
172
|
+
submitLabel: 'Send',
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it('renders all fields', () => {
|
|
177
|
+
const { getByText, getByPlaceholderText } = render(() => (
|
|
178
|
+
<ChatPrompt config={formConfig} onSubmit={() => {}} />
|
|
179
|
+
))
|
|
180
|
+
|
|
181
|
+
expect(getByText('Title')).toBeDefined()
|
|
182
|
+
expect(getByText('Category')).toBeDefined()
|
|
183
|
+
expect(getByText('Notes')).toBeDefined()
|
|
184
|
+
expect(getByPlaceholderText('Enter title')).toBeDefined()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('submit button is disabled when required fields empty', () => {
|
|
188
|
+
const { getByText } = render(() => (
|
|
189
|
+
<ChatPrompt config={formConfig} onSubmit={() => {}} />
|
|
190
|
+
))
|
|
191
|
+
|
|
192
|
+
const submitBtn = getByText('Send')
|
|
193
|
+
expect(submitBtn.hasAttribute('disabled')).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('submit button enables when required fields filled', async () => {
|
|
197
|
+
const { getByText, getByPlaceholderText } = render(() => (
|
|
198
|
+
<ChatPrompt config={formConfig} onSubmit={() => {}} />
|
|
199
|
+
))
|
|
200
|
+
|
|
201
|
+
const titleInput = getByPlaceholderText('Enter title')
|
|
202
|
+
fireEvent.input(titleInput, { target: { value: 'My Report' } })
|
|
203
|
+
|
|
204
|
+
const submitBtn = getByText('Send')
|
|
205
|
+
expect(submitBtn.hasAttribute('disabled')).toBe(false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('calls onSubmit with form data on submit', async () => {
|
|
209
|
+
const onSubmit = vi.fn()
|
|
210
|
+
const { getByText, getByPlaceholderText } = render(() => (
|
|
211
|
+
<ChatPrompt config={formConfig} onSubmit={onSubmit} />
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
fireEvent.input(getByPlaceholderText('Enter title'), { target: { value: 'Q4 Report' } })
|
|
215
|
+
fireEvent.click(getByText('Send'))
|
|
216
|
+
|
|
217
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
218
|
+
type: 'form',
|
|
219
|
+
value: { title: 'Q4 Report' },
|
|
220
|
+
label: 'title: Q4 Report',
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('shows required indicator on required fields', () => {
|
|
225
|
+
const { container } = render(() => (
|
|
226
|
+
<ChatPrompt config={formConfig} onSubmit={() => {}} />
|
|
227
|
+
))
|
|
228
|
+
|
|
229
|
+
const asterisks = container.querySelectorAll('.text-red-500')
|
|
230
|
+
expect(asterisks.length).toBeGreaterThan(0)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ─── Dismiss ─────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe('dismiss', () => {
|
|
237
|
+
it('calls onSubmit with dismissed:true and onDismiss when X clicked', () => {
|
|
238
|
+
const onSubmit = vi.fn()
|
|
239
|
+
const onDismiss = vi.fn()
|
|
240
|
+
const config: ChatPromptConfig = {
|
|
241
|
+
type: 'choice',
|
|
242
|
+
title: 'Test',
|
|
243
|
+
config: { options: [{ value: 'a', label: 'A' }] },
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { getByLabelText } = render(() => (
|
|
247
|
+
<ChatPrompt config={config} onSubmit={onSubmit} onDismiss={onDismiss} />
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
fireEvent.click(getByLabelText('Dismiss'))
|
|
251
|
+
|
|
252
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
253
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
254
|
+
type: 'choice',
|
|
255
|
+
value: '',
|
|
256
|
+
label: '',
|
|
257
|
+
dismissed: true,
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// ─── Accessibility ───────────────────────────────────
|
|
263
|
+
|
|
264
|
+
describe('accessibility', () => {
|
|
265
|
+
it('has role="dialog" with title as label', () => {
|
|
266
|
+
const config: ChatPromptConfig = {
|
|
267
|
+
type: 'choice',
|
|
268
|
+
title: 'Pick one',
|
|
269
|
+
config: { options: [{ value: 'a', label: 'A' }] },
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const { getByRole } = render(() => (
|
|
273
|
+
<ChatPrompt config={config} onSubmit={() => {}} />
|
|
274
|
+
))
|
|
275
|
+
|
|
276
|
+
const dialog = getByRole('dialog')
|
|
277
|
+
expect(dialog.getAttribute('aria-label')).toBe('Pick one')
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
})
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatPrompt — Ephemeral structured interaction above chat input
|
|
3
|
+
* v2.4.0: choice, confirm, form subtypes
|
|
4
|
+
*
|
|
5
|
+
* @experimental — This component may change without major bump until v2.5.0.
|
|
6
|
+
*
|
|
7
|
+
* Renders above the chat input. User responds → Promise resolves → prompt disappears.
|
|
8
|
+
* Supports AbortSignal for cleanup on navigation (C4).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Component, Show, For, createSignal, onCleanup, Switch, Match } from 'solid-js'
|
|
12
|
+
import type {
|
|
13
|
+
ChatPromptConfig,
|
|
14
|
+
ChatPromptResponse,
|
|
15
|
+
ChoicePromptConfig,
|
|
16
|
+
ConfirmPromptConfig,
|
|
17
|
+
FormPromptConfig,
|
|
18
|
+
} from '../types/chat-bus'
|
|
19
|
+
|
|
20
|
+
export interface ChatPromptProps {
|
|
21
|
+
/** Prompt configuration */
|
|
22
|
+
config: ChatPromptConfig
|
|
23
|
+
/** Called when user responds */
|
|
24
|
+
onSubmit: (response: ChatPromptResponse) => void
|
|
25
|
+
/** Called when user dismisses */
|
|
26
|
+
onDismiss?: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @experimental
|
|
31
|
+
* Ephemeral interaction component — choice buttons, confirmation dialog, or quick form.
|
|
32
|
+
* Designed to sit between the chat messages and the input area.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <ChatPrompt
|
|
36
|
+
* config={{ type: 'choice', title: 'Format?', config: { options: [...] } }}
|
|
37
|
+
* onSubmit={(r) => bus.events.emit('onChatPromptResponse', { streamKey, response: r })}
|
|
38
|
+
* onDismiss={() => setActivePrompt(null)}
|
|
39
|
+
* />
|
|
40
|
+
*/
|
|
41
|
+
export const ChatPrompt: Component<ChatPromptProps> = (props) => {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
class="w-full max-w-2xl mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-lg overflow-hidden"
|
|
45
|
+
style={{ animation: 'chat-prompt-slide-up 0.2s ease-out' }}
|
|
46
|
+
role="dialog"
|
|
47
|
+
aria-label={props.config.title}
|
|
48
|
+
>
|
|
49
|
+
{/* Header */}
|
|
50
|
+
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 dark:border-gray-700">
|
|
51
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white">{props.config.title}</p>
|
|
52
|
+
<button
|
|
53
|
+
onClick={() => {
|
|
54
|
+
props.onDismiss?.()
|
|
55
|
+
props.onSubmit({ type: props.config.type, value: '', label: '', dismissed: true })
|
|
56
|
+
}}
|
|
57
|
+
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
58
|
+
aria-label="Dismiss"
|
|
59
|
+
>
|
|
60
|
+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
61
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
62
|
+
</svg>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Body — type-specific */}
|
|
67
|
+
<div class="px-4 py-3">
|
|
68
|
+
<Switch>
|
|
69
|
+
<Match when={props.config.type === 'choice'}>
|
|
70
|
+
<ChoiceBody
|
|
71
|
+
config={props.config.config as ChoicePromptConfig}
|
|
72
|
+
onSelect={(value, label) => props.onSubmit({ type: 'choice', value, label })}
|
|
73
|
+
/>
|
|
74
|
+
</Match>
|
|
75
|
+
<Match when={props.config.type === 'confirm'}>
|
|
76
|
+
<ConfirmBody
|
|
77
|
+
config={props.config.config as ConfirmPromptConfig}
|
|
78
|
+
onConfirm={() => props.onSubmit({ type: 'confirm', value: 'confirmed', label: (props.config.config as ConfirmPromptConfig).confirmLabel || 'Confirmed' })}
|
|
79
|
+
onCancel={() => {
|
|
80
|
+
props.onDismiss?.()
|
|
81
|
+
props.onSubmit({ type: 'confirm', value: 'cancelled', label: (props.config.config as ConfirmPromptConfig).cancelLabel || 'Cancelled', dismissed: true })
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
</Match>
|
|
85
|
+
<Match when={props.config.type === 'form'}>
|
|
86
|
+
<FormBody
|
|
87
|
+
config={props.config.config as FormPromptConfig}
|
|
88
|
+
onSubmit={(data, label) => props.onSubmit({ type: 'form', value: data, label })}
|
|
89
|
+
/>
|
|
90
|
+
</Match>
|
|
91
|
+
</Switch>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>{`
|
|
95
|
+
@keyframes chat-prompt-slide-up {
|
|
96
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
97
|
+
to { opacity: 1; transform: translateY(0); }
|
|
98
|
+
}
|
|
99
|
+
`}</style>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Choice ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
const ChoiceBody: Component<{
|
|
107
|
+
config: ChoicePromptConfig
|
|
108
|
+
onSelect: (value: string, label: string) => void
|
|
109
|
+
}> = (props) => {
|
|
110
|
+
const layoutClass = () => {
|
|
111
|
+
switch (props.config.layout) {
|
|
112
|
+
case 'vertical': return 'flex flex-col gap-2'
|
|
113
|
+
case 'grid': return 'grid grid-cols-2 gap-2'
|
|
114
|
+
default: return 'flex flex-wrap gap-2'
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div class={layoutClass()}>
|
|
120
|
+
<For each={props.config.options}>
|
|
121
|
+
{(option) => (
|
|
122
|
+
<button
|
|
123
|
+
onClick={() => props.onSelect(option.value, option.label)}
|
|
124
|
+
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white hover:bg-blue-50 hover:border-blue-300 dark:hover:bg-blue-900/30 dark:hover:border-blue-600 transition-colors text-left"
|
|
125
|
+
>
|
|
126
|
+
<Show when={option.icon}>
|
|
127
|
+
<span class="mr-2">{option.icon}</span>
|
|
128
|
+
</Show>
|
|
129
|
+
{option.label}
|
|
130
|
+
<Show when={option.description}>
|
|
131
|
+
<span class="block text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-normal">{option.description}</span>
|
|
132
|
+
</Show>
|
|
133
|
+
</button>
|
|
134
|
+
)}
|
|
135
|
+
</For>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Confirm ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const ConfirmBody: Component<{
|
|
143
|
+
config: ConfirmPromptConfig
|
|
144
|
+
onConfirm: () => void
|
|
145
|
+
onCancel: () => void
|
|
146
|
+
}> = (props) => {
|
|
147
|
+
const isDanger = () => props.config.variant === 'danger'
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div>
|
|
151
|
+
<Show when={props.config.message}>
|
|
152
|
+
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{props.config.message}</p>
|
|
153
|
+
</Show>
|
|
154
|
+
<div class="flex gap-2 justify-end">
|
|
155
|
+
<button
|
|
156
|
+
onClick={props.onCancel}
|
|
157
|
+
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
158
|
+
>
|
|
159
|
+
{props.config.cancelLabel || 'Cancel'}
|
|
160
|
+
</button>
|
|
161
|
+
<button
|
|
162
|
+
onClick={props.onConfirm}
|
|
163
|
+
class={`px-4 py-2 text-sm font-medium rounded-lg text-white transition-colors ${
|
|
164
|
+
isDanger()
|
|
165
|
+
? 'bg-red-600 hover:bg-red-700'
|
|
166
|
+
: 'bg-blue-600 hover:bg-blue-700'
|
|
167
|
+
}`}
|
|
168
|
+
>
|
|
169
|
+
{props.config.confirmLabel || 'Confirm'}
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Form ────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const FormBody: Component<{
|
|
179
|
+
config: FormPromptConfig
|
|
180
|
+
onSubmit: (data: Record<string, unknown>, label: string) => void
|
|
181
|
+
}> = (props) => {
|
|
182
|
+
const [formData, setFormData] = createSignal<Record<string, string>>({})
|
|
183
|
+
|
|
184
|
+
const updateField = (name: string, value: string) => {
|
|
185
|
+
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const handleSubmit = (e: Event) => {
|
|
189
|
+
e.preventDefault()
|
|
190
|
+
const data = formData()
|
|
191
|
+
// Build a human-readable label from the form values
|
|
192
|
+
const label = Object.entries(data)
|
|
193
|
+
.filter(([, v]) => v)
|
|
194
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
195
|
+
.join(', ')
|
|
196
|
+
props.onSubmit(data, label || 'Form submitted')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const isValid = () => {
|
|
200
|
+
const data = formData()
|
|
201
|
+
return (props.config.fields || [])
|
|
202
|
+
.filter((f) => f.required)
|
|
203
|
+
.every((f) => data[f.name]?.trim())
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<form onSubmit={handleSubmit} class="flex flex-col gap-3">
|
|
208
|
+
<For each={props.config.fields}>
|
|
209
|
+
{(field) => (
|
|
210
|
+
<div>
|
|
211
|
+
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
|
|
212
|
+
{field.label}
|
|
213
|
+
<Show when={field.required}>
|
|
214
|
+
<span class="text-red-500 ml-0.5">*</span>
|
|
215
|
+
</Show>
|
|
216
|
+
</label>
|
|
217
|
+
<Switch>
|
|
218
|
+
<Match when={field.type === 'textarea'}>
|
|
219
|
+
<textarea
|
|
220
|
+
value={formData()[field.name] || ''}
|
|
221
|
+
onInput={(e) => updateField(field.name, e.currentTarget.value)}
|
|
222
|
+
placeholder={field.placeholder}
|
|
223
|
+
rows={3}
|
|
224
|
+
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none transition-colors"
|
|
225
|
+
/>
|
|
226
|
+
</Match>
|
|
227
|
+
<Match when={field.type === 'select'}>
|
|
228
|
+
<select
|
|
229
|
+
value={formData()[field.name] || ''}
|
|
230
|
+
onChange={(e) => updateField(field.name, e.currentTarget.value)}
|
|
231
|
+
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none transition-colors"
|
|
232
|
+
>
|
|
233
|
+
<option value="">{field.placeholder || 'Select...'}</option>
|
|
234
|
+
<For each={field.options}>
|
|
235
|
+
{(opt) => <option value={opt.value}>{opt.label}</option>}
|
|
236
|
+
</For>
|
|
237
|
+
</select>
|
|
238
|
+
</Match>
|
|
239
|
+
<Match when={true}>
|
|
240
|
+
<input
|
|
241
|
+
type={field.type === 'number' ? 'number' : 'text'}
|
|
242
|
+
value={formData()[field.name] || ''}
|
|
243
|
+
onInput={(e) => updateField(field.name, e.currentTarget.value)}
|
|
244
|
+
placeholder={field.placeholder}
|
|
245
|
+
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none transition-colors"
|
|
246
|
+
/>
|
|
247
|
+
</Match>
|
|
248
|
+
</Switch>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</For>
|
|
252
|
+
<div class="flex justify-end">
|
|
253
|
+
<button
|
|
254
|
+
type="submit"
|
|
255
|
+
disabled={!isValid()}
|
|
256
|
+
class="px-4 py-2 text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
|
257
|
+
>
|
|
258
|
+
{props.config.submitLabel || 'Submit'}
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
</form>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useChatBus — SolidJS hook + context provider for the Chat Bus
|
|
3
|
+
* v2.4.0: Event-driven chat toolkit
|
|
4
|
+
*
|
|
5
|
+
* @experimental — This API may change without major bump until v2.5.0.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createContext, useContext, onCleanup, type ParentComponent } from 'solid-js'
|
|
9
|
+
import { createChatBus } from '../services/chat-bus'
|
|
10
|
+
import type { ChatBus } from '../types/chat-bus'
|
|
11
|
+
|
|
12
|
+
// ─── Context ─────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const ChatBusContext = createContext<ChatBus>()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @experimental
|
|
18
|
+
* Provider that creates and shares a ChatBus with all children.
|
|
19
|
+
* Cleans up all listeners on unmount.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <ChatBusProvider>
|
|
23
|
+
* <ChatInterfaceStreaming />
|
|
24
|
+
* <BriefingPanel />
|
|
25
|
+
* <AgentRouter />
|
|
26
|
+
* </ChatBusProvider>
|
|
27
|
+
*/
|
|
28
|
+
export const ChatBusProvider: ParentComponent<{ bus?: ChatBus }> = (props) => {
|
|
29
|
+
const bus = props.bus ?? createChatBus()
|
|
30
|
+
|
|
31
|
+
onCleanup(() => {
|
|
32
|
+
bus.events.clear()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<ChatBusContext.Provider value={bus}>
|
|
37
|
+
{props.children}
|
|
38
|
+
</ChatBusContext.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Hook ────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @experimental
|
|
46
|
+
* Access the ChatBus from any child component.
|
|
47
|
+
* Must be used within a `<ChatBusProvider>`.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* function BriefingPanel() {
|
|
51
|
+
* const bus = useChatBus()
|
|
52
|
+
*
|
|
53
|
+
* bus.events.on('onBriefing', (event) => {
|
|
54
|
+
* addBriefing(event.briefing)
|
|
55
|
+
* })
|
|
56
|
+
*
|
|
57
|
+
* return <div>...</div>
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* function AgentRouter() {
|
|
62
|
+
* const bus = useChatBus()
|
|
63
|
+
*
|
|
64
|
+
* bus.events.on('onStreamEnd', (event) => {
|
|
65
|
+
* if (event.metadata.needs_clarification) {
|
|
66
|
+
* bus.commands.exec('showChatPrompt', {
|
|
67
|
+
* type: 'choice',
|
|
68
|
+
* title: 'Quelle periode ?',
|
|
69
|
+
* config: { options: [...] }
|
|
70
|
+
* })
|
|
71
|
+
* }
|
|
72
|
+
* })
|
|
73
|
+
* }
|
|
74
|
+
*/
|
|
75
|
+
export function useChatBus(): ChatBus {
|
|
76
|
+
const bus = useContext(ChatBusContext)
|
|
77
|
+
if (!bus) {
|
|
78
|
+
throw new Error('useChatBus must be used within a <ChatBusProvider>')
|
|
79
|
+
}
|
|
80
|
+
return bus
|
|
81
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,10 @@ export { EditableUIResourceRenderer } from './components/EditableUIResourceRende
|
|
|
38
38
|
export { ExpandableWrapper } from './components/ExpandableWrapper'
|
|
39
39
|
export { ComponentToolbar } from './components/ComponentToolbar'
|
|
40
40
|
|
|
41
|
+
// Chat Bus (v2.4.0 — @experimental)
|
|
42
|
+
export { ChatBusProvider, useChatBus } from './hooks/useChatBus'
|
|
43
|
+
export { ChatPrompt } from './components/ChatPrompt'
|
|
44
|
+
|
|
41
45
|
// Autocomplete Components
|
|
42
46
|
export { GhostText, GhostTextInput } from './components/GhostText'
|
|
43
47
|
export { AutocompleteDropdown } from './components/AutocompleteDropdown'
|
|
@@ -54,6 +58,7 @@ export type { ResizeHandleProps as ResizeHandleComponentProps } from './componen
|
|
|
54
58
|
export type { EditableUIResourceRendererProps } from './components/EditableUIResourceRenderer'
|
|
55
59
|
export type { ExpandableWrapperProps } from './components/ExpandableWrapper'
|
|
56
60
|
export type { ComponentToolbarProps, ToolbarAction, ToolbarIcon } from './components/ComponentToolbar'
|
|
61
|
+
export type { ChatPromptProps } from './components/ChatPrompt'
|
|
57
62
|
export type { GhostTextProps, GhostTextInputProps } from './components/GhostText'
|
|
58
63
|
export type { AutocompleteDropdownProps } from './components/AutocompleteDropdown'
|
|
59
64
|
export type { AutocompleteFormFieldProps, AutocompleteFormFieldParams } from './components/AutocompleteFormField'
|
|
@@ -205,4 +210,33 @@ export {
|
|
|
205
210
|
DEFAULT_IFRAME_DOMAINS,
|
|
206
211
|
TRUSTED_IFRAME_DOMAINS,
|
|
207
212
|
ComponentRegistry,
|
|
213
|
+
createEventEmitter,
|
|
214
|
+
createCommandHandler,
|
|
215
|
+
createChatBus,
|
|
208
216
|
} from './services'
|
|
217
|
+
|
|
218
|
+
// Chat Bus Types (v2.4.0 — @experimental)
|
|
219
|
+
export type {
|
|
220
|
+
ChatEventBase,
|
|
221
|
+
ChatEvents,
|
|
222
|
+
ChatCommands,
|
|
223
|
+
ChatBus,
|
|
224
|
+
ChatEventEmitter,
|
|
225
|
+
ChatCommandHandler,
|
|
226
|
+
EventSubscribeOptions,
|
|
227
|
+
ChatPromptConfig,
|
|
228
|
+
ChatPromptResponse,
|
|
229
|
+
ChoicePromptConfig,
|
|
230
|
+
ConfirmPromptConfig,
|
|
231
|
+
FormPromptConfig,
|
|
232
|
+
SelectPromptConfig,
|
|
233
|
+
SuggestionItem,
|
|
234
|
+
AgentContext,
|
|
235
|
+
BriefingEvent,
|
|
236
|
+
BriefingSection,
|
|
237
|
+
StreamDoneMetadata,
|
|
238
|
+
ChatError,
|
|
239
|
+
Citation,
|
|
240
|
+
ToolCallEvent,
|
|
241
|
+
ClarificationEvent,
|
|
242
|
+
} from './types/chat-bus'
|