@prenta/admin 1.11.0 → 1.12.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/CHANGELOG.md +26 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +54 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/ai-settings.render.test.js +26 -8
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +40 -2
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.js +4 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +8 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
- package/dist/components/seo/SeoSearchStats.d.ts +4 -0
- package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
- package/dist/components/seo/SeoSearchStats.js +91 -0
- package/dist/components/seo/SeoSearchStats.js.map +1 -0
- package/dist/lib/seo-service.d.ts +41 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +22 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +6 -0
- package/dist/views/seo/RedirectsTab.js.map +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 +8 -3
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +7 -6
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/SearchDataCard.d.ts +10 -0
- package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
- package/dist/views/settings/SearchDataCard.js +126 -0
- package/dist/views/settings/SearchDataCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useAiSettings.d.ts +12 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +29 -1
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
- package/src/__tests__/lib/seo-service-errors.test.ts +73 -0
- package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
- package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +55 -2
- package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
- package/src/components/seo/SeoEditorPane.tsx +13 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
- package/src/components/seo/SeoSearchStats.tsx +183 -0
- package/src/lib/seo-service.ts +64 -2
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +6 -0
- package/src/views/settings/AISettingsTab.tsx +1 -0
- package/src/views/settings/AiFeaturesCard.tsx +59 -2
- package/src/views/settings/SearchDataCard.tsx +390 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
- package/src/views/settings/useAiSettings.ts +71 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only search-performance block for the SEO editor pane: synced Google
|
|
5
|
+
* Search Console 28-day stats, top queries, and CrUX Core Web Vitals for one
|
|
6
|
+
* document. Purely informational — editing happens in the fields above it.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react'
|
|
9
|
+
import { fetchSearchInsight, type SearchInsight } from '../../lib/seo-service.js'
|
|
10
|
+
import { SeoLoading } from './primitives.js'
|
|
11
|
+
|
|
12
|
+
function formatCompact(n: number): string {
|
|
13
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
|
14
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`
|
|
15
|
+
return String(n)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function DeltaHint({
|
|
19
|
+
current,
|
|
20
|
+
previous,
|
|
21
|
+
lowerIsBetter = false,
|
|
22
|
+
}: {
|
|
23
|
+
current: number | null
|
|
24
|
+
previous: number | null
|
|
25
|
+
lowerIsBetter?: boolean
|
|
26
|
+
}) {
|
|
27
|
+
if (current == null || previous == null || previous === 0) return null
|
|
28
|
+
const diff = current - previous
|
|
29
|
+
if (diff === 0) return null
|
|
30
|
+
const improved = lowerIsBetter ? diff < 0 : diff > 0
|
|
31
|
+
return (
|
|
32
|
+
<span className={improved ? 'text-[var(--suc)]' : 'text-[var(--err)]'}>
|
|
33
|
+
{diff > 0 ? '+' : ''}
|
|
34
|
+
{Number.isInteger(diff) ? diff : diff.toFixed(1)}
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cwvTone(status: SearchInsight['cwvStatus']): { label: string; cls: string } {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case 'good':
|
|
42
|
+
return { label: 'Good', cls: 'bg-[var(--suc-l)] text-[var(--suc)]' }
|
|
43
|
+
case 'needs-improvement':
|
|
44
|
+
return { label: 'Needs improvement', cls: 'bg-[var(--warn-l)] text-[var(--warn)]' }
|
|
45
|
+
case 'poor':
|
|
46
|
+
return { label: 'Poor', cls: 'bg-[var(--err-l)] text-[var(--err)]' }
|
|
47
|
+
default:
|
|
48
|
+
return { label: 'No field data', cls: 'bg-[var(--bg)] text-[var(--muted)]' }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function SeoSearchStats({ entityId }: { entityId: string }) {
|
|
53
|
+
const [loading, setLoading] = useState(true)
|
|
54
|
+
const [connected, setConnected] = useState(false)
|
|
55
|
+
const [insight, setInsight] = useState<SearchInsight | null>(null)
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let alive = true
|
|
59
|
+
setLoading(true)
|
|
60
|
+
fetchSearchInsight(entityId)
|
|
61
|
+
.then((res) => {
|
|
62
|
+
if (!alive) return
|
|
63
|
+
setConnected(res.connected)
|
|
64
|
+
setInsight(res.insight)
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (!alive) return
|
|
68
|
+
setConnected(false)
|
|
69
|
+
setInsight(null)
|
|
70
|
+
})
|
|
71
|
+
.finally(() => alive && setLoading(false))
|
|
72
|
+
return () => {
|
|
73
|
+
alive = false
|
|
74
|
+
}
|
|
75
|
+
}, [entityId])
|
|
76
|
+
|
|
77
|
+
if (loading) return <SeoLoading />
|
|
78
|
+
|
|
79
|
+
if (!connected) {
|
|
80
|
+
return (
|
|
81
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
82
|
+
Connect Google Search Console in Settings → SEO → Search data to see real clicks,
|
|
83
|
+
impressions, and queries for this page.
|
|
84
|
+
</p>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!insight) {
|
|
89
|
+
return (
|
|
90
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
91
|
+
No search data synced for this page yet. Data appears after the next nightly sync once the
|
|
92
|
+
page has impressions in Google Search.
|
|
93
|
+
</p>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cwv = cwvTone(insight.cwvStatus)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-3">
|
|
101
|
+
<div className="grid grid-cols-3 gap-2">
|
|
102
|
+
{[
|
|
103
|
+
{
|
|
104
|
+
label: 'Clicks',
|
|
105
|
+
value: formatCompact(insight.clicks28d),
|
|
106
|
+
delta: <DeltaHint current={insight.clicks28d} previous={insight.clicksPrev28d} />,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'Impressions',
|
|
110
|
+
value: formatCompact(insight.impressions28d),
|
|
111
|
+
delta: (
|
|
112
|
+
<DeltaHint current={insight.impressions28d} previous={insight.impressionsPrev28d} />
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
label: 'Avg. position',
|
|
117
|
+
value: insight.avgPosition28d != null ? insight.avgPosition28d.toFixed(1) : '—',
|
|
118
|
+
delta: (
|
|
119
|
+
<DeltaHint
|
|
120
|
+
current={insight.avgPosition28d}
|
|
121
|
+
previous={insight.avgPositionPrev28d}
|
|
122
|
+
lowerIsBetter
|
|
123
|
+
/>
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
].map((stat) => (
|
|
127
|
+
<div key={stat.label} className="rounded-[7px] border border-[var(--bdr)] px-2.5 py-2">
|
|
128
|
+
<div className="text-[10.5px] text-[var(--muted)]">{stat.label}</div>
|
|
129
|
+
<div className="flex items-baseline gap-1.5">
|
|
130
|
+
<span className="text-[15px] font-semibold text-[var(--txt)]">{stat.value}</span>
|
|
131
|
+
<span className="text-[10.5px]">{stat.delta}</span>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
<p className="text-[10.5px] text-[var(--muted)]">
|
|
137
|
+
Last 28 days vs the prior 28 — synced from Google Search Console.
|
|
138
|
+
</p>
|
|
139
|
+
|
|
140
|
+
{insight.topQueries.length > 0 && (
|
|
141
|
+
<div>
|
|
142
|
+
<p className="mb-1.5 text-[11px] font-medium text-[var(--sub)]">Top queries</p>
|
|
143
|
+
<table className="w-full text-[11.5px]" aria-label="Top search queries for this page">
|
|
144
|
+
<thead>
|
|
145
|
+
<tr className="text-left text-[10.5px] text-[var(--muted)]">
|
|
146
|
+
<th className="pb-1 font-medium">Query</th>
|
|
147
|
+
<th className="pb-1 text-right font-medium">Clicks</th>
|
|
148
|
+
<th className="pb-1 text-right font-medium">Pos.</th>
|
|
149
|
+
</tr>
|
|
150
|
+
</thead>
|
|
151
|
+
<tbody>
|
|
152
|
+
{insight.topQueries.slice(0, 8).map((q) => (
|
|
153
|
+
<tr key={q.query} className="border-t border-[var(--bdr)]">
|
|
154
|
+
<td className="max-w-0 truncate py-1 pr-2 text-[var(--txt)]" title={q.query}>
|
|
155
|
+
{q.query}
|
|
156
|
+
</td>
|
|
157
|
+
<td className="py-1 text-right text-[var(--sub)]">{formatCompact(q.clicks)}</td>
|
|
158
|
+
<td className="py-1 text-right text-[var(--sub)]">{q.position.toFixed(1)}</td>
|
|
159
|
+
</tr>
|
|
160
|
+
))}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<div className="flex items-center justify-between gap-2 border-t border-[var(--bdr)] pt-2.5">
|
|
167
|
+
<span className="text-[11px] font-medium text-[var(--sub)]">
|
|
168
|
+
Core Web Vitals (real users)
|
|
169
|
+
</span>
|
|
170
|
+
<span className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium ${cwv.cls}`}>
|
|
171
|
+
{cwv.label}
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
{insight.cwvStatus !== null && (
|
|
175
|
+
<div className="grid grid-cols-3 gap-2 text-[11px] text-[var(--sub)]">
|
|
176
|
+
<span>LCP {insight.lcpMs != null ? `${(insight.lcpMs / 1000).toFixed(1)}s` : '—'}</span>
|
|
177
|
+
<span>INP {insight.inpMs != null ? `${insight.inpMs}ms` : '—'}</span>
|
|
178
|
+
<span>CLS {insight.cls != null ? insight.cls : '—'}</span>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -144,6 +144,46 @@ export async function fetchSearchPerformance(
|
|
|
144
144
|
return res.data?.rows ?? []
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// ─── Per-document search insight (synced GSC + CrUX) ─────────────────
|
|
148
|
+
|
|
149
|
+
export interface SearchInsightTopQuery {
|
|
150
|
+
query: string
|
|
151
|
+
clicks: number
|
|
152
|
+
impressions: number
|
|
153
|
+
position: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface SearchInsight {
|
|
157
|
+
path: string
|
|
158
|
+
clicks28d: number
|
|
159
|
+
impressions28d: number
|
|
160
|
+
avgPosition28d: number | null
|
|
161
|
+
clicksPrev28d: number
|
|
162
|
+
impressionsPrev28d: number
|
|
163
|
+
avgPositionPrev28d: number | null
|
|
164
|
+
topQueries: SearchInsightTopQuery[]
|
|
165
|
+
lcpMs: number | null
|
|
166
|
+
inpMs: number | null
|
|
167
|
+
cls: number | null
|
|
168
|
+
cwvStatus: 'good' | 'needs-improvement' | 'poor' | null
|
|
169
|
+
lastSyncedAt: string | null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Synced search stats for one document. `insight` is null when Search Console
|
|
174
|
+
* isn't connected or no data has synced for the page yet — the editor pane
|
|
175
|
+
* renders a quiet hint instead of an error in that case.
|
|
176
|
+
*/
|
|
177
|
+
export async function fetchSearchInsight(
|
|
178
|
+
entityId: string,
|
|
179
|
+
): Promise<{ connected: boolean; insight: SearchInsight | null }> {
|
|
180
|
+
const res = await cmsApi<{ connected: boolean; insight: SearchInsight | null }>(
|
|
181
|
+
`/seo/search-insights/${encodeURIComponent(entityId)}`,
|
|
182
|
+
)
|
|
183
|
+
throwIfError(res)
|
|
184
|
+
return res.data ?? { connected: false, insight: null }
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
// ─── Content SEO ─────────────────────────────────────────────────────
|
|
148
188
|
|
|
149
189
|
/** Schema.org types available in document SEO editors. */
|
|
@@ -995,12 +1035,24 @@ export async function saveRedirectRecoverySettings(
|
|
|
995
1035
|
export async function generate404RecoverySuggestions(): Promise<{
|
|
996
1036
|
error?: string
|
|
997
1037
|
autoApplied?: number
|
|
1038
|
+
/**
|
|
1039
|
+
* Set when AI governance vetoed a requested auto-apply (eligible plans were
|
|
1040
|
+
* stored as pending suggestions instead). Human-readable reason to surface.
|
|
1041
|
+
*/
|
|
1042
|
+
governanceBlocked?: string
|
|
998
1043
|
}> {
|
|
999
|
-
const res = await cmsApi<{
|
|
1044
|
+
const res = await cmsApi<{
|
|
1045
|
+
created?: number
|
|
1046
|
+
autoApplied?: number
|
|
1047
|
+
governanceBlocked?: string | null
|
|
1048
|
+
}>('/seo/redirects/suggest', {
|
|
1000
1049
|
method: 'POST',
|
|
1001
1050
|
})
|
|
1002
1051
|
if (res.error) return { error: res.error }
|
|
1003
|
-
return {
|
|
1052
|
+
return {
|
|
1053
|
+
autoApplied: res.data?.autoApplied ?? 0,
|
|
1054
|
+
governanceBlocked: res.data?.governanceBlocked ?? undefined,
|
|
1055
|
+
}
|
|
1004
1056
|
}
|
|
1005
1057
|
|
|
1006
1058
|
export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
|
|
@@ -1047,6 +1099,12 @@ export interface SeoAiResult {
|
|
|
1047
1099
|
brandAlignment?: BrandAlignmentScoreResult
|
|
1048
1100
|
/** True when the AI provider is not configured (graceful fallback). */
|
|
1049
1101
|
unavailable?: boolean
|
|
1102
|
+
/**
|
|
1103
|
+
* Server-reported refusal (daily request cap, monthly budget, disabled
|
|
1104
|
+
* feature). Surface verbatim — the runtime crafts these messages to tell the
|
|
1105
|
+
* user which limit was hit and where to raise it.
|
|
1106
|
+
*/
|
|
1107
|
+
error?: string
|
|
1050
1108
|
}
|
|
1051
1109
|
|
|
1052
1110
|
/**
|
|
@@ -1073,6 +1131,7 @@ export async function generateSeoField(
|
|
|
1073
1131
|
body: JSON.stringify({ field, ...context }),
|
|
1074
1132
|
})
|
|
1075
1133
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1134
|
+
if (res.error) return { error: res.error }
|
|
1076
1135
|
return res.data ?? {}
|
|
1077
1136
|
}
|
|
1078
1137
|
|
|
@@ -1082,6 +1141,7 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
|
|
|
1082
1141
|
body: JSON.stringify({ issueId }),
|
|
1083
1142
|
})
|
|
1084
1143
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1144
|
+
if (res.error) return { error: res.error }
|
|
1085
1145
|
return res.data ?? {}
|
|
1086
1146
|
}
|
|
1087
1147
|
|
|
@@ -1095,12 +1155,14 @@ export async function generateStructuredData(context: {
|
|
|
1095
1155
|
body: JSON.stringify(context),
|
|
1096
1156
|
})
|
|
1097
1157
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1158
|
+
if (res.error) return { error: res.error }
|
|
1098
1159
|
return res.data ?? {}
|
|
1099
1160
|
}
|
|
1100
1161
|
|
|
1101
1162
|
export async function summarizeSeoAudit(): Promise<SeoAiResult> {
|
|
1102
1163
|
const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
|
|
1103
1164
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1165
|
+
if (res.error) return { error: res.error }
|
|
1104
1166
|
return res.data ?? {}
|
|
1105
1167
|
}
|
|
1106
1168
|
|
|
@@ -200,7 +200,7 @@ export function OverviewTab({
|
|
|
200
200
|
<SeoEmptyState
|
|
201
201
|
icon={<BarChart3 size={24} />}
|
|
202
202
|
title="Search Console not connected"
|
|
203
|
-
description="
|
|
203
|
+
description="Connect a Google service account in Settings → SEO → Search data, then run a sync. Real clicks, impressions, and positions will appear here."
|
|
204
204
|
/>
|
|
205
205
|
) : searchPerformance.length === 0 ? (
|
|
206
206
|
<SeoEmptyState title="No search performance data" />
|
|
@@ -172,6 +172,12 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
|
|
|
172
172
|
? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
|
|
173
173
|
: ''
|
|
174
174
|
toast.success(`Generated 404 recovery suggestions.${autoMsg}`)
|
|
175
|
+
if (res.governanceBlocked) {
|
|
176
|
+
// AI governance vetoed the auto-apply (eligible plans were stored as
|
|
177
|
+
// pending suggestions). Tell the operator why, since the SEO module's
|
|
178
|
+
// "Auto-apply" toggle alone no longer decides.
|
|
179
|
+
toast.info(`Auto-apply skipped: ${res.governanceBlocked}`)
|
|
180
|
+
}
|
|
175
181
|
refetch()
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -122,6 +122,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
|
|
|
122
122
|
canEdit={canEdit}
|
|
123
123
|
onToggle={ai.toggleFeature}
|
|
124
124
|
onSetFeatureModel={ai.setFeatureModel}
|
|
125
|
+
onSetGovernance={ai.setFeatureGovernance}
|
|
125
126
|
/>
|
|
126
127
|
<BrandVoiceCard
|
|
127
128
|
value={ai.form.brandVoice}
|
|
@@ -13,8 +13,8 @@ const SELECT_CLASS =
|
|
|
13
13
|
* Global AI feature toggles with per-feature model routing. Each switch persists
|
|
14
14
|
* (via the Save bar) to the shared `ai.features.*` flags; the optional model
|
|
15
15
|
* picker overrides which model that feature uses (otherwise it inherits the
|
|
16
|
-
* default).
|
|
17
|
-
* and
|
|
16
|
+
* default). The governance flags (approval, bulk apply, daily cap) are enforced
|
|
17
|
+
* by the governed AI runtime and the auto-apply gates in core.
|
|
18
18
|
*/
|
|
19
19
|
export function AiFeaturesCard({
|
|
20
20
|
features,
|
|
@@ -23,6 +23,7 @@ export function AiFeaturesCard({
|
|
|
23
23
|
canEdit,
|
|
24
24
|
onToggle,
|
|
25
25
|
onSetFeatureModel,
|
|
26
|
+
onSetGovernance,
|
|
26
27
|
}: {
|
|
27
28
|
features: AiFeatureView[]
|
|
28
29
|
models: AiModelView[]
|
|
@@ -30,6 +31,14 @@ export function AiFeaturesCard({
|
|
|
30
31
|
canEdit: boolean
|
|
31
32
|
onToggle: (key: string, enabled: boolean) => void
|
|
32
33
|
onSetFeatureModel: (key: string, modelId: string) => void
|
|
34
|
+
onSetGovernance: (
|
|
35
|
+
key: string,
|
|
36
|
+
patch: Partial<{
|
|
37
|
+
approvalRequired: boolean
|
|
38
|
+
allowBulkApply: boolean
|
|
39
|
+
maxRequestsPerDay: string
|
|
40
|
+
}>,
|
|
41
|
+
) => void
|
|
33
42
|
}) {
|
|
34
43
|
return (
|
|
35
44
|
<SettingsCard
|
|
@@ -49,6 +58,7 @@ export function AiFeaturesCard({
|
|
|
49
58
|
canEdit={canEdit}
|
|
50
59
|
onToggle={onToggle}
|
|
51
60
|
onSetFeatureModel={onSetFeatureModel}
|
|
61
|
+
onSetGovernance={onSetGovernance}
|
|
52
62
|
/>
|
|
53
63
|
))}
|
|
54
64
|
</div>
|
|
@@ -64,6 +74,7 @@ function FeatureRow({
|
|
|
64
74
|
canEdit,
|
|
65
75
|
onToggle,
|
|
66
76
|
onSetFeatureModel,
|
|
77
|
+
onSetGovernance,
|
|
67
78
|
}: {
|
|
68
79
|
feature: AiFeatureView
|
|
69
80
|
models: AiModelView[]
|
|
@@ -71,8 +82,17 @@ function FeatureRow({
|
|
|
71
82
|
canEdit: boolean
|
|
72
83
|
onToggle: (key: string, enabled: boolean) => void
|
|
73
84
|
onSetFeatureModel: (key: string, modelId: string) => void
|
|
85
|
+
onSetGovernance: (
|
|
86
|
+
key: string,
|
|
87
|
+
patch: Partial<{
|
|
88
|
+
approvalRequired: boolean
|
|
89
|
+
allowBulkApply: boolean
|
|
90
|
+
maxRequestsPerDay: string
|
|
91
|
+
}>,
|
|
92
|
+
) => void
|
|
74
93
|
}) {
|
|
75
94
|
const selectId = useId()
|
|
95
|
+
const capId = useId()
|
|
76
96
|
const required = feature.requiredCapabilities ?? []
|
|
77
97
|
const effective = effectiveFeatureModel(feature.modelId, defaultModelId, required, models)
|
|
78
98
|
const defaultModel = models.find(
|
|
@@ -141,6 +161,43 @@ function FeatureRow({
|
|
|
141
161
|
needs. Pick a compatible model.
|
|
142
162
|
</p>
|
|
143
163
|
)}
|
|
164
|
+
|
|
165
|
+
<div className="border-border mt-4 space-y-3 border-t pt-3">
|
|
166
|
+
<SettingsToggleRow
|
|
167
|
+
label="Require approval"
|
|
168
|
+
description="AI output must be human-approved before it is applied."
|
|
169
|
+
checked={feature.approvalRequired}
|
|
170
|
+
disabled={!canEdit}
|
|
171
|
+
onChange={(v) => onSetGovernance(feature.key, { approvalRequired: v })}
|
|
172
|
+
/>
|
|
173
|
+
<SettingsToggleRow
|
|
174
|
+
label="Allow bulk apply"
|
|
175
|
+
description="Permit applying many AI results in one operation."
|
|
176
|
+
checked={feature.allowBulkApply}
|
|
177
|
+
disabled={!canEdit}
|
|
178
|
+
onChange={(v) => onSetGovernance(feature.key, { allowBulkApply: v })}
|
|
179
|
+
/>
|
|
180
|
+
<div>
|
|
181
|
+
<label htmlFor={capId} className="text-muted-foreground mb-1 block text-sm">
|
|
182
|
+
Max requests per day
|
|
183
|
+
</label>
|
|
184
|
+
<input
|
|
185
|
+
id={capId}
|
|
186
|
+
type="text"
|
|
187
|
+
inputMode="numeric"
|
|
188
|
+
value={feature.maxRequestsPerDay}
|
|
189
|
+
placeholder="Unlimited"
|
|
190
|
+
disabled={!canEdit}
|
|
191
|
+
onChange={(e) =>
|
|
192
|
+
onSetGovernance(feature.key, { maxRequestsPerDay: e.target.value })
|
|
193
|
+
}
|
|
194
|
+
className={SELECT_CLASS}
|
|
195
|
+
/>
|
|
196
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
197
|
+
Site-wide daily cap for this feature. Leave empty for unlimited.
|
|
198
|
+
</p>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
144
201
|
</div>
|
|
145
202
|
)}
|
|
146
203
|
</div>
|