@actuate-media/cms-admin 0.56.2 → 0.57.1
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/CHANGELOG.md +15 -0
- package/dist/__tests__/views/ai-settings.render.test.js +38 -4
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -1
- package/dist/components/AICoauthorBubbleMenu.js +5 -1
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -1
- package/dist/components/BrandVoiceAlignmentBadge.d.ts +6 -0
- package/dist/components/BrandVoiceAlignmentBadge.d.ts.map +1 -0
- package/dist/components/BrandVoiceAlignmentBadge.js +11 -0
- package/dist/components/BrandVoiceAlignmentBadge.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +5 -1
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -1
- package/dist/components/CoauthorResultPopover.js +35 -6
- package/dist/components/CoauthorResultPopover.js.map +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +5 -1
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +43 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/lib/brand-voice-client.d.ts +21 -0
- package/dist/lib/brand-voice-client.d.ts.map +1 -0
- package/dist/lib/brand-voice-client.js +27 -0
- package/dist/lib/brand-voice-client.js.map +1 -0
- package/dist/lib/coauthor-client.d.ts +2 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -1
- package/dist/lib/coauthor-client.js +1 -0
- package/dist/lib/coauthor-client.js.map +1 -1
- package/dist/lib/seo-service.d.ts +12 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +0 -10
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Settings.js +1 -1
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts +3 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +30 -2
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/BrandVoiceCard.d.ts +44 -2
- package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -1
- package/dist/views/settings/BrandVoiceCard.js +215 -8
- package/dist/views/settings/BrandVoiceCard.js.map +1 -1
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts +13 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js +74 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts +7 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js +54 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +32 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +85 -3
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/ai-settings.render.test.tsx +54 -4
- package/src/components/AICoauthorBubbleMenu.tsx +12 -0
- package/src/components/BrandVoiceAlignmentBadge.tsx +39 -0
- package/src/components/CoauthorResultPopover.tsx +62 -4
- package/src/components/CommandPalette.tsx +12 -0
- package/src/components/seo/SeoEditorDrawer.tsx +65 -1
- package/src/lib/brand-voice-client.ts +38 -0
- package/src/lib/coauthor-client.ts +4 -0
- package/src/lib/seo-service.ts +2 -0
- package/src/views/Settings.tsx +1 -1
- package/src/views/settings/AISettingsTab.tsx +47 -3
- package/src/views/settings/BrandVoiceCard.tsx +608 -75
- package/src/views/settings/brand-voice/BrandVoiceAlignmentPanel.tsx +205 -0
- package/src/views/settings/brand-voice/BrandVoiceRulesSections.tsx +321 -0
- package/src/views/settings/useAiSettings.ts +134 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
|
|
4
|
-
import { Sparkles, Check } from 'lucide-react'
|
|
4
|
+
import { Sparkles, Check, Loader2, Wand2 } from 'lucide-react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
|
+
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
6
7
|
import {
|
|
7
8
|
fetchSeoContentRecord,
|
|
8
9
|
updateSeoFields,
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
type SeoFieldPayload,
|
|
12
13
|
type LlmsInclude,
|
|
13
14
|
} from '../../lib/seo-service.js'
|
|
15
|
+
import { fixBrandVoiceContent } from '../../lib/brand-voice-client.js'
|
|
16
|
+
import { BrandVoiceAlignmentBadge } from '../BrandVoiceAlignmentBadge.js'
|
|
14
17
|
import { Drawer } from './Drawer.js'
|
|
15
18
|
import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from './primitives.js'
|
|
16
19
|
|
|
@@ -28,6 +31,11 @@ type AiField =
|
|
|
28
31
|
const KEY_TAKEAWAY_MAX = 240
|
|
29
32
|
const LLMS_DESC_MAX = 160
|
|
30
33
|
|
|
34
|
+
function seoBrandChannel(field: AiField): string | undefined {
|
|
35
|
+
if (field === 'metaDescription' || field === 'ogDescription') return 'meta_description'
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
function CharCount({ value, max }: { value: string; max: number }) {
|
|
32
40
|
const over = value.length > max
|
|
33
41
|
return (
|
|
@@ -90,6 +98,10 @@ export function SeoEditorDrawer({
|
|
|
90
98
|
const [error, setError] = useState<string | null>(null)
|
|
91
99
|
const [saving, setSaving] = useState(false)
|
|
92
100
|
const [aiBusy, setAiBusy] = useState<AiField | null>(null)
|
|
101
|
+
const [aiFixBusy, setAiFixBusy] = useState<AiField | null>(null)
|
|
102
|
+
const [aiAlignments, setAiAlignments] = useState<
|
|
103
|
+
Partial<Record<AiField, BrandAlignmentScoreResult>>
|
|
104
|
+
>({})
|
|
93
105
|
const [form, setForm] = useState<SeoFieldPayload>({})
|
|
94
106
|
|
|
95
107
|
useEffect(() => {
|
|
@@ -141,6 +153,9 @@ export function SeoEditorDrawer({
|
|
|
141
153
|
}
|
|
142
154
|
if (result.text) {
|
|
143
155
|
update({ [field]: result.text } as Partial<SeoFieldPayload>)
|
|
156
|
+
if (result.brandAlignment) {
|
|
157
|
+
setAiAlignments((prev) => ({ ...prev, [field]: result.brandAlignment }))
|
|
158
|
+
}
|
|
144
159
|
toast.success('AI suggestion applied — review before saving.')
|
|
145
160
|
} else {
|
|
146
161
|
toast.error('No suggestion was returned.')
|
|
@@ -152,6 +167,53 @@ export function SeoEditorDrawer({
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
|
|
170
|
+
async function handleAiFix(field: AiField) {
|
|
171
|
+
const text = (form[field as keyof SeoFieldPayload] as string | undefined) ?? ''
|
|
172
|
+
if (!text.trim()) return
|
|
173
|
+
setAiFixBusy(field)
|
|
174
|
+
try {
|
|
175
|
+
const res = await fixBrandVoiceContent({
|
|
176
|
+
content: text,
|
|
177
|
+
channel: seoBrandChannel(field),
|
|
178
|
+
})
|
|
179
|
+
if (!res.ok || !res.result) {
|
|
180
|
+
toast.error(res.error ?? 'Could not fix copy for brand voice.')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
update({ [field]: res.result.text } as Partial<SeoFieldPayload>)
|
|
184
|
+
setAiAlignments((prev) => ({ ...prev, [field]: res.result!.afterScore }))
|
|
185
|
+
toast.success('Copy adjusted to match brand voice.')
|
|
186
|
+
} finally {
|
|
187
|
+
setAiFixBusy(null)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function alignmentRow(field: AiField) {
|
|
192
|
+
const alignment = aiAlignments[field]
|
|
193
|
+
if (!alignment) return null
|
|
194
|
+
const fixing = aiFixBusy === field
|
|
195
|
+
return (
|
|
196
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
197
|
+
<BrandVoiceAlignmentBadge alignment={alignment} compact />
|
|
198
|
+
{alignment.score < 85 && (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={() => handleAiFix(field)}
|
|
202
|
+
disabled={fixing || aiBusy !== null}
|
|
203
|
+
className="text-primary hover:bg-accent focus-visible:ring-ring inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
|
|
204
|
+
>
|
|
205
|
+
{fixing ? (
|
|
206
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
207
|
+
) : (
|
|
208
|
+
<Wand2 className="h-3.5 w-3.5" aria-hidden />
|
|
209
|
+
)}
|
|
210
|
+
Fix for brand voice
|
|
211
|
+
</button>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
155
217
|
async function handleSave() {
|
|
156
218
|
if (!entityType || !entityId) return
|
|
157
219
|
setSaving(true)
|
|
@@ -252,6 +314,7 @@ export function SeoEditorDrawer({
|
|
|
252
314
|
onChange={(e) => update({ metaDescription: e.target.value })}
|
|
253
315
|
placeholder="Short summary shown under the title in search results"
|
|
254
316
|
/>
|
|
317
|
+
{alignmentRow('metaDescription')}
|
|
255
318
|
</Field>
|
|
256
319
|
|
|
257
320
|
<Field label="Focus keyword" hint={aiButton('focusKeyword')}>
|
|
@@ -307,6 +370,7 @@ export function SeoEditorDrawer({
|
|
|
307
370
|
onChange={(e) => update({ ogDescription: e.target.value })}
|
|
308
371
|
placeholder={form.metaDescription || 'Description when shared on social media'}
|
|
309
372
|
/>
|
|
373
|
+
{alignmentRow('ogDescription')}
|
|
310
374
|
</Field>
|
|
311
375
|
<div className="border-border overflow-hidden rounded-lg border">
|
|
312
376
|
{record.ogImage ? (
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
2
|
+
import { cmsApi } from './api.js'
|
|
3
|
+
|
|
4
|
+
export type { BrandAlignmentScoreResult }
|
|
5
|
+
|
|
6
|
+
export interface BrandVoiceFixResult {
|
|
7
|
+
text: string
|
|
8
|
+
deterministicFixes: string[]
|
|
9
|
+
beforeScore: BrandAlignmentScoreResult
|
|
10
|
+
afterScore: BrandAlignmentScoreResult
|
|
11
|
+
usedAi: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function fixBrandVoiceContent(input: {
|
|
15
|
+
content: string
|
|
16
|
+
channel?: string
|
|
17
|
+
useAi?: boolean
|
|
18
|
+
}): Promise<{ ok: boolean; result?: BrandVoiceFixResult; error?: string }> {
|
|
19
|
+
const res = await cmsApi<BrandVoiceFixResult>('/ai/brand-voice/fix', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
body: JSON.stringify(input),
|
|
22
|
+
})
|
|
23
|
+
if (res.error) return { ok: false, error: res.error }
|
|
24
|
+
if (!res.data?.text) return { ok: false, error: 'No fixed copy was returned.' }
|
|
25
|
+
return { ok: true, result: res.data }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function brandAlignmentLabel(score: number): string {
|
|
29
|
+
if (score >= 85) return 'On brand'
|
|
30
|
+
if (score >= 65) return 'Mostly on brand'
|
|
31
|
+
return 'Off brand'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function brandAlignmentTone(score: number): string {
|
|
35
|
+
if (score >= 85) return 'text-success'
|
|
36
|
+
if (score >= 65) return 'text-warning'
|
|
37
|
+
return 'text-destructive'
|
|
38
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cmsApi } from './api.js'
|
|
2
|
+
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Inline AI co-author client — thin, typed wrapper around the
|
|
@@ -68,6 +69,7 @@ export interface CoauthorResult {
|
|
|
68
69
|
text: string
|
|
69
70
|
/** Populated only when `action === 'proofread'`. */
|
|
70
71
|
changes?: ProofreadChange[]
|
|
72
|
+
brandAlignment?: BrandAlignmentScoreResult
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export type CoauthorOutcome =
|
|
@@ -125,6 +127,7 @@ export async function coauthorText(
|
|
|
125
127
|
action?: CoauthorAction
|
|
126
128
|
text?: unknown
|
|
127
129
|
changes?: unknown
|
|
130
|
+
brandAlignment?: BrandAlignmentScoreResult
|
|
128
131
|
}>('/ai/coauthor', {
|
|
129
132
|
method: 'POST',
|
|
130
133
|
body: JSON.stringify(body),
|
|
@@ -155,6 +158,7 @@ export async function coauthorText(
|
|
|
155
158
|
action === 'proofread' && Array.isArray(res.data.changes)
|
|
156
159
|
? (res.data.changes as ProofreadChange[])
|
|
157
160
|
: undefined,
|
|
161
|
+
brandAlignment: res.data.brandAlignment,
|
|
158
162
|
},
|
|
159
163
|
}
|
|
160
164
|
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Endpoint paths here are the contract implemented by cms-core
|
|
9
9
|
* `packages/cms-core/src/api/handlers.ts` across phases 2-3 and 6.
|
|
10
10
|
*/
|
|
11
|
+
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
11
12
|
import { cmsApi } from './api.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -643,6 +644,7 @@ export interface SeoAiResult {
|
|
|
643
644
|
text?: string
|
|
644
645
|
alternatives?: string[]
|
|
645
646
|
structuredData?: Record<string, unknown>
|
|
647
|
+
brandAlignment?: BrandAlignmentScoreResult
|
|
646
648
|
/** True when the AI provider is not configured (graceful fallback). */
|
|
647
649
|
unavailable?: boolean
|
|
648
650
|
}
|
package/src/views/Settings.tsx
CHANGED
|
@@ -325,7 +325,7 @@ export function Settings({
|
|
|
325
325
|
</Tabs.Content>
|
|
326
326
|
|
|
327
327
|
<Tabs.Content value="ai" className="space-y-4">
|
|
328
|
-
<AISettingsTab role={session?.user?.role} />
|
|
328
|
+
<AISettingsTab role={session?.user?.role} config={config} />
|
|
329
329
|
</Tabs.Content>
|
|
330
330
|
|
|
331
331
|
<Tabs.Content value="users">
|
|
@@ -4,7 +4,7 @@ import { AlertTriangle, Info, Loader2, Plus } from 'lucide-react'
|
|
|
4
4
|
import { useId, useMemo, useState } from 'react'
|
|
5
5
|
import { toast } from 'sonner'
|
|
6
6
|
import { AiFeaturesCard } from './AiFeaturesCard.js'
|
|
7
|
-
import { BrandVoiceCard } from './BrandVoiceCard.js'
|
|
7
|
+
import { BrandVoiceCard, type BrandVoiceAiReadiness } from './BrandVoiceCard.js'
|
|
8
8
|
import { ModelSelector } from './ModelSelector.js'
|
|
9
9
|
import { MonthlyUsageCard } from './MonthlyUsageCard.js'
|
|
10
10
|
import { ProviderConnectionCard } from './ProviderConnectionCard.js'
|
|
@@ -30,12 +30,46 @@ const PROVIDER_OPTIONS: Array<{ value: AiProviderKey; label: string }> = [
|
|
|
30
30
|
export interface AISettingsTabProps {
|
|
31
31
|
/** Current user's role. Only ADMIN can persist changes. */
|
|
32
32
|
role?: string
|
|
33
|
+
/** Resolved CMS config — used to list content collections for brand voice analysis. */
|
|
34
|
+
config?: unknown
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export function AISettingsTab({ role }: AISettingsTabProps) {
|
|
37
|
+
export function AISettingsTab({ role, config }: AISettingsTabProps) {
|
|
36
38
|
const canEdit = role === 'ADMIN'
|
|
37
39
|
const ai = useAiSettings(canEdit)
|
|
38
40
|
|
|
41
|
+
const brandVoiceAiReadiness = useMemo((): BrandVoiceAiReadiness => {
|
|
42
|
+
const contentWriting = ai.form.features.find((f) => f.key === 'ai.features.contentWriting')
|
|
43
|
+
if (contentWriting && !contentWriting.enabled) {
|
|
44
|
+
return {
|
|
45
|
+
ready: false,
|
|
46
|
+
message:
|
|
47
|
+
'Content writing suggestions are turned off. Enable them in AI Features and click Save changes before generating a brand voice profile.',
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (ai.providers.length === 0) {
|
|
51
|
+
return {
|
|
52
|
+
ready: false,
|
|
53
|
+
message:
|
|
54
|
+
'Connect an AI provider under Provider connections before generating a brand voice profile.',
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!ai.form.defaultModelId) {
|
|
58
|
+
return {
|
|
59
|
+
ready: false,
|
|
60
|
+
message:
|
|
61
|
+
'Select a default model in the AI Assistant card and click Save changes. Analyze & draft profile uses the Content writing feature.',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (ai.dirty) {
|
|
65
|
+
return {
|
|
66
|
+
ready: false,
|
|
67
|
+
message: 'Save your AI settings before generating a brand voice profile.',
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { ready: true }
|
|
71
|
+
}, [ai.dirty, ai.form.defaultModelId, ai.form.features, ai.providers.length])
|
|
72
|
+
|
|
39
73
|
if (ai.loading) {
|
|
40
74
|
return (
|
|
41
75
|
<div className="space-y-4" aria-busy="true" aria-live="polite">
|
|
@@ -89,7 +123,17 @@ export function AISettingsTab({ role }: AISettingsTabProps) {
|
|
|
89
123
|
onToggle={ai.toggleFeature}
|
|
90
124
|
onSetFeatureModel={ai.setFeatureModel}
|
|
91
125
|
/>
|
|
92
|
-
<BrandVoiceCard
|
|
126
|
+
<BrandVoiceCard
|
|
127
|
+
value={ai.form.brandVoice}
|
|
128
|
+
canEdit={canEdit}
|
|
129
|
+
onChange={ai.setBrandVoice}
|
|
130
|
+
config={config}
|
|
131
|
+
onAnalyze={canEdit ? ai.analyzeBrandVoice : undefined}
|
|
132
|
+
onScore={canEdit ? ai.scoreBrandVoice : undefined}
|
|
133
|
+
editMode={ai.brandVoiceEditMode}
|
|
134
|
+
onEditModeChange={ai.setBrandVoiceEditMode}
|
|
135
|
+
aiReadiness={brandVoiceAiReadiness}
|
|
136
|
+
/>
|
|
93
137
|
<MonthlyUsageCard
|
|
94
138
|
usage={ai.usage}
|
|
95
139
|
monthlyTokenLimit={ai.form.monthlyTokenLimit}
|