@actuate-media/cms-admin 0.34.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.
@@ -1,22 +1,35 @@
1
1
  'use client'
2
2
 
3
+ import { AlertTriangle } from 'lucide-react'
3
4
  import { useId } from 'react'
4
5
  import { SettingsToggleRow } from './components.js'
5
- import type { AiFeatureView } from './useAiSettings.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'
6
11
 
7
12
  /**
8
- * Global AI feature toggles. Each switch persists (via the Save bar) to the
9
- * shared `ai.features.*` flags. Enforcement across modules lands in a later
10
- * phase; here we surface and store the operator's intent.
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.
11
18
  */
12
19
  export function AiFeaturesCard({
13
20
  features,
21
+ models,
22
+ defaultModelId,
14
23
  canEdit,
15
24
  onToggle,
25
+ onSetFeatureModel,
16
26
  }: {
17
27
  features: AiFeatureView[]
28
+ models: AiModelView[]
29
+ defaultModelId: string
18
30
  canEdit: boolean
19
31
  onToggle: (key: string, enabled: boolean) => void
32
+ onSetFeatureModel: (key: string, modelId: string) => void
20
33
  }) {
21
34
  const headingId = useId()
22
35
  return (
@@ -25,21 +38,23 @@ export function AiFeaturesCard({
25
38
  AI Features
26
39
  </h3>
27
40
  <p className="text-muted-foreground mb-4 text-sm">
28
- Turn individual AI-powered features on or off across the CMS.
41
+ Turn individual AI-powered features on or off, and optionally route each to a specific
42
+ model.
29
43
  </p>
30
44
 
31
45
  {features.length === 0 ? (
32
46
  <p className="text-muted-foreground text-sm">No AI features are available.</p>
33
47
  ) : (
34
- <div className="space-y-4">
48
+ <div className="divide-border divide-y">
35
49
  {features.map((f) => (
36
- <SettingsToggleRow
50
+ <FeatureRow
37
51
  key={f.key}
38
- label={f.label}
39
- description={f.description}
40
- checked={f.enabled}
41
- disabled={!canEdit}
42
- onChange={(v) => onToggle(f.key, v)}
52
+ feature={f}
53
+ models={models}
54
+ defaultModelId={defaultModelId}
55
+ canEdit={canEdit}
56
+ onToggle={onToggle}
57
+ onSetFeatureModel={onSetFeatureModel}
43
58
  />
44
59
  ))}
45
60
  </div>
@@ -47,3 +62,93 @@ export function AiFeaturesCard({
47
62
  </section>
48
63
  )
49
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,64 @@
1
+ /**
2
+ * Client-side, display-only feature → model resolution.
3
+ *
4
+ * Mirrors a simplified version of cms-core's `resolveFeatureModel` so the UI can
5
+ * preview which model a feature will use as the admin edits — including before
6
+ * Save. The server resolver remains the authority for the AI runtime; this only
7
+ * drives labels and capability warnings.
8
+ */
9
+
10
+ import type { AiModelView } from './useAiSettings.js'
11
+
12
+ const CAPABILITY_LABELS: Record<string, string> = {
13
+ text: 'text',
14
+ vision: 'vision',
15
+ imageGeneration: 'image generation',
16
+ embeddings: 'embeddings',
17
+ toolCalling: 'tool calling',
18
+ structuredOutput: 'structured output',
19
+ streaming: 'streaming',
20
+ reasoning: 'reasoning',
21
+ longContext: 'long context',
22
+ }
23
+
24
+ export function findModel(models: AiModelView[], id: string | undefined): AiModelView | undefined {
25
+ if (!id) return undefined
26
+ return models.find((m) => m.id === id || m.providerModelId === id)
27
+ }
28
+
29
+ /** Required capabilities a model is missing. Unknown capability strings are ignored. */
30
+ export function missingCapabilities(model: AiModelView | undefined, required: string[]): string[] {
31
+ const wanted = required.filter((c) => c in CAPABILITY_LABELS)
32
+ if (!model) return wanted
33
+ return wanted.filter((c) => !model.capabilities?.[c])
34
+ }
35
+
36
+ export function capabilityLabel(key: string): string {
37
+ return CAPABILITY_LABELS[key] ?? key
38
+ }
39
+
40
+ export interface EffectiveFeatureModel {
41
+ /** The id that will be used (override, else the global default). */
42
+ modelId: string
43
+ model?: AiModelView
44
+ /** True when the feature has its own model override (not the default). */
45
+ isOverride: boolean
46
+ missingCapabilities: string[]
47
+ }
48
+
49
+ export function effectiveFeatureModel(
50
+ override: string | undefined,
51
+ defaultModelId: string,
52
+ required: string[],
53
+ models: AiModelView[],
54
+ ): EffectiveFeatureModel {
55
+ const isOverride = Boolean(override)
56
+ const modelId = override || defaultModelId
57
+ const model = findModel(models, modelId)
58
+ return { modelId, model, isOverride, missingCapabilities: missingCapabilities(model, required) }
59
+ }
60
+
61
+ /** Whether a model satisfies every required capability and is selectable. */
62
+ export function isModelCompatible(model: AiModelView, required: string[]): boolean {
63
+ return model.status !== 'unavailable' && missingCapabilities(model, required).length === 0
64
+ }
@@ -76,6 +76,10 @@ export interface AiFeatureView {
76
76
  enabled: boolean
77
77
  category: string
78
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
79
83
  }
80
84
 
81
85
  export interface AiUsageView {
@@ -114,7 +118,11 @@ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
114
118
  return {
115
119
  defaultProviderId: res.settings.defaultProviderId ?? '',
116
120
  defaultModelId: res.settings.defaultModelId ?? '',
117
- features: res.settings.features.map((f) => ({ ...f })),
121
+ features: res.settings.features.map((f) => ({
122
+ ...f,
123
+ modelId: f.modelId ?? '',
124
+ providerId: f.providerId ?? '',
125
+ })),
118
126
  monthlyTokenLimit:
119
127
  typeof res.settings.budget.monthlyTokenLimit === 'number'
120
128
  ? String(res.settings.budget.monthlyTokenLimit)
@@ -152,6 +160,8 @@ export interface UseAiSettings {
152
160
  setDefaultModel: (id: string) => void
153
161
  setMonthlyTokenLimit: (value: string) => void
154
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
155
165
  dirty: boolean
156
166
  budgetError: string | null
157
167
  saveState: SaveState
@@ -253,6 +263,16 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
253
263
  setSaveState('idle')
254
264
  }, [])
255
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
+
256
276
  const budgetError = useMemo(() => {
257
277
  if (form.monthlyTokenLimit === '') return null
258
278
  const n = Number(form.monthlyTokenLimit)
@@ -265,7 +285,10 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
265
285
  if (form.defaultModelId !== baseline.defaultModelId) return true
266
286
  if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit) return true
267
287
  if (form.features.length !== baseline.features.length) return true
268
- return form.features.some((f, i) => f.enabled !== baseline.features[i]?.enabled)
288
+ return form.features.some((f, i) => {
289
+ const base = baseline.features[i]
290
+ return f.enabled !== base?.enabled || (f.modelId ?? '') !== (base?.modelId ?? '')
291
+ })
269
292
  }, [form, baseline])
270
293
 
271
294
  const save = useCallback(async () => {
@@ -278,7 +301,11 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
278
301
  body: JSON.stringify({
279
302
  defaultProviderId: form.defaultProviderId || null,
280
303
  defaultModelId: form.defaultModelId || null,
281
- features: form.features.map((f) => ({ key: f.key, enabled: f.enabled })),
304
+ features: form.features.map((f) => ({
305
+ key: f.key,
306
+ enabled: f.enabled,
307
+ modelId: f.modelId ? f.modelId : null,
308
+ })),
282
309
  budget: {
283
310
  monthlyTokenLimit:
284
311
  form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
@@ -431,6 +458,7 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
431
458
  setDefaultModel: setDefaultModelId,
432
459
  setMonthlyTokenLimit,
433
460
  toggleFeature,
461
+ setFeatureModel,
434
462
  dirty,
435
463
  budgetError,
436
464
  saveState,