@actuate-media/cms-admin 0.34.0 → 0.36.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 (29) hide show
  1. package/dist/__tests__/views/ai-settings.render.test.js +69 -0
  2. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  3. package/dist/actuate-admin.css +1 -1
  4. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  5. package/dist/views/settings/AISettingsTab.js +2 -1
  6. package/dist/views/settings/AISettingsTab.js.map +1 -1
  7. package/dist/views/settings/AiFeaturesCard.d.ts +10 -5
  8. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  9. package/dist/views/settings/AiFeaturesCard.js +26 -5
  10. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  11. package/dist/views/settings/BrandVoiceCard.d.ts +12 -0
  12. package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -0
  13. package/dist/views/settings/BrandVoiceCard.js +27 -0
  14. package/dist/views/settings/BrandVoiceCard.js.map +1 -0
  15. package/dist/views/settings/featureModel.d.ts +25 -0
  16. package/dist/views/settings/featureModel.d.ts.map +1 -0
  17. package/dist/views/settings/featureModel.js +45 -0
  18. package/dist/views/settings/featureModel.js.map +1 -0
  19. package/dist/views/settings/useAiSettings.d.ts +18 -0
  20. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  21. package/dist/views/settings/useAiSettings.js +73 -3
  22. package/dist/views/settings/useAiSettings.js.map +1 -1
  23. package/package.json +2 -2
  24. package/src/__tests__/views/ai-settings.render.test.tsx +97 -0
  25. package/src/views/settings/AISettingsTab.tsx +10 -1
  26. package/src/views/settings/AiFeaturesCard.tsx +117 -12
  27. package/src/views/settings/BrandVoiceCard.tsx +152 -0
  28. package/src/views/settings/featureModel.ts +64 -0
  29. package/src/views/settings/useAiSettings.ts +107 -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 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,152 @@
