feat: expose canvas agent rollout controls

This commit is contained in:
134 2026-06-23 21:56:05 +08:00
parent fae9d32ffb
commit 43e01e7613
4 changed files with 364 additions and 0 deletions

View File

@ -204,6 +204,7 @@ import {
getDefaultCanvasToolbarItem,
normalizeCanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import type { CanvasAgentFeatureSettings } from "@/lib/canvas/agent-feature-flags";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
AI_WASH_CONFIG_KEY,
@ -1147,6 +1148,7 @@ type SiteSettings = {
canvasEnabled: boolean;
agentEnabled: boolean;
canvasAgentModeEnabled: boolean;
canvasAgentFeatureSettings: CanvasAgentFeatureSettings;
videoCanvasEnabled: boolean;
videoCanvasSidebarEnabled: boolean;
agentCategoryTreeConfig: {
@ -1225,6 +1227,43 @@ type HomeAnimationConfig = {
updatedAt: string | null;
};
const defaultCanvasAgentFeatureSettings: CanvasAgentFeatureSettings = {
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: true,
annotationEditEnabled: true,
};
function normalizeCanvasAgentFeatureSettingsForClient(
value: unknown
): CanvasAgentFeatureSettings {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<CanvasAgentFeatureSettings>)
: {};
const writebackMode =
raw.writebackMode === "legacy_canvas_media" ||
raw.writebackMode === "operation_shadow" ||
raw.writebackMode === "operation_authoritative"
? raw.writebackMode
: defaultCanvasAgentFeatureSettings.writebackMode;
return {
operationProtocolEnabled:
raw.operationProtocolEnabled ??
defaultCanvasAgentFeatureSettings.operationProtocolEnabled,
writebackMode,
slotEnabled: raw.slotEnabled ?? defaultCanvasAgentFeatureSettings.slotEnabled,
viewOperationEnabled:
raw.viewOperationEnabled ??
defaultCanvasAgentFeatureSettings.viewOperationEnabled,
annotationEditEnabled:
raw.annotationEditEnabled ??
defaultCanvasAgentFeatureSettings.annotationEditEnabled,
};
}
const defaultLovartPromptTagVisibility: LovartPromptTagVisibility = {
nanoBananaPro: true,
design: true,
@ -2102,6 +2141,7 @@ export function AdminSettingsClient() {
canvasEnabled: true,
agentEnabled: true,
canvasAgentModeEnabled: true,
canvasAgentFeatureSettings: defaultCanvasAgentFeatureSettings,
videoCanvasEnabled: true,
videoCanvasSidebarEnabled: true,
agentCategoryTreeConfig: {
@ -2223,6 +2263,9 @@ export function AdminSettingsClient() {
agentEnabled: (siteSettingsData as any).agentEnabled ?? true,
canvasAgentModeEnabled:
(siteSettingsData as any).canvasAgentModeEnabled ?? true,
canvasAgentFeatureSettings: normalizeCanvasAgentFeatureSettingsForClient(
(siteSettingsData as any).canvasAgentFeatureSettings
),
videoCanvasEnabled:
(siteSettingsData as any).videoCanvasEnabled ??
(siteSettingsData as any).videoCanvasSidebarEnabled ??
@ -12172,6 +12215,165 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<Label>Canvas Agent 操作协议灰度</Label>
<div className="text-muted-foreground text-xs">
控制对话框 Agent 是否通过受控协议写回画布,以及
slot、视角和标注编辑能力的开放范围。
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="canvasAgentOperationProtocolEnabled">
操作协议
</Label>
<div className="text-muted-foreground text-xs">
关闭后不执行 CanvasOperationPlan,也不输出 shadow
操作。
</div>
</div>
<Switch
checked={
siteSettings.canvasAgentFeatureSettings
.operationProtocolEnabled
}
id="canvasAgentOperationProtocolEnabled"
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
canvasAgentFeatureSettings: {
...siteSettings.canvasAgentFeatureSettings,
operationProtocolEnabled: checked,
},
})
}
/>
</div>
<div className="space-y-2 rounded-lg border p-3">
<Label htmlFor="canvasAgentWritebackMode">
写回模式
</Label>
<Select
onValueChange={(value) =>
setSiteSettings({
...siteSettings,
canvasAgentFeatureSettings: {
...siteSettings.canvasAgentFeatureSettings,
writebackMode:
value as CanvasAgentFeatureSettings["writebackMode"],
},
})
}
value={
siteSettings.canvasAgentFeatureSettings.writebackMode
}
>
<SelectTrigger id="canvasAgentWritebackMode">
<SelectValue placeholder="选择写回模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="legacy_canvas_media">
旧 canvasMedia
</SelectItem>
<SelectItem value="operation_shadow">
Shadow 记录
</SelectItem>
<SelectItem value="operation_authoritative">
Operation 权威写回
</SelectItem>
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
Shadow 只记录操作计划;权威写回会直接调用 mutation
API 更新画布。
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="canvasAgentSlotEnabled">
Agent Slot
</Label>
<div className="text-muted-foreground text-xs">
允许 Agent 创建或填充画布生成槽位。
</div>
</div>
<Switch
checked={
siteSettings.canvasAgentFeatureSettings.slotEnabled
}
id="canvasAgentSlotEnabled"
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
canvasAgentFeatureSettings: {
...siteSettings.canvasAgentFeatureSettings,
slotEnabled: checked,
},
})
}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="canvasAgentViewOperationEnabled">
视角控制
</Label>
<div className="text-muted-foreground text-xs">
允许 Agent 临时缩放、平移并聚焦当前编辑对象。
</div>
</div>
<Switch
checked={
siteSettings.canvasAgentFeatureSettings
.viewOperationEnabled
}
id="canvasAgentViewOperationEnabled"
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
canvasAgentFeatureSettings: {
...siteSettings.canvasAgentFeatureSettings,
viewOperationEnabled: checked,
},
})
}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3 md:col-span-2">
<div className="space-y-0.5">
<Label htmlFor="canvasAgentAnnotationEditEnabled">
标注编辑
</Label>
<div className="text-muted-foreground text-xs">
允许 Agent 读取画布批注、备注和框选区域并生成编辑任务。
</div>
</div>
<Switch
checked={
siteSettings.canvasAgentFeatureSettings
.annotationEditEnabled
}
id="canvasAgentAnnotationEditEnabled"
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
canvasAgentFeatureSettings: {
...siteSettings.canvasAgentFeatureSettings,
annotationEditEnabled: checked,
},
})
}
/>
</div>
</div>
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">

