@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/__tests__/views/ai-settings.render.test.js +38 -4
  3. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -1
  6. package/dist/components/AICoauthorBubbleMenu.js +5 -1
  7. package/dist/components/AICoauthorBubbleMenu.js.map +1 -1
  8. package/dist/components/BrandVoiceAlignmentBadge.d.ts +6 -0
  9. package/dist/components/BrandVoiceAlignmentBadge.d.ts.map +1 -0
  10. package/dist/components/BrandVoiceAlignmentBadge.js +11 -0
  11. package/dist/components/BrandVoiceAlignmentBadge.js.map +1 -0
  12. package/dist/components/CoauthorResultPopover.d.ts +5 -1
  13. package/dist/components/CoauthorResultPopover.d.ts.map +1 -1
  14. package/dist/components/CoauthorResultPopover.js +35 -6
  15. package/dist/components/CoauthorResultPopover.js.map +1 -1
  16. package/dist/components/CommandPalette.d.ts.map +1 -1
  17. package/dist/components/CommandPalette.js +5 -1
  18. package/dist/components/CommandPalette.js.map +1 -1
  19. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  20. package/dist/components/seo/SeoEditorDrawer.js +43 -2
  21. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  22. package/dist/components/ui/Badge.d.ts +1 -1
  23. package/dist/lib/brand-voice-client.d.ts +21 -0
  24. package/dist/lib/brand-voice-client.d.ts.map +1 -0
  25. package/dist/lib/brand-voice-client.js +27 -0
  26. package/dist/lib/brand-voice-client.js.map +1 -0
  27. package/dist/lib/coauthor-client.d.ts +2 -0
  28. package/dist/lib/coauthor-client.d.ts.map +1 -1
  29. package/dist/lib/coauthor-client.js +1 -0
  30. package/dist/lib/coauthor-client.js.map +1 -1
  31. package/dist/lib/seo-service.d.ts +12 -0
  32. package/dist/lib/seo-service.d.ts.map +1 -1
  33. package/dist/lib/seo-service.js +0 -10
  34. package/dist/lib/seo-service.js.map +1 -1
  35. package/dist/views/Settings.js +1 -1
  36. package/dist/views/Settings.js.map +1 -1
  37. package/dist/views/settings/AISettingsTab.d.ts +3 -1
  38. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  39. package/dist/views/settings/AISettingsTab.js +30 -2
  40. package/dist/views/settings/AISettingsTab.js.map +1 -1
  41. package/dist/views/settings/BrandVoiceCard.d.ts +44 -2
  42. package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -1
  43. package/dist/views/settings/BrandVoiceCard.js +215 -8
  44. package/dist/views/settings/BrandVoiceCard.js.map +1 -1
  45. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts +13 -0
  46. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts.map +1 -0
  47. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js +74 -0
  48. package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js.map +1 -0
  49. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts +7 -0
  50. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts.map +1 -0
  51. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js +54 -0
  52. package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js.map +1 -0
  53. package/dist/views/settings/useAiSettings.d.ts +32 -0
  54. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  55. package/dist/views/settings/useAiSettings.js +85 -3
  56. package/dist/views/settings/useAiSettings.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/__tests__/views/ai-settings.render.test.tsx +54 -4
  59. package/src/components/AICoauthorBubbleMenu.tsx +12 -0
  60. package/src/components/BrandVoiceAlignmentBadge.tsx +39 -0
  61. package/src/components/CoauthorResultPopover.tsx +62 -4
  62. package/src/components/CommandPalette.tsx +12 -0
  63. package/src/components/seo/SeoEditorDrawer.tsx +65 -1
  64. package/src/lib/brand-voice-client.ts +38 -0
  65. package/src/lib/coauthor-client.ts +4 -0
  66. package/src/lib/seo-service.ts +2 -0
  67. package/src/views/Settings.tsx +1 -1
  68. package/src/views/settings/AISettingsTab.tsx +47 -3
  69. package/src/views/settings/BrandVoiceCard.tsx +608 -75
  70. package/src/views/settings/brand-voice/BrandVoiceAlignmentPanel.tsx +205 -0
  71. package/src/views/settings/brand-voice/BrandVoiceRulesSections.tsx +321 -0
  72. 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
  }
@@ -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
  }
@@ -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 value={ai.form.brandVoice} canEdit={canEdit} onChange={ai.setBrandVoice} />
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}