1
+ 'use client'
2
+
3
+ import { Sparkles } from 'lucide-react'
4
+ import { useId } from 'react'
5
+ import { SettingsToggleRow } from './components.js'
6
+ import type { BrandVoiceForm } from './useAiSettings.js'
7
+
8
+ const INPUT_CLASS =
9
+ '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'
10
+ const TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[88px] resize-y`
11
+ const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
12
+
13
+ const TONE_MAX = 400
14
+ const GUIDELINES_MAX = 2000
15
+ const SAMPLE_MAX = 2000
16
+
17
+ /**
18
+ * Brand voice — a reusable description of how generated copy should sound. When
19
+ * enabled, it is injected into the system prompt of content-generation features
20
+ * (co-author, SEO titles/descriptions) so every AI surface writes on-brand.
21
+ */
22
+ export function BrandVoiceCard({
23
+ value,
24
+ canEdit,
25
+ onChange,
26
+ }: {
27
+ value: BrandVoiceForm
28
+ canEdit: boolean
29
+ onChange: (patch: Partial<BrandVoiceForm>) => void
30
+ }) {
31
+ const headingId = useId()
32
+ const nameId = useId()
33
+ const toneId = useId()
34
+ const audienceId = useId()
35
+ const guidelinesId = useId()
36
+ const sampleId = useId()
37
+ const disabled = !canEdit
38
+
39
+ return (
40
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
41
+ <div className="mb-4 flex items-center gap-2">
42
+ <span className="bg-brand/10 text-brand flex h-9 w-9 items-center justify-center rounded-lg">
43
+ <Sparkles size={18} aria-hidden="true" />
44
+ </span>
45
+ <div>
46
+ <h3 id={headingId} className="text-foreground text-base font-medium">
47
+ Brand voice
48
+ </h3>
49
+ <p className="text-muted-foreground text-sm">
50
+ Teach the AI how your brand sounds. Applied to content writing and SEO copy.
51
+ </p>
52
+ </div>
53
+ </div>
54
+
55
+ <div className="space-y-4">
56
+ <SettingsToggleRow
57
+ label="Apply brand voice"
58
+ description="When on, generated copy follows the tone and guidelines below."
59
+ checked={value.enabled}
60
+ disabled={disabled}
61
+ onChange={(v) => onChange({ enabled: v })}
62
+ />
63
+
64
+ <div className="border-border space-y-4 border-t pt-4">
65
+ <div>
66
+ <label htmlFor={nameId} className={LABEL_CLASS}>
67
+ Name
68
+ </label>
69
+ <input
70
+ id={nameId}
71
+ type="text"
72
+ value={value.name}
73
+ disabled={disabled}
74
+ placeholder="e.g. Acme Marketing"
75
+ onChange={(e) => onChange({ name: e.target.value })}
76
+ className={INPUT_CLASS}
77
+ />
78
+ </div>
79
+
80
+ <div>
81
+ <label htmlFor={toneId} className={LABEL_CLASS}>
82
+ Tone
83
+ </label>
84
+ <input
85
+ id={toneId}
86
+ type="text"
87
+ value={value.tone}
88
+ disabled={disabled}
89
+ maxLength={TONE_MAX}
90
+ placeholder="e.g. confident, warm, plain-spoken"
91
+ onChange={(e) => onChange({ tone: e.target.value })}
92
+ className={INPUT_CLASS}
93
+ />
94
+ </div>
95
+
96
+ <div>
97
+ <label htmlFor={audienceId} className={LABEL_CLASS}>
98
+ Audience <span className="text-muted-foreground font-normal">(optional)</span>
99
+ </label>
100
+ <input
101
+ id={audienceId}
102
+ type="text"
103
+ value={value.audience}
104
+ disabled={disabled}
105
+ placeholder="e.g. busy IT directors at mid-market SaaS companies"
106
+ onChange={(e) => onChange({ audience: e.target.value })}
107
+ className={INPUT_CLASS}
108
+ />
109
+ </div>
110
+
111
+ <div>
112
+ <label htmlFor={guidelinesId} className={LABEL_CLASS}>
113
+ Guidelines <span className="text-muted-foreground font-normal">(optional)</span>
114
+ </label>
115
+ <textarea
116
+ id={guidelinesId}
117
+ value={value.guidelines}
118
+ disabled={disabled}
119
+ maxLength={GUIDELINES_MAX}
120
+ placeholder={
121
+ 'Do: lead with the benefit, use active voice.\nDon\u2019t: use jargon, exclamation points, or hype.'
122
+ }
123
+ onChange={(e) => onChange({ guidelines: e.target.value })}
124
+ className={TEXTAREA_CLASS}
125
+ />
126
+ <p className="text-muted-foreground mt-1 text-sm">
127
+ {value.guidelines.length}/{GUIDELINES_MAX}
128
+ </p>
129
+ </div>
130
+
131
+ <div>
132
+ <label htmlFor={sampleId} className={LABEL_CLASS}>
133
+ Sample copy <span className="text-muted-foreground font-normal">(optional)</span>
134
+ </label>
135
+ <textarea
136
+ id={sampleId}
137
+ value={value.sampleText}
138
+ disabled={disabled}
139
+ maxLength={SAMPLE_MAX}
140
+ placeholder="Paste a paragraph of on-brand copy the AI can pattern-match against."
141
+ onChange={(e) => onChange({ sampleText: e.target.value })}
142
+ className={TEXTAREA_CLASS}
143
+ />
144
+ <p className="text-muted-foreground mt-1 text-sm">
145
+ {value.sampleText.length}/{SAMPLE_MAX}
146
+ </p>
147
+ </div>
148
+ </div>
149
+ </div>
150
+ </section>
151
+ )
152
+ }
@@ -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 {
@@ -89,12 +93,32 @@ export interface AiUsageView {
89
93
  runCount: number
90
94
  }
91
95
 
96
+ /** Editable brand voice fields (the buffered Save-bar portion). */
97
+ export interface BrandVoiceForm {
98
+ enabled: boolean
99
+ name: string
100
+ tone: string
101
+ audience: string
102
+ guidelines: string
103
+ sampleText: string
104
+ }
105
+
106
+ const EMPTY_BRAND_VOICE: BrandVoiceForm = {
107
+ enabled: false,
108
+ name: '',
109
+ tone: '',
110
+ audience: '',
111
+ guidelines: '',
112
+ sampleText: '',
113
+ }
114
+
92
115
  /** The buffered (Save-bar) portion of AI settings. */
93
116
  export interface AiSettingsForm {
94
117
  defaultProviderId: string
95
118
  defaultModelId: string
96
119
  features: AiFeatureView[]
97
120
  monthlyTokenLimit: string
121
+ brandVoice: BrandVoiceForm
98
122
  }
99
123
 
100
124
  interface AiSettingsResponse {
@@ -103,6 +127,14 @@ interface AiSettingsResponse {
103
127
  defaultModelId?: string
104
128
  features: AiFeatureView[]
105
129
  budget: { monthlyTokenLimit?: number }
130
+ brandVoice?: {
131
+ enabled?: boolean
132
+ name?: string
133
+ tone?: string
134
+ audience?: string
135
+ guidelines?: string
136
+ sampleText?: string
137
+ }
106
138
  }
107
139
  providers: AiProviderView[]
108
140
  defaultModel: AiModelView | null
@@ -114,11 +146,23 @@ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
114
146
  return {
115
147
  defaultProviderId: res.settings.defaultProviderId ?? '',
116
148
  defaultModelId: res.settings.defaultModelId ?? '',
117
- features: res.settings.features.map((f) => ({ ...f })),
149
+ features: res.settings.features.map((f) => ({
150
+ ...f,
151
+ modelId: f.modelId ?? '',
152
+ providerId: f.providerId ?? '',
153
+ })),
118
154
  monthlyTokenLimit:
119
155
  typeof res.settings.budget.monthlyTokenLimit === 'number'
120
156
  ? String(res.settings.budget.monthlyTokenLimit)
121
157
  : '',
158
+ brandVoice: {
159
+ enabled: res.settings.brandVoice?.enabled ?? false,
160
+ name: res.settings.brandVoice?.name ?? '',
161
+ tone: res.settings.brandVoice?.tone ?? '',
162
+ audience: res.settings.brandVoice?.audience ?? '',
163
+ guidelines: res.settings.brandVoice?.guidelines ?? '',
164
+ sampleText: res.settings.brandVoice?.sampleText ?? '',
165
+ },
122
166
  }
123
167
  }
124
168
 
@@ -127,6 +171,19 @@ const EMPTY_FORM: AiSettingsForm = {
127
171
  defaultModelId: '',
128
172
  features: [],
129
173
  monthlyTokenLimit: '',
174
+ brandVoice: { ...EMPTY_BRAND_VOICE },
175
+ }
176
+
177
+ /** A brand voice with no content and disabled clears the stored profile (send null). */
178
+ function brandVoiceIsEmpty(bv: BrandVoiceForm): boolean {
179
+ return (
180
+ !bv.enabled &&
181
+ !bv.name.trim() &&
182
+ !bv.tone.trim() &&
183
+ !bv.audience.trim() &&
184
+ !bv.guidelines.trim() &&
185
+ !bv.sampleText.trim()
186
+ )
130
187
  }
131
188
 
132
189
  export interface ProviderInput {
@@ -152,6 +209,10 @@ export interface UseAiSettings {
152
209
  setDefaultModel: (id: string) => void
153
210
  setMonthlyTokenLimit: (value: string) => void
154
211
  toggleFeature: (key: string, enabled: boolean) => void
212
+ /** Set a per-feature model override. Empty string reverts to the default model. */
213
+ setFeatureModel: (key: string, modelId: string) => void
214
+ /** Patch one or more brand voice fields. */
215
+ setBrandVoice: (patch: Partial<BrandVoiceForm>) => void
155
216
  dirty: boolean
156
217
  budgetError: string | null
157
218
  saveState: SaveState
@@ -253,6 +314,21 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
253
314
  setSaveState('idle')
254
315
  }, [])
255
316
 
317
+ const setFeatureModel = useCallback((key: string, modelId: string) => {
318
+ setForm((prev) => ({
319
+ ...prev,
320
+ features: prev.features.map((f) =>
321
+ f.key === key ? { ...f, modelId, providerId: modelId ? f.providerId : '' } : f,
322
+ ),
323
+ }))
324
+ setSaveState('idle')
325
+ }, [])
326
+
327
+ const setBrandVoice = useCallback((patch: Partial<BrandVoiceForm>) => {
328
+ setForm((prev) => ({ ...prev, brandVoice: { ...prev.brandVoice, ...patch } }))
329
+ setSaveState('idle')
330
+ }, [])
331
+
256
332
  const budgetError = useMemo(() => {
257
333
  if (form.monthlyTokenLimit === '') return null
258
334
  const n = Number(form.monthlyTokenLimit)
@@ -265,7 +341,19 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
265
341
  if (form.defaultModelId !== baseline.defaultModelId) return true
266
342
  if (form.monthlyTokenLimit !== baseline.monthlyTokenLimit) return true
267
343
  if (form.features.length !== baseline.features.length) return true
268
- return form.features.some((f, i) => f.enabled !== baseline.features[i]?.enabled)
344
+ const bvKeys: (keyof BrandVoiceForm)[] = [
345
+ 'enabled',
346
+ 'name',
347
+ 'tone',
348
+ 'audience',
349
+ 'guidelines',
350
+ 'sampleText',
351
+ ]
352
+ if (bvKeys.some((k) => form.brandVoice[k] !== baseline.brandVoice[k])) return true
353
+ return form.features.some((f, i) => {
354
+ const base = baseline.features[i]
355
+ return f.enabled !== base?.enabled || (f.modelId ?? '') !== (base?.modelId ?? '')
356
+ })
269
357
  }, [form, baseline])
270
358
 
271
359
  const save = useCallback(async () => {
@@ -278,11 +366,25 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
278
366
  body: JSON.stringify({
279
367
  defaultProviderId: form.defaultProviderId || null,
280
368
  defaultModelId: form.defaultModelId || null,
281
- features: form.features.map((f) => ({ key: f.key, enabled: f.enabled })),
369
+ features: form.features.map((f) => ({
370
+ key: f.key,
371
+ enabled: f.enabled,
372
+ modelId: f.modelId ? f.modelId : null,
373
+ })),
282
374
  budget: {
283
375
  monthlyTokenLimit:
284
376
  form.monthlyTokenLimit === '' ? null : Number(form.monthlyTokenLimit),
285
377
  },
378
+ brandVoice: brandVoiceIsEmpty(form.brandVoice)
379
+ ? null
380
+ : {
381
+ enabled: form.brandVoice.enabled,
382
+ name: form.brandVoice.name,
383
+ tone: form.brandVoice.tone,
384
+ audience: form.brandVoice.audience,
385
+ guidelines: form.brandVoice.guidelines,
386
+ sampleText: form.brandVoice.sampleText,
387
+ },
286
388
  }),
287
389
  })
288
390
  if (res.error) throw new Error(res.error)
@@ -431,6 +533,8 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
431
533
  setDefaultModel: setDefaultModelId,
432
534
  setMonthlyTokenLimit,
433
535
  toggleFeature,
536
+ setFeatureModel,
537
+ setBrandVoice,
434
538
  dirty,
435
539
  budgetError,
436
540
  saveState,