View File

@ -97,6 +97,19 @@ import {
defaultCanvasToolbarConfig,
normalizeCanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY,
CANVAS_AGENT_FEATURE_SETTING_KEYS,
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY,
CANVAS_AGENT_SLOT_ENABLED_KEY,
CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY,
CANVAS_AGENT_WRITEBACK_MODE_KEY,
canvasAgentFeatureFlagsToSettings,
canvasAgentFeatureSettingsToSystemSettings,
getCanvasAgentFeatureFlags,
type CanvasAgentFeatureSettings,
type CanvasAgentFeatureSettingsLike,
} from "@/lib/canvas/agent-feature-flags";
import {
LOVART_HOME_SIDEBAR_CONFIG_KEY,
defaultLovartHomeSidebarConfig,
@ -453,6 +466,34 @@ function normalizeCanvasAgentModeEnabled(value: unknown): boolean {
return value !== false;
}
function normalizeCanvasAgentFeatureSettings(
settings?: CanvasAgentFeatureSettingsLike | null
): CanvasAgentFeatureSettings {
return canvasAgentFeatureFlagsToSettings(
getCanvasAgentFeatureFlags(process.env, settings)
);
}
function buildCanvasAgentFeatureSettings(
valueByKey: Map<string, unknown>
): CanvasAgentFeatureSettings {
return normalizeCanvasAgentFeatureSettings({
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: valueByKey.get(
CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY
),
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: valueByKey.get(
CANVAS_AGENT_WRITEBACK_MODE_KEY
),
[CANVAS_AGENT_SLOT_ENABLED_KEY]: valueByKey.get(CANVAS_AGENT_SLOT_ENABLED_KEY),
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: valueByKey.get(
CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY
),
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: valueByKey.get(
CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY
),
});
}
function normalizeVideoCanvasEnabled(
value: unknown,
fallbackValue: unknown = true
@ -700,6 +741,7 @@ async function getScopedPublicSiteSettings(
LAYER_SPLIT_CONFIG_KEY,
ANGLE_EDIT_CONFIG_KEY,
CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
@ -721,6 +763,7 @@ async function getScopedPublicSiteSettings(
canvasAgentModeEnabled: normalizeCanvasAgentModeEnabled(
valueByKey.get(CANVAS_AGENT_MODE_ENABLED_KEY)
),
canvasAgentFeatureSettings: buildCanvasAgentFeatureSettings(valueByKey),
videoCanvasEnabled: normalizeVideoCanvasEnabled(
valueByKey.get(VIDEO_CANVAS_ENABLED_KEY),
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
@ -787,6 +830,7 @@ async function getScopedPublicSiteSettings(
LAYER_SPLIT_CONFIG_KEY,
ANGLE_EDIT_CONFIG_KEY,
CANVAS_AGENT_MODE_ENABLED_KEY,
...CANVAS_AGENT_FEATURE_SETTING_KEYS,
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
@ -805,6 +849,7 @@ async function getScopedPublicSiteSettings(
canvasAgentModeEnabled: normalizeCanvasAgentModeEnabled(
valueByKey.get(CANVAS_AGENT_MODE_ENABLED_KEY)
),
canvasAgentFeatureSettings: buildCanvasAgentFeatureSettings(valueByKey),
videoCanvasEnabled: normalizeVideoCanvasEnabled(
valueByKey.get(VIDEO_CANVAS_ENABLED_KEY),
valueByKey.get(VIDEO_CANVAS_SIDEBAR_ENABLED_KEY)
@ -1054,6 +1099,7 @@ export async function GET(request: NextRequest) {
lovartHomeSidebarConfigSetting,
layerSplitConfigSetting,
canvasAgentModeEnabledSetting,
canvasAgentFeatureSettingsRows,
videoCanvasEnabledSetting,
videoCanvasSidebarEnabledSetting,
storageFailoverConfigSetting,
@ -1180,6 +1226,10 @@ export async function GET(request: NextRequest) {
.where(eq(systemSettings.key, CANVAS_AGENT_MODE_ENABLED_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select({ key: systemSettings.key, value: systemSettings.value })
.from(systemSettings)
.where(inArray(systemSettings.key, [...CANVAS_AGENT_FEATURE_SETTING_KEYS])),
db
.select()
.from(systemSettings)
@ -1314,6 +1364,12 @@ export async function GET(request: NextRequest) {
const canvasAgentModeEnabled = normalizeCanvasAgentModeEnabled(
canvasAgentModeEnabledSetting?.value
);
const canvasAgentFeatureValueByKey = new Map<string, unknown>(
canvasAgentFeatureSettingsRows.map((row) => [row.key, row.value])
);
const canvasAgentFeatureSettings = buildCanvasAgentFeatureSettings(
canvasAgentFeatureValueByKey
);
const videoCanvasSidebarEnabled = normalizeVideoCanvasSidebarEnabled(
videoCanvasSidebarEnabledSetting?.value
);
@ -1397,6 +1453,7 @@ export async function GET(request: NextRequest) {
canvasEnabled: true,
agentEnabled: true,
canvasAgentModeEnabled: true,
canvasAgentFeatureSettings,
videoCanvasEnabled: true,
videoCanvasSidebarEnabled: true,
agentPresetLibraryConfig: defaultAgentPresetLibraryConfig,
@ -1482,6 +1539,7 @@ export async function GET(request: NextRequest) {
lovartHomeSidebarConfig,
layerSplitEnabled,
canvasAgentModeEnabled,
canvasAgentFeatureSettings,
videoCanvasEnabled,
videoCanvasSidebarEnabled,
storageFailoverConfig,
@ -1576,6 +1634,19 @@ const updateSchema = z.object({
canvasEnabled: z.boolean().optional(),
agentEnabled: z.boolean().optional(),
canvasAgentModeEnabled: z.boolean().optional(),
canvasAgentFeatureSettings: z
.object({
operationProtocolEnabled: z.boolean(),
writebackMode: z.enum([
"legacy_canvas_media",
"operation_shadow",
"operation_authoritative",
]),
slotEnabled: z.boolean(),
viewOperationEnabled: z.boolean(),
annotationEditEnabled: z.boolean(),
})
.optional(),
videoCanvasEnabled: z.boolean().optional(),
videoCanvasSidebarEnabled: z.boolean().optional(),
agentPresetLibraryConfig: z
@ -2064,6 +2135,7 @@ export async function PUT(request: NextRequest) {
canvasToolbarConfig,
lovartHomeSidebarConfig,
canvasAgentModeEnabled,
canvasAgentFeatureSettings,
videoCanvasEnabled,
videoCanvasSidebarEnabled,
homeAnimationConfig,
@ -2332,6 +2404,40 @@ export async function PUT(request: NextRequest) {
});
};
const persistCanvasAgentFeatureSettings = async () => {
if (canvasAgentFeatureSettings === undefined) {
return;
}
const normalized = normalizeCanvasAgentFeatureSettings(
canvasAgentFeatureSettingsToSystemSettings(canvasAgentFeatureSettings)
);
const settingEntries = Object.entries(
canvasAgentFeatureSettingsToSystemSettings(normalized)
);
await Promise.all(
settingEntries.map(([key, value]) =>
db
.insert(systemSettings)
.values({
key,
value,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value,
updatedAt: new Date(),
updatedBy: session.user.id,
},
})
)
);
};
await createAgentWorkflowScopeSnapshot();
// 查找现有设置
@ -2489,6 +2595,8 @@ export async function PUT(request: NextRequest) {
});
}
await persistCanvasAgentFeatureSettings();
if (videoCanvasEnabled !== undefined) {
const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled);
await db
@ -3142,6 +3250,8 @@ export async function PUT(request: NextRequest) {
});
}
await persistCanvasAgentFeatureSettings();
if (videoCanvasSidebarEnabled !== undefined) {
const normalized = normalizeVideoCanvasSidebarEnabled(videoCanvasSidebarEnabled);
await db

View File

@ -11,6 +11,8 @@ export type CanvasAgentFeatureFlags = {
annotationEditEnabled: boolean;
};
export type CanvasAgentFeatureSettings = CanvasAgentFeatureFlags;
export const CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY =
"canvas_agent_operation_protocol_enabled";
export const CANVAS_AGENT_WRITEBACK_MODE_KEY = "canvas_agent_writeback_mode";
@ -124,6 +126,32 @@ export function getCanvasAgentFeatureFlags(
return mergeCanvasAgentFeatureFlagsWithSettings(envFlags, settings);
}
export function canvasAgentFeatureFlagsToSettings(
flags: CanvasAgentFeatureFlags
): CanvasAgentFeatureSettings {
return {
operationProtocolEnabled: flags.operationProtocolEnabled,
writebackMode: flags.writebackMode,
slotEnabled: flags.slotEnabled,
viewOperationEnabled: flags.viewOperationEnabled,
annotationEditEnabled: flags.annotationEditEnabled,
};
}
export function canvasAgentFeatureSettingsToSystemSettings(
settings: CanvasAgentFeatureSettings
): CanvasAgentFeatureSettingsLike {
return {
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]:
settings.operationProtocolEnabled,
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: settings.writebackMode,
[CANVAS_AGENT_SLOT_ENABLED_KEY]: settings.slotEnabled,
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: settings.viewOperationEnabled,
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]:
settings.annotationEditEnabled,
};
}
export function canApplyCanvasAgentMutations(flags: CanvasAgentFeatureFlags) {
return (
flags.operationProtocolEnabled &&

View File

@ -134,6 +134,8 @@ import {
CANVAS_AGENT_SLOT_ENABLED_KEY,
CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY,
CANVAS_AGENT_WRITEBACK_MODE_KEY,
canvasAgentFeatureFlagsToSettings,
canvasAgentFeatureSettingsToSystemSettings,
canApplyCanvasAgentMutations,
getCanvasAgentFeatureFlags,
normalizeCanvasAgentBoolean,
@ -1833,6 +1835,28 @@ test.describe("canvas chat pure helpers", () => {
annotationEditEnabled: false,
});
expect(canApplyCanvasAgentMutations(settingsOverride)).toBe(true);
expect(canvasAgentFeatureFlagsToSettings(settingsOverride)).toEqual({
operationProtocolEnabled: true,
writebackMode: "operation_authoritative",
slotEnabled: true,
viewOperationEnabled: false,
annotationEditEnabled: false,
});
expect(
canvasAgentFeatureSettingsToSystemSettings({
operationProtocolEnabled: false,
writebackMode: "operation_shadow",
slotEnabled: true,
viewOperationEnabled: true,
annotationEditEnabled: false,
})
).toEqual({
[CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: false,
[CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_shadow",
[CANVAS_AGENT_SLOT_ENABLED_KEY]: true,
[CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: true,
[CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: false,
});
const invalidSettingsFallback = getCanvasAgentFeatureFlags(
{ CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow" },