@actuate-media/cms-admin 0.32.0 → 0.34.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 (69) 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 +182 -0
  4. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/seo-settings.render.test.js +152 -0
  8. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
  9. package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
  10. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/MediaPickerModal.d.ts +15 -1
  13. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  14. package/dist/components/MediaPickerModal.js +17 -1
  15. package/dist/components/MediaPickerModal.js.map +1 -1
  16. package/dist/views/Settings.d.ts.map +1 -1
  17. package/dist/views/Settings.js +3 -47
  18. package/dist/views/Settings.js.map +1 -1
  19. package/dist/views/settings/AISettingsTab.d.ts +6 -0
  20. package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
  21. package/dist/views/settings/AISettingsTab.js +138 -0
  22. package/dist/views/settings/AISettingsTab.js.map +1 -0
  23. package/dist/views/settings/AiFeaturesCard.d.ts +12 -0
  24. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
  25. package/dist/views/settings/AiFeaturesCard.js +14 -0
  26. package/dist/views/settings/AiFeaturesCard.js.map +1 -0
  27. package/dist/views/settings/CapabilityBadges.d.ts +5 -0
  28. package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
  29. package/dist/views/settings/CapabilityBadges.js +26 -0
  30. package/dist/views/settings/CapabilityBadges.js.map +1 -0
  31. package/dist/views/settings/ModelSelector.d.ts +12 -0
  32. package/dist/views/settings/ModelSelector.d.ts.map +1 -0
  33. package/dist/views/settings/ModelSelector.js +29 -0
  34. package/dist/views/settings/ModelSelector.js.map +1 -0
  35. package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
  36. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
  37. package/dist/views/settings/MonthlyUsageCard.js +30 -0
  38. package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
  39. package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
  40. package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
  41. package/dist/views/settings/ProviderConnectionCard.js +48 -0
  42. package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
  43. package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
  44. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
  45. package/dist/views/settings/SeoSettingsTab.js +126 -0
  46. package/dist/views/settings/SeoSettingsTab.js.map +1 -0
  47. package/dist/views/settings/useAiSettings.d.ts +143 -0
  48. package/dist/views/settings/useAiSettings.d.ts.map +1 -0
  49. package/dist/views/settings/useAiSettings.js +278 -0
  50. package/dist/views/settings/useAiSettings.js.map +1 -0
  51. package/dist/views/settings/useSeoSettings.d.ts +79 -0
  52. package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
  53. package/dist/views/settings/useSeoSettings.js +278 -0
  54. package/dist/views/settings/useSeoSettings.js.map +1 -0
  55. package/package.json +2 -2
  56. package/src/__tests__/views/ai-settings.render.test.tsx +225 -0
  57. package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
  58. package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
  59. package/src/components/MediaPickerModal.tsx +39 -2
  60. package/src/views/Settings.tsx +4 -141
  61. package/src/views/settings/AISettingsTab.tsx +411 -0
  62. package/src/views/settings/AiFeaturesCard.tsx +49 -0
  63. package/src/views/settings/CapabilityBadges.tsx +66 -0
  64. package/src/views/settings/ModelSelector.tsx +115 -0
  65. package/src/views/settings/MonthlyUsageCard.tsx +118 -0
  66. package/src/views/settings/ProviderConnectionCard.tsx +245 -0
  67. package/src/views/settings/SeoSettingsTab.tsx +658 -0
  68. package/src/views/settings/useAiSettings.ts +448 -0
  69. package/src/views/settings/useSeoSettings.ts +374 -0
