@actuate-media/cms-admin 0.33.0 → 0.35.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.
Files changed (51) hide show
  1. package/dist/__tests__/views/ai-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/ai-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/ai-settings.render.test.js +229 -0
  4. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
  5. package/dist/actuate-admin.css +1 -1
  6. package/dist/views/Settings.d.ts.map +1 -1
  7. package/dist/views/Settings.js +2 -46
  8. package/dist/views/Settings.js.map +1 -1
  9. package/dist/views/settings/AISettingsTab.d.ts +6 -0
  10. package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
  11. package/dist/views/settings/AISettingsTab.js +138 -0
  12. package/dist/views/settings/AISettingsTab.js.map +1 -0
  13. package/dist/views/settings/AiFeaturesCard.d.ts +17 -0
  14. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
  15. package/dist/views/settings/AiFeaturesCard.js +35 -0
  16. package/dist/views/settings/AiFeaturesCard.js.map +1 -0
  17. package/dist/views/settings/CapabilityBadges.d.ts +5 -0
  18. package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
  19. package/dist/views/settings/CapabilityBadges.js +26 -0
  20. package/dist/views/settings/CapabilityBadges.js.map +1 -0
  21. package/dist/views/settings/ModelSelector.d.ts +12 -0
  22. package/dist/views/settings/ModelSelector.d.ts.map +1 -0
  23. package/dist/views/settings/ModelSelector.js +29 -0
  24. package/dist/views/settings/ModelSelector.js.map +1 -0
  25. package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
  26. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
  27. package/dist/views/settings/MonthlyUsageCard.js +30 -0
  28. package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
  29. package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
  30. package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
  31. package/dist/views/settings/ProviderConnectionCard.js +48 -0
  32. package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
  33. package/dist/views/settings/featureModel.d.ts +25 -0
  34. package/dist/views/settings/featureModel.d.ts.map +1 -0
  35. package/dist/views/settings/featureModel.js +45 -0
  36. package/dist/views/settings/featureModel.js.map +1 -0
  37. package/dist/views/settings/useAiSettings.d.ts +149 -0
  38. package/dist/views/settings/useAiSettings.d.ts.map +1 -0
  39. package/dist/views/settings/useAiSettings.js +297 -0
  40. package/dist/views/settings/useAiSettings.js.map +1 -0
  41. package/package.json +3 -3
  42. package/src/__tests__/views/ai-settings.render.test.tsx +286 -0
  43. package/src/views/Settings.tsx +2 -139
  44. package/src/views/settings/AISettingsTab.tsx +418 -0
  45. package/src/views/settings/AiFeaturesCard.tsx +154 -0
  46. package/src/views/settings/CapabilityBadges.tsx +66 -0
  47. package/src/views/settings/ModelSelector.tsx +115 -0
  48. package/src/views/settings/MonthlyUsageCard.tsx +118 -0
  49. package/src/views/settings/ProviderConnectionCard.tsx +245 -0
  50. package/src/views/settings/featureModel.ts +64 -0
  51. package/src/views/settings/useAiSettings.ts +476 -0
