@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,476 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { cmsApi } from '../../lib/api.js'
5
+
6
+ /**
7
+ * Settings → AI state.
8
+ *
9
+ * Two persistence surfaces, mirroring the SEO tab's multi-backend pattern:
10
+ * - Provider connections (create / update / test / disconnect / sync models)
11
+ * act IMMEDIATELY against `/ai/providers/*`. These touch encrypted secrets,
12
+ * so they shouldn't sit in a dirty buffer waiting for a Save click.
13
+ * - Defaults, feature toggles, and the monthly budget are buffered and saved
14
+ * together via `PUT /ai/settings` through the shared SettingsSaveBar.
15
+ *
16
+ * Provider API keys are never returned by the server; we only ever receive a
17
+ * masked `apiKeyLast4` + a source flag.
18
+ */
19
+
20
+ export type AiProviderKey = 'openai' | 'anthropic' | 'xai' | 'custom'
21
+ export type AiConnectionStatus =
22
+ | 'connected'
23
+ | 'invalid_key'
24
+ | 'rate_limited'
25
+ | 'provider_down'
26
+ | 'needs_setup'
27
+ | 'unknown'
28
+ export type AiModelStatus = 'available' | 'unavailable' | 'deprecated' | 'unknown'
29
+
30
+ export interface AiModelView {
31
+ id: string
32
+ provider: AiProviderKey
33
+ providerModelId: string
34
+ displayName: string
35
+ description?: string
36
+ status: AiModelStatus
37
+ capabilities: Record<string, boolean>
38
+ contextWindow?: number
39
+ maxOutputTokens?: number
40
+ inputModalities: string[]
41
+ outputModalities: string[]
42
+ pricing?: {
43
+ inputPerMillion?: number
44
+ outputPerMillion?: number
45
+ image?: number
46
+ search?: number
47
+ }
48
+ lastSyncedAt: string
49
+ source: string
50
+ }
51
+
52
+ export interface AiProviderView {
53
+ id: string
54
+ provider: AiProviderKey
55
+ displayName: string
56
+ enabled: boolean
57
+ status: AiConnectionStatus
58
+ apiKeyLast4?: string
59
+ baseUrl?: string
60
+ organizationId?: string
61
+ projectId?: string
62
+ lastTestedAt?: string
63
+ lastModelSyncAt?: string
64
+ apiKeySource: 'environment' | 'database' | 'config'
65
+ keyManagedExternally: boolean
66
+ hasKey: boolean
67
+ createdAt: string
68
+ updatedAt: string
69
+ updatedBy: string
70
+ }
71
+
72
+ export interface AiFeatureView {
73
+ key: string
74
+ label: string
75
+ description: string
76
+ enabled: boolean
77
+ category: string
78
+ requiredCapabilities: string[]
79
+ /** Per-feature model override (`provider:modelId`). Empty = use the default. */
80
+ modelId?: string
81
+ /** Per-feature provider connection override. Usually derived from the model. */
82
+ providerId?: string
83
+ }
84
+
85
+ export interface AiUsageView {
86
+ totalTokens: number
87
+ promptTokens: number
88
+ completionTokens: number
89
+ estimatedCost?: number
90
+ monthlyTokenLimit?: number
91
+ periodStart: string
92
+ periodEnd: string
93
+ runCount: number
94
+ }
95
+
96
+ /** The buffered (Save-bar) portion of AI settings. */
97
+ export interface AiSettingsForm {
98
+ defaultProviderId: string
99
+ defaultModelId: string
100
+ features: AiFeatureView[]
101
+ monthlyTokenLimit: string
102
+ }
103
+
104
+ interface AiSettingsResponse {
105
+ settings: {
106
+ defaultProviderId?: string
107
+ defaultModelId?: string
108
+ features: AiFeatureView[]
109
+ budget: { monthlyTokenLimit?: number }
110
+ }
111
+ providers: AiProviderView[]
112
+ defaultModel: AiModelView | null
113
+ }
114
+
115
+ type SaveState = 'idle' | 'saving' | 'success' | 'error'
116
+
117
+ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
118
+ return {
119
+ defaultProviderId: res.settings.defaultProviderId ?? '',
120
+ defaultModelId: res.settings.defaultModelId ?? '',
121
+ features: res.settings.features.map((f) => ({
122
+ ...f,
123
+ modelId: f.modelId ?? '',
124
+ providerId: f.providerId ?? '',
125
+ })),
126
+ monthlyTokenLimit:
127
+ typeof res.settings.budget.monthlyTokenLimit === 'number'
128
+ ? String(res.settings.budget.monthlyTokenLimit)
129
+ : '',
130
+ }
131
+ }
132
+
133
+ const EMPTY_FORM: AiSettingsForm = {
134
+ defaultProviderId: '',
135
+ defaultModelId: '',
136
+ features: [],
137
+ monthlyTokenLimit: '',
138
+ }
139
+
140
+ export interface ProviderInput {
141
+ provider: AiProviderKey
142
+ displayName?: string
143
+ apiKey?: string
144
+ baseUrl?: string
145
+ organizationId?: string
146
+ projectId?: string
147
+ enabled?: boolean
148
+ test?: boolean
149
+ }
150
+
151
+ export interface UseAiSettings {
152
+ loading: boolean
153
+ loadError: string | null
154
+ providers: AiProviderView[]
155
+ models: AiModelView[]
156
+ usage: AiUsageView | null
157
+ defaultModel: AiModelView | null
158
+ form: AiSettingsForm
159
+ setDefaultProvider: (id: string) => void
160
+ setDefaultModel: (id: string) => void
161
+ setMonthlyTokenLimit: (value: string) => void
162
+ toggleFeature: (key: string, enabled: boolean) => void
163
+ /** Set a per-feature model override. Empty string reverts to the default model. */
164
+ setFeatureModel: (key: string, modelId: string) => void
165
+ dirty: boolean
166
+ budgetError: string | null
167
+ saveState: SaveState
168
+ saveError: string | null
169
+ save: () => Promise<void>
170
+ reset: () => void
171
+ refetch: () => void
172
+ /** Provider connection actions (persist immediately). */
173
+ createProvider: (input: ProviderInput) => Promise<{ ok: boolean; error?: string }>
174
+ updateProvider: (
175
+ id: string,
176
+ patch: Omit<Partial<ProviderInput>, 'apiKey'> & { apiKey?: string | null },
177
+ ) => Promise<{ ok: boolean; error?: string }>
178
+ deleteProvider: (id: string) => Promise<{ ok: boolean; error?: string }>
179
+ testProvider: (
180
+ id: string,
181
+ ) => Promise<{ ok: boolean; status?: AiConnectionStatus; message?: string; error?: string }>
182
+ syncModels: (
183
+ id: string,
184
+ ) => Promise<{ ok: boolean; fromFallback?: boolean; warning?: string | null; error?: string }>
185
+ busyProviderId: string | null
186
+ }
187
+
188
+ export function useAiSettings(canEdit: boolean): UseAiSettings {
189
+ const [loading, setLoading] = useState(true)
190
+ const [loadError, setLoadError] = useState<string | null>(null)
191
+ const [providers, setProviders] = useState<AiProviderView[]>([])
192
+ const [models, setModels] = useState<AiModelView[]>([])
193
+ const [usage, setUsage] = useState<AiUsageView | null>(null)
194
+ const [defaultModel, setDefaultModel] = useState<AiModelView | null>(null)
195
+ const [baseline, setBaseline] = useState<AiSettingsForm>(EMPTY_FORM)
196
+ const [form, setForm] = useState<AiSettingsForm>(EMPTY_FORM)
197
+ const [saveState, setSaveState] = useState<SaveState>('idle')
198
+ const [saveError, setSaveError] = useState<string | null>(null)
199
+ const [busyProviderId, setBusyProviderId] = useState<string | null>(null)
200
+ const [reloadKey, setReloadKey] = useState(0)
201
+
202
+ const loadModels = useCallback(async () => {
203
+ const res = await cmsApi<{ models: AiModelView[] }>('/ai/models')
204
+ if (!res.error && res.data) setModels(res.data.models ?? [])
205
+ }, [])
206
+
207
+ useEffect(() => {
208
+ let cancelled = false
209
+ setLoading(true)
210
+ setLoadError(null)
211
+ Promise.all([
212
+ cmsApi<AiSettingsResponse>('/ai/settings'),
213
+ cmsApi<{ models: AiModelView[] }>('/ai/models'),
214
+ cmsApi<AiUsageView>('/ai/usage/summary'),
215
+ ])
216
+ .then(([settingsRes, modelsRes, usageRes]) => {
217
+ if (cancelled) return
218
+ if (settingsRes.error) {
219
+ setLoadError(settingsRes.error)
220
+ return
221
+ }
222
+ if (settingsRes.data) {
223
+ const loaded = formFromResponse(settingsRes.data)
224
+ setBaseline(loaded)
225
+ setForm(loaded)
226
+ setProviders(settingsRes.data.providers ?? [])
227
+ setDefaultModel(settingsRes.data.defaultModel ?? null)
228
+ }
229
+ if (!modelsRes.error && modelsRes.data) setModels(modelsRes.data.models ?? [])
230
+ if (!usageRes.error && usageRes.data) setUsage(usageRes.data)
231
+ })
232
+ .catch(() => {
233
+ if (!cancelled) setLoadError('Failed to load AI settings.')
234
+ })
235
+ .finally(() => {
236
+ if (!cancelled) setLoading(false)
237
+ })
238
+ return () => {
239
+ cancelled = true
240
+ }
241
+ }, [reloadKey])
242
+
243
+ const setDefaultProvider = useCallback((id: string) => {
244
+ setForm((prev) => ({ ...prev, defaultProviderId: id, defaultModelId: '' }))
245
+ setSaveState('idle')
246
+ }, [])
247
+
248
+ const setDefaultModelId = useCallback((id: string) => {
249
+ setForm((prev) => ({ ...prev, defaultModelId: id }))
250
+ setSaveState('idle')
251
+ }, [])
252
+
253
+ const setMonthlyTokenLimit = useCallback((value: string) => {
254
+ setForm((prev) => ({ ...prev, monthlyTokenLimit: value.replace(/[^\d]/g, '') }))
255
+ setSaveState('idle')
256
+ }, [])
257
+
258
+ const toggleFeature = useCallback((key: string, enabled: boolean) => {
259
+ setForm((prev) => ({
260
+ ...prev,
261
+ features: prev.features.map((f) => (f.key === key ? { ...f, enabled } : f)),
262
+ }))
263
+ setSaveState('idle')
264
+ }, [])
265
+
266
+ const setFeatureModel = useCallback((key: string, modelId: string) => {
267
+ setForm((prev) => ({
268
+ ...prev,
269
+ features: prev.features.map((f) =>
270
+ f.key === key ? { ...f, modelId, providerId: modelId ? f.providerId : '' } : f,
271
+ ),
272
+ }))
273
+ setSaveState('idle')
274
+ }, [])
275
+
276
+ const budgetError = useMemo(() => {
277
+ if (form.monthlyTokenLimit === '') return null
278
+ const n = Number(form.monthlyTokenLimit)
279
+ if (!Number.isFinite(n) || n < 0) return 'Monthly token limit must be a non-negative number.'
280
+ return null
281
+ }, [form.monthlyTokenLimit])
282
+
283
+ const dirty = useMemo(() => {
284
+ if (form.defaultProviderId !== baseline.defaultProviderId) return true
285
+ if (form.defaultModelId !== baseline.defaultModelId) return true
286
+ if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit) return true
287
+ if (form.features.length !== baseline.features.length) return true
288
+ return form.features.some((f, i) => {
289
+ const base = baseline.features[i]
290
+ return f.enabled !== base?.enabled || (f.modelId ?? '') !== (base?.modelId ?? '')
291
+ })
292
+ }, [form, baseline])
293
+
294
+ const save = useCallback(async () => {
295
+ if (!canEdit || budgetError) return
296
+ setSaveState('saving')
297
+ setSaveError(null)
298
+ try {
299
+ const res = await cmsApi<AiSettingsResponse>('/ai/settings', {
300
+ method: 'PUT',
301
+ body: JSON.stringify({
302
+ defaultProviderId: form.defaultProviderId || null,
303
+ defaultModelId: form.defaultModelId || null,
304
+ features: form.features.map((f) => ({
305
+ key: f.key,
306
+ enabled: f.enabled,
307
+ modelId: f.modelId ? f.modelId : null,
308
+ })),
309
+ budget: {
310
+ monthlyTokenLimit:
311
+ form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
312
+ },
313
+ }),
314
+ })
315
+ if (res.error) throw new Error(res.error)
316
+ setBaseline(form)
317
+ setSaveState('success')
318
+ // Refresh usage so the budget meter reflects the new limit immediately.
319
+ const usageRes = await cmsApi<AiUsageView>('/ai/usage/summary')
320
+ if (!usageRes.error && usageRes.data) setUsage(usageRes.data)
321
+ } catch (err) {
322
+ setSaveState('error')
323
+ setSaveError(err instanceof Error ? err.message : 'Failed to save AI settings.')
324
+ }
325
+ }, [canEdit, budgetError, form])
326
+
327
+ const reset = useCallback(() => {
328
+ setForm(baseline)
329
+ setSaveState('idle')
330
+ setSaveError(null)
331
+ }, [baseline])
332
+
333
+ const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
334
+
335
+ const reloadProviders = useCallback(async () => {
336
+ const res = await cmsApi<AiSettingsResponse>('/ai/settings')
337
+ if (!res.error && res.data) {
338
+ setProviders(res.data.providers ?? [])
339
+ setDefaultModel(res.data.defaultModel ?? null)
340
+ }
341
+ }, [])
342
+
343
+ const createProvider = useCallback(
344
+ async (input: ProviderInput) => {
345
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
346
+ setBusyProviderId('new')
347
+ try {
348
+ const res = await cmsApi<AiProviderView>('/ai/providers', {
349
+ method: 'POST',
350
+ body: JSON.stringify(input),
351
+ })
352
+ if (res.error) return { ok: false, error: res.error }
353
+ await reloadProviders()
354
+ return { ok: true }
355
+ } finally {
356
+ setBusyProviderId(null)
357
+ }
358
+ },
359
+ [canEdit, reloadProviders],
360
+ )
361
+
362
+ const updateProvider = useCallback(
363
+ async (
364
+ id: string,
365
+ patch: Omit<Partial<ProviderInput>, 'apiKey'> & { apiKey?: string | null },
366
+ ) => {
367
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
368
+ setBusyProviderId(id)
369
+ try {
370
+ const res = await cmsApi<AiProviderView>(`/ai/providers/${id}`, {
371
+ method: 'PUT',
372
+ body: JSON.stringify(patch),
373
+ })
374
+ if (res.error) return { ok: false, error: res.error }
375
+ await reloadProviders()
376
+ return { ok: true }
377
+ } finally {
378
+ setBusyProviderId(null)
379
+ }
380
+ },
381
+ [canEdit, reloadProviders],
382
+ )
383
+
384
+ const deleteProvider = useCallback(
385
+ async (id: string) => {
386
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
387
+ setBusyProviderId(id)
388
+ try {
389
+ const res = await cmsApi(`/ai/providers/${id}`, { method: 'DELETE' })
390
+ if (res.error) return { ok: false, error: res.error }
391
+ await reloadProviders()
392
+ await loadModels()
393
+ return { ok: true }
394
+ } finally {
395
+ setBusyProviderId(null)
396
+ }
397
+ },
398
+ [canEdit, reloadProviders, loadModels],
399
+ )
400
+
401
+ const testProvider = useCallback(
402
+ async (id: string) => {
403
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
404
+ setBusyProviderId(id)
405
+ try {
406
+ const res = await cmsApi<{
407
+ provider: AiProviderView
408
+ result: { ok: boolean; status: AiConnectionStatus; message: string }
409
+ }>(`/ai/providers/${id}/test`, { method: 'POST' })
410
+ if (res.error) return { ok: false, error: res.error }
411
+ await reloadProviders()
412
+ return {
413
+ ok: res.data?.result.ok ?? false,
414
+ status: res.data?.result.status,
415
+ message: res.data?.result.message,
416
+ }
417
+ } finally {
418
+ setBusyProviderId(null)
419
+ }
420
+ },
421
+ [canEdit, reloadProviders],
422
+ )
423
+
424
+ const syncModels = useCallback(
425
+ async (id: string) => {
426
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
427
+ setBusyProviderId(id)
428
+ try {
429
+ const res = await cmsApi<{
430
+ models: AiModelView[]
431
+ fromFallback: boolean
432
+ warning: string | null
433
+ }>(`/ai/providers/${id}/sync-models`, { method: 'POST' })
434
+ if (res.error) return { ok: false, error: res.error }
435
+ await loadModels()
436
+ await reloadProviders()
437
+ return {
438
+ ok: true,
439
+ fromFallback: res.data?.fromFallback,
440
+ warning: res.data?.warning ?? null,
441
+ }
442
+ } finally {
443
+ setBusyProviderId(null)
444
+ }
445
+ },
446
+ [canEdit, loadModels, reloadProviders],
447
+ )
448
+
449
+ return {
450
+ loading,
451
+ loadError,
452
+ providers,
453
+ models,
454
+ usage,
455
+ defaultModel,
456
+ form,
457
+ setDefaultProvider,
458
+ setDefaultModel: setDefaultModelId,
459
+ setMonthlyTokenLimit,
460
+ toggleFeature,
461
+ setFeatureModel,
462
+ dirty,
463
+ budgetError,
464
+ saveState,
465
+ saveError,
466
+ save,
467
+ reset,
468
+ refetch,
469
+ createProvider,
470
+ updateProvider,
471
+ deleteProvider,
472
+ testProvider,
473
+ syncModels,
474
+ busyProviderId,
475
+ }
476
+ }