@@ -0,0 +1,448 @@
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
+ }
80
+
81
+ export interface AiUsageView {
82
+ totalTokens: number
83
+ promptTokens: number
84
+ completionTokens: number
85
+ estimatedCost?: number
86
+ monthlyTokenLimit?: number
87
+ periodStart: string
88
+ periodEnd: string
89
+ runCount: number
90
+ }
91
+
92
+ /** The buffered (Save-bar) portion of AI settings. */
93
+ export interface AiSettingsForm {
94
+ defaultProviderId: string
95
+ defaultModelId: string
96
+ features: AiFeatureView[]
97
+ monthlyTokenLimit: string
98
+ }
99
+
100
+ interface AiSettingsResponse {
101
+ settings: {
102
+ defaultProviderId?: string
103
+ defaultModelId?: string
104
+ features: AiFeatureView[]
105
+ budget: { monthlyTokenLimit?: number }
106
+ }
107
+ providers: AiProviderView[]
108
+ defaultModel: AiModelView | null
109
+ }
110
+
111
+ type SaveState = 'idle' | 'saving' | 'success' | 'error'
112
+
113
+ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
114
+ return {
115
+ defaultProviderId: res.settings.defaultProviderId ?? '',
116
+ defaultModelId: res.settings.defaultModelId ?? '',
117
+ features: res.settings.features.map((f) => ({ ...f })),
118
+ monthlyTokenLimit:
119
+ typeof res.settings.budget.monthlyTokenLimit === 'number'
120
+ ? String(res.settings.budget.monthlyTokenLimit)
121
+ : '',
122
+ }
123
+ }
124
+
125
+ const EMPTY_FORM: AiSettingsForm = {
126
+ defaultProviderId: '',
127
+ defaultModelId: '',
128
+ features: [],
129
+ monthlyTokenLimit: '',
130
+ }
131
+
132
+ export interface ProviderInput {
133
+ provider: AiProviderKey
134
+ displayName?: string
135
+ apiKey?: string
136
+ baseUrl?: string
137
+ organizationId?: string
138
+ projectId?: string
139
+ enabled?: boolean
140
+ test?: boolean
141
+ }
142
+
143
+ export interface UseAiSettings {
144
+ loading: boolean
145
+ loadError: string | null
146
+ providers: AiProviderView[]
147
+ models: AiModelView[]
148
+ usage: AiUsageView | null
149
+ defaultModel: AiModelView | null
150
+ form: AiSettingsForm
151
+ setDefaultProvider: (id: string) => void
152
+ setDefaultModel: (id: string) => void
153
+ setMonthlyTokenLimit: (value: string) => void
154
+ toggleFeature: (key: string, enabled: boolean) => void
155
+ dirty: boolean
156
+ budgetError: string | null
157
+ saveState: SaveState
158
+ saveError: string | null
159
+ save: () => Promise<void>
160
+ reset: () => void
161
+ refetch: () => void
162
+ /** Provider connection actions (persist immediately). */
163
+ createProvider: (input: ProviderInput) => Promise<{ ok: boolean; error?: string }>
164
+ updateProvider: (
165
+ id: string,
166
+ patch: Omit<Partial<ProviderInput>, 'apiKey'> & { apiKey?: string | null },
167
+ ) => Promise<{ ok: boolean; error?: string }>
168
+ deleteProvider: (id: string) => Promise<{ ok: boolean; error?: string }>
169
+ testProvider: (
170
+ id: string,
171
+ ) => Promise<{ ok: boolean; status?: AiConnectionStatus; message?: string; error?: string }>
172
+ syncModels: (
173
+ id: string,
174
+ ) => Promise<{ ok: boolean; fromFallback?: boolean; warning?: string | null; error?: string }>
175
+ busyProviderId: string | null
176
+ }
177
+
178
+ export function useAiSettings(canEdit: boolean): UseAiSettings {
179
+ const [loading, setLoading] = useState(true)
180
+ const [loadError, setLoadError] = useState<string | null>(null)
181
+ const [providers, setProviders] = useState<AiProviderView[]>([])
182
+ const [models, setModels] = useState<AiModelView[]>([])
183
+ const [usage, setUsage] = useState<AiUsageView | null>(null)
184
+ const [defaultModel, setDefaultModel] = useState<AiModelView | null>(null)
185
+ const [baseline, setBaseline] = useState<AiSettingsForm>(EMPTY_FORM)
186
+ const [form, setForm] = useState<AiSettingsForm>(EMPTY_FORM)
187
+ const [saveState, setSaveState] = useState<SaveState>('idle')
188
+ const [saveError, setSaveError] = useState<string | null>(null)
189
+ const [busyProviderId, setBusyProviderId] = useState<string | null>(null)
190
+ const [reloadKey, setReloadKey] = useState(0)
191
+
192
+ const loadModels = useCallback(async () => {
193
+ const res = await cmsApi<{ models: AiModelView[] }>('/ai/models')
194
+ if (!res.error && res.data) setModels(res.data.models ?? [])
195
+ }, [])
196
+
197
+ useEffect(() => {
198
+ let cancelled = false
199
+ setLoading(true)
200
+ setLoadError(null)
201
+ Promise.all([
202
+ cmsApi<AiSettingsResponse>('/ai/settings'),
203
+ cmsApi<{ models: AiModelView[] }>('/ai/models'),
204
+ cmsApi<AiUsageView>('/ai/usage/summary'),
205
+ ])
206
+ .then(([settingsRes, modelsRes, usageRes]) => {
207
+ if (cancelled) return
208
+ if (settingsRes.error) {
209
+ setLoadError(settingsRes.error)
210
+ return
211
+ }
212
+ if (settingsRes.data) {
213
+ const loaded = formFromResponse(settingsRes.data)
214
+ setBaseline(loaded)
215
+ setForm(loaded)
216
+ setProviders(settingsRes.data.providers ?? [])
217
+ setDefaultModel(settingsRes.data.defaultModel ?? null)
218
+ }
219
+ if (!modelsRes.error && modelsRes.data) setModels(modelsRes.data.models ?? [])
220
+ if (!usageRes.error && usageRes.data) setUsage(usageRes.data)
221
+ })
222
+ .catch(() => {
223
+ if (!cancelled) setLoadError('Failed to load AI settings.')
224
+ })
225
+ .finally(() => {
226
+ if (!cancelled) setLoading(false)
227
+ })
228
+ return () => {
229
+ cancelled = true
230
+ }
231
+ }, [reloadKey])
232
+
233
+ const setDefaultProvider = useCallback((id: string) => {
234
+ setForm((prev) => ({ ...prev, defaultProviderId: id, defaultModelId: '' }))
235
+ setSaveState('idle')
236
+ }, [])
237
+
238
+ const setDefaultModelId = useCallback((id: string) => {
239
+ setForm((prev) => ({ ...prev, defaultModelId: id }))
240
+ setSaveState('idle')
241
+ }, [])
242
+
243
+ const setMonthlyTokenLimit = useCallback((value: string) => {
244
+ setForm((prev) => ({ ...prev, monthlyTokenLimit: value.replace(/[^\d]/g, '') }))
245
+ setSaveState('idle')
246
+ }, [])
247
+
248
+ const toggleFeature = useCallback((key: string, enabled: boolean) => {
249
+ setForm((prev) => ({
250
+ ...prev,
251
+ features: prev.features.map((f) => (f.key === key ? { ...f, enabled } : f)),
252
+ }))
253
+ setSaveState('idle')
254
+ }, [])
255
+
256
+ const budgetError = useMemo(() => {
257
+ if (form.monthlyTokenLimit === '') return null
258
+ const n = Number(form.monthlyTokenLimit)
259
+ if (!Number.isFinite(n) || n < 0) return 'Monthly token limit must be a non-negative number.'
260
+ return null
261
+ }, [form.monthlyTokenLimit])
262
+
263
+ const dirty = useMemo(() => {
264
+ if (form.defaultProviderId !== baseline.defaultProviderId) return true
265
+ if (form.defaultModelId !== baseline.defaultModelId) return true
266
+ if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit) return true
267
+ if (form.features.length !== baseline.features.length) return true
268
+ return form.features.some((f, i) => f.enabled !== baseline.features[i]?.enabled)
269
+ }, [form, baseline])
270
+
271
+ const save = useCallback(async () => {
272
+ if (!canEdit || budgetError) return
273
+ setSaveState('saving')
274
+ setSaveError(null)
275
+ try {
276
+ const res = await cmsApi<AiSettingsResponse>('/ai/settings', {
277
+ method: 'PUT',
278
+ body: JSON.stringify({
279
+ defaultProviderId: form.defaultProviderId || null,
280
+ defaultModelId: form.defaultModelId || null,
281
+ features: form.features.map((f) => ({ key: f.key, enabled: f.enabled })),
282
+ budget: {
283
+ monthlyTokenLimit:
284
+ form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
285
+ },
286
+ }),
287
+ })
288
+ if (res.error) throw new Error(res.error)
289
+ setBaseline(form)
290
+ setSaveState('success')
291
+ // Refresh usage so the budget meter reflects the new limit immediately.
292
+ const usageRes = await cmsApi<AiUsageView>('/ai/usage/summary')
293
+ if (!usageRes.error && usageRes.data) setUsage(usageRes.data)
294
+ } catch (err) {
295
+ setSaveState('error')
296
+ setSaveError(err instanceof Error ? err.message : 'Failed to save AI settings.')
297
+ }
298
+ }, [canEdit, budgetError, form])
299
+
300
+ const reset = useCallback(() => {
301
+ setForm(baseline)
302
+ setSaveState('idle')
303
+ setSaveError(null)
304
+ }, [baseline])
305
+
306
+ const refetch = useCallback(() => setReloadKey((k) => k + 1), [])
307
+
308
+ const reloadProviders = useCallback(async () => {
309
+ const res = await cmsApi<AiSettingsResponse>('/ai/settings')
310
+ if (!res.error && res.data) {
311
+ setProviders(res.data.providers ?? [])
312
+ setDefaultModel(res.data.defaultModel ?? null)
313
+ }
314
+ }, [])
315
+
316
+ const createProvider = useCallback(
317
+ async (input: ProviderInput) => {
318
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
319
+ setBusyProviderId('new')
320
+ try {
321
+ const res = await cmsApi<AiProviderView>('/ai/providers', {
322
+ method: 'POST',
323
+ body: JSON.stringify(input),
324
+ })
325
+ if (res.error) return { ok: false, error: res.error }
326
+ await reloadProviders()
327
+ return { ok: true }
328
+ } finally {
329
+ setBusyProviderId(null)
330
+ }
331
+ },
332
+ [canEdit, reloadProviders],
333
+ )
334
+
335
+ const updateProvider = useCallback(
336
+ async (
337
+ id: string,
338
+ patch: Omit<Partial<ProviderInput>, 'apiKey'> & { apiKey?: string | null },
339
+ ) => {
340
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
341
+ setBusyProviderId(id)
342
+ try {
343
+ const res = await cmsApi<AiProviderView>(`/ai/providers/${id}`, {
344
+ method: 'PUT',
345
+ body: JSON.stringify(patch),
346
+ })
347
+ if (res.error) return { ok: false, error: res.error }
348
+ await reloadProviders()
349
+ return { ok: true }
350
+ } finally {
351
+ setBusyProviderId(null)
352
+ }
353
+ },
354
+ [canEdit, reloadProviders],
355
+ )
356
+
357
+ const deleteProvider = useCallback(
358
+ async (id: string) => {
359
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
360
+ setBusyProviderId(id)
361
+ try {
362
+ const res = await cmsApi(`/ai/providers/${id}`, { method: 'DELETE' })
363
+ if (res.error) return { ok: false, error: res.error }
364
+ await reloadProviders()
365
+ await loadModels()
366
+ return { ok: true }
367
+ } finally {
368
+ setBusyProviderId(null)
369
+ }
370
+ },
371
+ [canEdit, reloadProviders, loadModels],
372
+ )
373
+
374
+ const testProvider = useCallback(
375
+ async (id: string) => {
376
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
377
+ setBusyProviderId(id)
378
+ try {
379
+ const res = await cmsApi<{
380
+ provider: AiProviderView
381
+ result: { ok: boolean; status: AiConnectionStatus; message: string }
382
+ }>(`/ai/providers/${id}/test`, { method: 'POST' })
383
+ if (res.error) return { ok: false, error: res.error }
384
+ await reloadProviders()
385
+ return {
386
+ ok: res.data?.result.ok ?? false,
387
+ status: res.data?.result.status,
388
+ message: res.data?.result.message,
389
+ }
390
+ } finally {
391
+ setBusyProviderId(null)
392
+ }
393
+ },
394
+ [canEdit, reloadProviders],
395
+ )
396
+
397
+ const syncModels = useCallback(
398
+ async (id: string) => {
399
+ if (!canEdit) return { ok: false, error: 'You do not have permission to do this.' }
400
+ setBusyProviderId(id)
401
+ try {
402
+ const res = await cmsApi<{
403
+ models: AiModelView[]
404
+ fromFallback: boolean
405
+ warning: string | null
406
+ }>(`/ai/providers/${id}/sync-models`, { method: 'POST' })
407
+ if (res.error) return { ok: false, error: res.error }
408
+ await loadModels()
409
+ await reloadProviders()
410
+ return {
411
+ ok: true,
412
+ fromFallback: res.data?.fromFallback,
413
+ warning: res.data?.warning ?? null,
414
+ }
415
+ } finally {
416
+ setBusyProviderId(null)
417
+ }
418
+ },
419
+ [canEdit, loadModels, reloadProviders],
420
+ )
421
+
422
+ return {
423
+ loading,
424
+ loadError,
425
+ providers,
426
+ models,
427
+ usage,
428
+ defaultModel,
429
+ form,
430
+ setDefaultProvider,
431
+ setDefaultModel: setDefaultModelId,
432
+ setMonthlyTokenLimit,
433
+ toggleFeature,
434
+ dirty,
435
+ budgetError,
436
+ saveState,
437
+ saveError,
438
+ save,
439
+ reset,
440
+ refetch,
441
+ createProvider,
442
+ updateProvider,
443
+ deleteProvider,
444
+ testProvider,
445
+ syncModels,
446
+ busyProviderId,
447
+ }
448
+ }