@@ -0,0 +1,418 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle, Bot, Info, Loader2, Plus } from 'lucide-react'
4
+ import { useId, useMemo, useState } from 'react'
5
+ import { toast } from 'sonner'
6
+ import { AiFeaturesCard } from './AiFeaturesCard.js'
7
+ import { ModelSelector } from './ModelSelector.js'
8
+ import { MonthlyUsageCard } from './MonthlyUsageCard.js'
9
+ import { ProviderConnectionCard } from './ProviderConnectionCard.js'
10
+ import { SettingsSaveBar } from './components.js'
11
+ import {
12
+ useAiSettings,
13
+ type AiProviderKey,
14
+ type ProviderInput,
15
+ type UseAiSettings,
16
+ } from './useAiSettings.js'
17
+
18
+ const INPUT_CLASS =
19
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
20
+ const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
21
+
22
+ const PROVIDER_OPTIONS: Array<{ value: AiProviderKey; label: string }> = [
23
+ { value: 'anthropic', label: 'Anthropic (Claude)' },
24
+ { value: 'openai', label: 'OpenAI (GPT)' },
25
+ { value: 'xai', label: 'xAI (Grok)' },
26
+ { value: 'custom', label: 'Custom (OpenAI-compatible)' },
27
+ ]
28
+
29
+ export interface AISettingsTabProps {
30
+ /** Current user's role. Only ADMIN can persist changes. */
31
+ role?: string
32
+ }
33
+
34
+ export function AISettingsTab({ role }: AISettingsTabProps) {
35
+ const canEdit = role === 'ADMIN'
36
+ const ai = useAiSettings(canEdit)
37
+
38
+ if (ai.loading) {
39
+ return (
40
+ <div className="space-y-4" aria-busy="true" aria-live="polite">
41
+ <span className="sr-only">Loading AI settings…</span>
42
+ {[0, 1, 2].map((i) => (
43
+ <div key={i} className="border-border bg-card rounded-lg border p-4">
44
+ <div className="bg-muted mb-4 h-4 w-40 animate-pulse rounded" />
45
+ <div className="space-y-3">
46
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
47
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
48
+ </div>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ )
53
+ }
54
+
55
+ return (
56
+ <div className="space-y-4">
57
+ {ai.loadError && (
58
+ <div
59
+ role="alert"
60
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
61
+ >
62
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
63
+ <span className="flex-1">{ai.loadError}</span>
64
+ <button
65
+ type="button"
66
+ onClick={ai.refetch}
67
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
68
+ >
69
+ Retry
70
+ </button>
71
+ </div>
72
+ )}
73
+
74
+ {!canEdit && (
75
+ <div className="border-border bg-muted text-muted-foreground flex items-center gap-2 rounded-lg border p-3 text-sm">
76
+ <Info size={16} className="shrink-0" aria-hidden="true" />
77
+ You have read-only access to AI settings. Only administrators can make changes.
78
+ </div>
79
+ )}
80
+
81
+ <AssistantCard ai={ai} canEdit={canEdit} />
82
+ <ProviderConnectionsSection ai={ai} canEdit={canEdit} />
83
+ <AiFeaturesCard
84
+ features={ai.form.features}
85
+ models={ai.models}
86
+ defaultModelId={ai.form.defaultModelId}
87
+ canEdit={canEdit}
88
+ onToggle={ai.toggleFeature}
89
+ onSetFeatureModel={ai.setFeatureModel}
90
+ />
91
+ <MonthlyUsageCard
92
+ usage={ai.usage}
93
+ monthlyTokenLimit={ai.form.monthlyTokenLimit}
94
+ onChangeLimit={ai.setMonthlyTokenLimit}
95
+ budgetError={ai.budgetError}
96
+ canEdit={canEdit}
97
+ />
98
+
99
+ {ai.saveState === 'error' && ai.saveError && (
100
+ <div
101
+ role="alert"
102
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-3 text-sm"
103
+ >
104
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
105
+ <span className="flex-1">{ai.saveError}</span>
106
+ </div>
107
+ )}
108
+
109
+ <SettingsSaveBar
110
+ dirty={ai.dirty}
111
+ canEdit={canEdit}
112
+ hasErrors={Boolean(ai.budgetError)}
113
+ saveState={ai.saveState}
114
+ onSave={ai.save}
115
+ onReset={ai.reset}
116
+ />
117
+ </div>
118
+ )
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // AI Assistant — default provider + default model
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function AssistantCard({ ai, canEdit }: { ai: UseAiSettings; canEdit: boolean }) {
126
+ const headingId = useId()
127
+ const providerSelectId = useId()
128
+ const [refreshing, setRefreshing] = useState(false)
129
+
130
+ const defaultProvider = ai.providers.find((p) => p.id === ai.form.defaultProviderId)
131
+ const modelsForDefault = useMemo(() => {
132
+ if (!defaultProvider) return ai.models
133
+ return ai.models.filter((m) => m.provider === defaultProvider.provider)
134
+ }, [ai.models, defaultProvider])
135
+
136
+ async function handleRefresh() {
137
+ if (!defaultProvider) return
138
+ setRefreshing(true)
139
+ try {
140
+ const res = await ai.syncModels(defaultProvider.id)
141
+ if (!res.ok) toast.error(res.error ?? 'Failed to refresh models.')
142
+ else if (res.fromFallback)
143
+ toast.warning(res.warning ?? 'Showing the curated model list (live listing unavailable).')
144
+ else toast.success('Model list refreshed.')
145
+ } finally {
146
+ setRefreshing(false)
147
+ }
148
+ }
149
+
150
+ return (
151
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
152
+ <div className="mb-4 flex items-center gap-2">
153
+ <span className="bg-brand/10 text-brand flex h-9 w-9 items-center justify-center rounded-lg">
154
+ <Bot size={18} aria-hidden="true" />
155
+ </span>
156
+ <div>
157
+ <h3 id={headingId} className="text-foreground text-base font-medium">
158
+ AI Assistant
159
+ </h3>
160
+ <p className="text-muted-foreground text-sm">
161
+ The default provider and model used across AI features.
162
+ </p>
163
+ </div>
164
+ </div>
165
+
166
+ {ai.providers.length === 0 ? (
167
+ <div className="border-border bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-sm">
168
+ No AI providers are connected yet. Add one below to enable AI features.
169
+ </div>
170
+ ) : (
171
+ <div className="space-y-4">
172
+ <div>
173
+ <label htmlFor={providerSelectId} className={LABEL_CLASS}>
174
+ Default provider
175
+ </label>
176
+ <select
177
+ id={providerSelectId}
178
+ value={ai.form.defaultProviderId}
179
+ disabled={!canEdit}
180
+ onChange={(e) => ai.setDefaultProvider(e.target.value)}
181
+ className={INPUT_CLASS}
182
+ >
183
+ <option value="">Select a provider…</option>
184
+ {ai.providers.map((p) => (
185
+ <option key={p.id} value={p.id}>
186
+ {p.displayName}
187
+ </option>
188
+ ))}
189
+ </select>
190
+ </div>
191
+
192
+ <ModelSelector
193
+ models={modelsForDefault}
194
+ value={ai.form.defaultModelId}
195
+ onChange={ai.setDefaultModel}
196
+ onRefresh={handleRefresh}
197
+ refreshing={refreshing}
198
+ disabled={!canEdit || !defaultProvider}
199
+ noModelsHint={
200
+ defaultProvider
201
+ ? 'No models cached yet. Use “Refresh models” to load them.'
202
+ : 'Select a default provider to choose a model.'
203
+ }
204
+ />
205
+ </div>
206
+ )}
207
+ </section>
208
+ )
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Provider connections (manage multiple)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ function ProviderConnectionsSection({ ai, canEdit }: { ai: UseAiSettings; canEdit: boolean }) {
216
+ const headingId = useId()
217
+ const [adding, setAdding] = useState(false)
218
+
219
+ return (
220
+ <section aria-labelledby={headingId} className="space-y-3">
221
+ <div className="flex items-center justify-between">
222
+ <h3 id={headingId} className="text-foreground text-base font-medium">
223
+ Provider connections
224
+ </h3>
225
+ {canEdit && !adding && (
226
+ <button
227
+ type="button"
228
+ onClick={() => setAdding(true)}
229
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
230
+ >
231
+ <Plus size={14} aria-hidden="true" />
232
+ Add provider
233
+ </button>
234
+ )}
235
+ </div>
236
+
237
+ {adding && (
238
+ <AddProviderForm
239
+ ai={ai}
240
+ onDone={() => setAdding(false)}
241
+ onCancel={() => setAdding(false)}
242
+ />
243
+ )}
244
+
245
+ {ai.providers.map((p) => (
246
+ <ProviderConnectionCard
247
+ key={p.id}
248
+ provider={p}
249
+ canEdit={canEdit}
250
+ busy={ai.busyProviderId === p.id}
251
+ onTest={async () => {
252
+ const res = await ai.testProvider(p.id)
253
+ if (!res.ok) toast.error(res.error ?? res.message ?? 'Connection test failed.')
254
+ else toast.success(res.message ?? 'Connection successful.')
255
+ }}
256
+ onSyncModels={async () => {
257
+ const res = await ai.syncModels(p.id)
258
+ if (!res.ok) toast.error(res.error ?? 'Failed to refresh models.')
259
+ else if (res.fromFallback)
260
+ toast.warning(res.warning ?? 'Showing the curated model list.')
261
+ else toast.success('Model list refreshed.')
262
+ }}
263
+ onUpdateKey={async (apiKey) => {
264
+ const res = await ai.updateProvider(p.id, { apiKey })
265
+ if (!res.ok) toast.error(res.error ?? 'Failed to update key.')
266
+ else toast.success('API key updated.')
267
+ }}
268
+ onUpdateBaseUrl={async (baseUrl) => {
269
+ const res = await ai.updateProvider(p.id, { baseUrl })
270
+ if (!res.ok) toast.error(res.error ?? 'Failed to update base URL.')
271
+ else toast.success('Base URL updated.')
272
+ }}
273
+ onDisconnect={async () => {
274
+ const res = await ai.deleteProvider(p.id)
275
+ if (!res.ok) toast.error(res.error ?? 'Failed to disconnect.')
276
+ else toast.success(`${p.displayName} disconnected.`)
277
+ }}
278
+ />
279
+ ))}
280
+
281
+ {ai.providers.length === 0 && !adding && (
282
+ <p className="text-muted-foreground text-sm">No providers connected.</p>
283
+ )}
284
+ </section>
285
+ )
286
+ }
287
+
288
+ function AddProviderForm({
289
+ ai,
290
+ onDone,
291
+ onCancel,
292
+ }: {
293
+ ai: UseAiSettings
294
+ onDone: () => void
295
+ onCancel: () => void
296
+ }) {
297
+ const providerId = useId()
298
+ const nameId = useId()
299
+ const keyId = useId()
300
+ const baseUrlId = useId()
301
+ const [provider, setProvider] = useState<AiProviderKey>('anthropic')
302
+ const [displayName, setDisplayName] = useState('')
303
+ const [apiKey, setApiKey] = useState('')
304
+ const [baseUrl, setBaseUrl] = useState('')
305
+ const [submitting, setSubmitting] = useState(false)
306
+
307
+ const needsBaseUrl = provider === 'custom'
308
+ const canSubmit = !submitting && (!needsBaseUrl || baseUrl.trim() !== '')
309
+
310
+ async function submit() {
311
+ setSubmitting(true)
312
+ try {
313
+ const input: ProviderInput = {
314
+ provider,
315
+ displayName: displayName.trim() || undefined,
316
+ apiKey: apiKey.trim() || undefined,
317
+ baseUrl: needsBaseUrl ? baseUrl.trim() : undefined,
318
+ test: true,
319
+ }
320
+ const res = await ai.createProvider(input)
321
+ if (!res.ok) {
322
+ toast.error(res.error ?? 'Failed to add provider.')
323
+ return
324
+ }
325
+ toast.success('Provider connected.')
326
+ onDone()
327
+ } finally {
328
+ setSubmitting(false)
329
+ }
330
+ }
331
+
332
+ return (
333
+ <div className="border-border bg-card rounded-lg border p-4">
334
+ <h4 className="text-foreground mb-3 text-base font-medium">Add a provider</h4>
335
+ <div className="space-y-3">
336
+ <div>
337
+ <label htmlFor={providerId} className={LABEL_CLASS}>
338
+ Provider
339
+ </label>
340
+ <select
341
+ id={providerId}
342
+ value={provider}
343
+ onChange={(e) => setProvider(e.target.value as AiProviderKey)}
344
+ className={INPUT_CLASS}
345
+ >
346
+ {PROVIDER_OPTIONS.map((o) => (
347
+ <option key={o.value} value={o.value}>
348
+ {o.label}
349
+ </option>
350
+ ))}
351
+ </select>
352
+ </div>
353
+ <div>
354
+ <label htmlFor={nameId} className={LABEL_CLASS}>
355
+ Display name (optional)
356
+ </label>
357
+ <input
358
+ id={nameId}
359
+ type="text"
360
+ value={displayName}
361
+ placeholder="e.g. Production Anthropic"
362
+ onChange={(e) => setDisplayName(e.target.value)}
363
+ className={INPUT_CLASS}
364
+ />
365
+ </div>
366
+ {needsBaseUrl && (
367
+ <div>
368
+ <label htmlFor={baseUrlId} className={LABEL_CLASS}>
369
+ Base URL
370
+ </label>
371
+ <input
372
+ id={baseUrlId}
373
+ type="url"
374
+ value={baseUrl}
375
+ placeholder="https://api.example.com/v1"
376
+ onChange={(e) => setBaseUrl(e.target.value)}
377
+ className={INPUT_CLASS}
378
+ />
379
+ </div>
380
+ )}
381
+ <div>
382
+ <label htmlFor={keyId} className={LABEL_CLASS}>
383
+ API key
384
+ </label>
385
+ <input
386
+ id={keyId}
387
+ type="password"
388
+ autoComplete="off"
389
+ spellCheck={false}
390
+ value={apiKey}
391
+ placeholder="Paste API key (or leave blank if set via environment)"
392
+ onChange={(e) => setApiKey(e.target.value)}
393
+ className={INPUT_CLASS}
394
+ />
395
+ </div>
396
+ <div className="flex items-center justify-end gap-3 pt-1">
397
+ <button
398
+ type="button"
399
+ onClick={onCancel}
400
+ disabled={submitting}
401
+ className="text-muted-foreground hover:text-foreground rounded-md px-3 py-2 text-sm transition-colors disabled:opacity-50"
402
+ >
403
+ Cancel
404
+ </button>
405
+ <button
406
+ type="button"
407
+ onClick={submit}
408
+ disabled={!canSubmit}
409
+ className="bg-brand text-brand-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
410
+ >
411
+ {submitting && <Loader2 size={14} className="animate-spin" aria-hidden="true" />}
412
+ Connect &amp; test
413
+ </button>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ )
418
+ }
@@ -0,0 +1,154 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle } from 'lucide-react'
4
+ import { useId } from 'react'
5
+ import { SettingsToggleRow } from './components.js'
6
+ import { capabilityLabel, effectiveFeatureModel, isModelCompatible } from './featureModel.js'
7
+ import type { AiFeatureView, AiModelView } from './useAiSettings.js'
8
+
9
+ const SELECT_CLASS =
10
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-sm text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
11
+
12
+ /**
13
+ * Global AI feature toggles with per-feature model routing. Each switch persists
14
+ * (via the Save bar) to the shared `ai.features.*` flags; the optional model
15
+ * picker overrides which model that feature uses (otherwise it inherits the
16
+ * default). Enforcement across modules lands in a later phase; here we surface
17
+ * and store the operator's intent.
18
+ */
19
+ export function AiFeaturesCard({
20
+ features,
21
+ models,
22
+ defaultModelId,
23
+ canEdit,
24
+ onToggle,
25
+ onSetFeatureModel,
26
+ }: {
27
+ features: AiFeatureView[]
28
+ models: AiModelView[]
29
+ defaultModelId: string
30
+ canEdit: boolean
31
+ onToggle: (key: string, enabled: boolean) => void
32
+ onSetFeatureModel: (key: string, modelId: string) => void
33
+ }) {
34
+ const headingId = useId()
35
+ return (
36
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
37
+ <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
38
+ AI Features
39
+ </h3>
40
+ <p className="text-muted-foreground mb-4 text-sm">
41
+ Turn individual AI-powered features on or off, and optionally route each to a specific
42
+ model.
43
+ </p>
44
+
45
+ {features.length === 0 ? (
46
+ <p className="text-muted-foreground text-sm">No AI features are available.</p>
47
+ ) : (
48
+ <div className="divide-border divide-y">
49
+ {features.map((f) => (
50
+ <FeatureRow
51
+ key={f.key}
52
+ feature={f}
53
+ models={models}
54
+ defaultModelId={defaultModelId}
55
+ canEdit={canEdit}
56
+ onToggle={onToggle}
57
+ onSetFeatureModel={onSetFeatureModel}
58
+ />
59
+ ))}
60
+ </div>
61
+ )}
62
+ </section>
63
+ )
64
+ }
65
+
66
+ function FeatureRow({
67
+ feature,
68
+ models,
69
+ defaultModelId,
70
+ canEdit,
71
+ onToggle,
72
+ onSetFeatureModel,
73
+ }: {
74
+ feature: AiFeatureView
75
+ models: AiModelView[]
76
+ defaultModelId: string
77
+ canEdit: boolean
78
+ onToggle: (key: string, enabled: boolean) => void
79
+ onSetFeatureModel: (key: string, modelId: string) => void
80
+ }) {
81
+ const selectId = useId()
82
+ const required = feature.requiredCapabilities ?? []
83
+ const effective = effectiveFeatureModel(feature.modelId, defaultModelId, required, models)
84
+ const defaultModel = models.find(
85
+ (m) => m.id === defaultModelId || m.providerModelId === defaultModelId,
86
+ )
87
+
88
+ const hasModels = models.length > 0
89
+ const defaultLabel = defaultModel
90
+ ? `Use default (${defaultModel.displayName})`
91
+ : 'Use default model'
92
+
93
+ return (
94
+ <div className="py-4 first:pt-0 last:pb-0">
95
+ <SettingsToggleRow
96
+ label={feature.label}
97
+ description={feature.description}
98
+ checked={feature.enabled}
99
+ disabled={!canEdit}
100
+ onChange={(v) => onToggle(feature.key, v)}
101
+ />
102
+
103
+ {feature.enabled && (
104
+ <div className="mt-3 pl-0">
105
+ <label htmlFor={selectId} className="text-muted-foreground mb-1 block text-sm">
106
+ Model
107
+ </label>
108
+ {hasModels ? (
109
+ <select
110
+ id={selectId}
111
+ value={feature.modelId ?? ''}
112
+ disabled={!canEdit}
113
+ onChange={(e) => onSetFeatureModel(feature.key, e.target.value)}
114
+ className={SELECT_CLASS}
115
+ >
116
+ <option value="">{defaultLabel}</option>
117
+ {models.map((m) => {
118
+ const compatible = isModelCompatible(m, required)
119
+ return (
120
+ <option key={m.id} value={m.id} disabled={!compatible}>
121
+ {m.displayName}
122
+ {m.status === 'unavailable' ? ' (unavailable)' : ''}
123
+ {m.status !== 'unavailable' && !compatible ? ' (incompatible)' : ''}
124
+ </option>
125
+ )
126
+ })}
127
+ </select>
128
+ ) : (
129
+ <p className="text-muted-foreground text-sm">
130
+ Connect a provider and refresh models to route this feature.
131
+ </p>
132
+ )}
133
+
134
+ {hasModels && !effective.isOverride && (
135
+ <p className="text-muted-foreground mt-1 text-sm">
136
+ {effective.model
137
+ ? `Uses the default model (${effective.model.displayName}).`
138
+ : 'No default model selected yet — set one in the AI Assistant card above.'}
139
+ </p>
140
+ )}
141
+
142
+ {hasModels && effective.missingCapabilities.length > 0 && (
143
+ <p className="text-warning mt-1.5 flex items-start gap-1.5 text-sm" role="status">
144
+ <AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
145
+ This model can&apos;t do{' '}
146
+ {effective.missingCapabilities.map(capabilityLabel).join(', ')}, which this feature
147
+ needs. Pick a compatible model.
148
+ </p>
149
+ )}
150
+ </div>
151
+ )}
152
+ </div>
153
+ )
154
+ }
@@ -0,0 +1,66 @@
1
+ 'use client'
2
+
3
+ import {
4
+ Binary,
5
+ Braces,
6
+ Brain,
7
+ Eye,
8
+ Image as ImageIcon,
9
+ type LucideIcon,
10
+ Type,
11
+ Wrench,
12
+ Zap,
13
+ } from 'lucide-react'
14
+
15
+ /**
16
+ * Renders a model's capabilities as labelled badges. Text + icon (never
17
+ * color-only) so the information is accessible to color-blind users and screen
18
+ * readers.
19
+ */
20
+
21
+ interface CapabilityMeta {
22
+ label: string
23
+ icon: LucideIcon
24
+ }
25
+
26
+ // Ordered for stable rendering. Keys match `AIModelCapabilities`.
27
+ const CAPABILITY_META: Record<string, CapabilityMeta> = {
28
+ text: { label: 'Text', icon: Type },
29
+ vision: { label: 'Vision', icon: Eye },
30
+ imageGeneration: { label: 'Image gen', icon: ImageIcon },
31
+ embeddings: { label: 'Embeddings', icon: Binary },
32
+ reasoning: { label: 'Reasoning', icon: Brain },
33
+ toolCalling: { label: 'Tools', icon: Wrench },
34
+ structuredOutput: { label: 'Structured', icon: Braces },
35
+ longContext: { label: 'Long context', icon: Zap },
36
+ }
37
+
38
+ export function CapabilityBadges({
39
+ capabilities,
40
+ className,
41
+ }: {
42
+ capabilities: Record<string, boolean>
43
+ className?: string
44
+ }) {
45
+ const active = Object.keys(CAPABILITY_META).filter((k) => capabilities?.[k])
46
+ if (active.length === 0) {
47
+ return <span className="text-muted-foreground text-sm">No capability data</span>
48
+ }
49
+ return (
50
+ <ul className={`flex flex-wrap gap-1.5 ${className ?? ''}`} aria-label="Model capabilities">
51
+ {active.map((key) => {
52
+ const meta = CAPABILITY_META[key]!
53
+ const Icon = meta.icon
54
+ return (
55
+ <li
56
+ key={key}
57
+ className="border-border bg-muted text-muted-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs"
58
+ >
59
+ <Icon size={11} aria-hidden="true" />
60
+ {meta.label}
61
+ </li>
62
+ )
63
+ })}
64
+ </ul>
65
+ )
66
+ }