@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.
- package/dist/__tests__/views/ai-settings.render.test.js +69 -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 +2 -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/BrandVoiceCard.d.ts +12 -0
- package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -0
- package/dist/views/settings/BrandVoiceCard.js +27 -0
- package/dist/views/settings/BrandVoiceCard.js.map +1 -0
- 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 +18 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +73 -3
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/ai-settings.render.test.tsx +97 -0
- package/src/views/settings/AISettingsTab.tsx +10 -1
- package/src/views/settings/AiFeaturesCard.tsx +117 -12
- package/src/views/settings/BrandVoiceCard.tsx +152 -0
- package/src/views/settings/featureModel.ts +64 -0
- 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
|
|
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,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) => ({
|
|
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
|
-
|
|
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) => ({
|
|
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,
|