@prenta/admin 1.10.1 → 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 +32 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -1
- package/dist/AdminRoot.js.map +1 -1
- 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/page-editor-service.test.js +23 -0
- package/dist/__tests__/lib/page-editor-service.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/editor-seo-content.d.ts.map +1 -1
- package/dist/lib/editor-seo-content.js +2 -2
- package/dist/lib/editor-seo-content.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +2 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +3 -3
- package/dist/lib/post-editor-service.js.map +1 -1
- 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/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +3 -2
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +5 -1
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/section-types.d.ts +1 -1
- package/dist/views/page-editor/section-types.d.ts.map +1 -1
- package/dist/views/page-editor/section-types.js +1 -1
- package/dist/views/page-editor/section-types.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +2 -2
- package/dist/views/page-editor/sections/SectionRenderer.js.map +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/AdminRoot.tsx +4 -0
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
- package/src/__tests__/lib/page-editor-service.test.ts +23 -0
- 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/editor-seo-content.ts +6 -2
- package/src/lib/page-editor-service.ts +2 -1
- package/src/lib/post-editor-service.ts +5 -2
- package/src/lib/seo-service.ts +64 -2
- package/src/views/page-editor/SectionInspector.tsx +23 -20
- package/src/views/page-editor/SectionsPanel.tsx +27 -17
- package/src/views/page-editor/section-types.ts +2 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +2 -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
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
|
|
|
@@ -8,7 +8,7 @@ import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
|
|
|
8
8
|
import { RichTextField } from '../../fields/RichTextField.js'
|
|
9
9
|
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
10
10
|
import type { SectionSettings } from './section-types.js'
|
|
11
|
-
import { getSectionType } from './section-types.js'
|
|
11
|
+
import { getSectionType, isSectionTypeHideable } from './section-types.js'
|
|
12
12
|
|
|
13
13
|
interface SectionInspectorProps {
|
|
14
14
|
section: PageSection
|
|
@@ -62,6 +62,7 @@ export function SectionInspector({
|
|
|
62
62
|
onPostBodyChange,
|
|
63
63
|
}: SectionInspectorProps) {
|
|
64
64
|
const def = getSectionType(section.sectionType)
|
|
65
|
+
const showVisibilityToggle = !def || isSectionTypeHideable(def)
|
|
65
66
|
const bodySource = String(section.content.source ?? 'field')
|
|
66
67
|
const editPostBodyField =
|
|
67
68
|
section.sectionType === 'article-body' &&
|
|
@@ -193,25 +194,27 @@ export function SectionInspector({
|
|
|
193
194
|
onChange={(e) => onMetaChange({ internalLabel: e.target.value })}
|
|
194
195
|
/>
|
|
195
196
|
</div>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
197
|
+
{showVisibilityToggle && (
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={onToggleVisibility}
|
|
201
|
+
disabled={!canEdit}
|
|
202
|
+
aria-pressed={!section.visible}
|
|
203
|
+
className="border-border text-foreground hover:bg-accent flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm disabled:opacity-50"
|
|
204
|
+
>
|
|
205
|
+
<span className="flex items-center gap-2">
|
|
206
|
+
{section.visible ? (
|
|
207
|
+
<Eye className="h-4 w-4" aria-hidden />
|
|
208
|
+
) : (
|
|
209
|
+
<EyeOff className="h-4 w-4" aria-hidden />
|
|
210
|
+
)}
|
|
211
|
+
{section.visible ? 'Visible on page' : 'Hidden from page'}
|
|
212
|
+
</span>
|
|
213
|
+
<span className="text-muted-foreground text-xs">
|
|
214
|
+
{section.visible ? 'Hide' : 'Show'}
|
|
215
|
+
</span>
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
215
218
|
</fieldset>
|
|
216
219
|
|
|
217
220
|
{/* Appearance */}
|
|
@@ -32,6 +32,11 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
|
32
32
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
33
33
|
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
34
34
|
import { sectionIconFor } from './section-icons.js'
|
|
35
|
+
import {
|
|
36
|
+
getSectionType,
|
|
37
|
+
isSectionEffectivelyVisible,
|
|
38
|
+
isSectionTypeHideable,
|
|
39
|
+
} from './section-types.js'
|
|
35
40
|
|
|
36
41
|
interface SectionsPanelProps {
|
|
37
42
|
sections: PageSection[]
|
|
@@ -290,6 +295,9 @@ function SectionRow({
|
|
|
290
295
|
// to view the read-only notice.
|
|
291
296
|
const locked = section.unmanaged === true
|
|
292
297
|
const Icon = sectionIconFor(section.sectionType)
|
|
298
|
+
const typeDef = getSectionType(section.sectionType)
|
|
299
|
+
const showVisibilityToggle = !typeDef || isSectionTypeHideable(typeDef)
|
|
300
|
+
const effectivelyVisible = isSectionEffectivelyVisible(section)
|
|
293
301
|
|
|
294
302
|
return (
|
|
295
303
|
<div
|
|
@@ -314,7 +322,7 @@ function SectionRow({
|
|
|
314
322
|
<Icon className="h-4 w-4" aria-hidden />
|
|
315
323
|
</span>
|
|
316
324
|
<span
|
|
317
|
-
className={`truncate text-sm ${
|
|
325
|
+
className={`truncate text-sm ${effectivelyVisible ? 'text-foreground' : 'text-muted-foreground line-through'}`}
|
|
318
326
|
>
|
|
319
327
|
{section.name}
|
|
320
328
|
</span>
|
|
@@ -330,22 +338,24 @@ function SectionRow({
|
|
|
330
338
|
</span>
|
|
331
339
|
) : (
|
|
332
340
|
<>
|
|
333
|
-
{/* Visibility survives the design-owned lockdown
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
341
|
+
{/* Visibility survives the design-owned lockdown for hideable types.
|
|
342
|
+
hideable: false types omit the control entirely. */}
|
|
343
|
+
{showVisibilityToggle && (
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
onClick={() => onToggleVisibility(section.id)}
|
|
347
|
+
disabled={!canEdit}
|
|
348
|
+
aria-pressed={!section.visible}
|
|
349
|
+
aria-label={section.visible ? `Hide ${section.name}` : `Show ${section.name}`}
|
|
350
|
+
className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
|
|
351
|
+
>
|
|
352
|
+
{section.visible ? (
|
|
353
|
+
<Eye className="h-4 w-4" aria-hidden />
|
|
354
|
+
) : (
|
|
355
|
+
<EyeOff className="h-4 w-4" aria-hidden />
|
|
356
|
+
)}
|
|
357
|
+
</button>
|
|
358
|
+
)}
|
|
349
359
|
|
|
350
360
|
{/* Disclosure. The row itself already toggles, but a visible chevron
|
|
351
361
|
is what says the row opens at all — without one the list looks
|
|
@@ -4,7 +4,7 @@ import { Puzzle } from 'lucide-react'
|
|
|
4
4
|
import { fieldPathFromEventTarget } from '../../../lib/preview-bridge.js'
|
|
5
5
|
import type { PageSection } from '../../../lib/page-editor-service.js'
|
|
6
6
|
import { ErrorBoundary } from '../../../components/ErrorBoundary.js'
|
|
7
|
-
import { getSectionType } from '../section-types.js'
|
|
7
|
+
import { getSectionType, isSectionEffectivelyVisible } from '../section-types.js'
|
|
8
8
|
import { ADMIN_RENDER_OPTIONS, useAdminSectionRenderers } from './admin-renderers.js'
|
|
9
9
|
import { backgroundClass, paddingClass, type PostRenderContext } from './parts.js'
|
|
10
10
|
import { HeroBannerSection } from './HeroBannerSection.js'
|
|
@@ -204,7 +204,7 @@ export function SectionRenderer({
|
|
|
204
204
|
// the editor's own view of it, and a hidden section that rendered solid here
|
|
205
205
|
// would misreport what was live at that moment.
|
|
206
206
|
const chromed = editable || reviewing
|
|
207
|
-
const hidden = chromed && !section
|
|
207
|
+
const hidden = chromed && !isSectionEffectivelyVisible(section)
|
|
208
208
|
// Review mode is read-only. Click-to-select would edit the CURRENT page
|
|
209
209
|
// while the canvas shows a past one.
|
|
210
210
|
const interactive = editable && !reviewing
|
|
@@ -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>
|