@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.
- package/dist/__tests__/views/ai-settings.render.test.js +47 -0
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AiFeaturesCard.d.ts +10 -5
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +26 -5
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/featureModel.d.ts +25 -0
- package/dist/views/settings/featureModel.d.ts.map +1 -0
- package/dist/views/settings/featureModel.js +45 -0
- package/dist/views/settings/featureModel.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +6 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +22 -3
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/views/ai-settings.render.test.tsx +61 -0
- package/src/views/settings/AISettingsTab.tsx +8 -1
- package/src/views/settings/AiFeaturesCard.tsx +117 -12
- package/src/views/settings/featureModel.ts +64 -0
- package/src/views/settings/useAiSettings.ts +31 -3
|
@@ -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
|
|
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
|
|
9
|
-
* shared `ai.features.*` flags
|
|
10
|
-
*
|
|
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
|
|
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="
|
|
48
|
+
<div className="divide-border divide-y">
|
|
35
49
|
{features.map((f) => (
|
|
36
|
-
<
|
|
50
|
+
<FeatureRow
|
|
37
51
|
key={f.key}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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'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) => ({
|
|
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) =>
|
|
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) => ({
|
|
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,
|