From 43e01e761332631989d4213a9eaa5b2c732995b9 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 21:56:05 +0800 Subject: [PATCH] feat: expose canvas agent rollout controls --- .../settings/admin-settings-client.tsx | 202 ++++++++++++++++++ app/api/site-settings/route.ts | 110 ++++++++++ lib/canvas/agent-feature-flags.ts | 28 +++ tests/routes/canvas-chat-helpers.test.ts | 24 +++ 4 files changed, 364 insertions(+) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 548f74a14..615d45e19 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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) + : {}; + 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() { /> +
+
+ +
+ 控制对话框 Agent 是否通过受控协议写回画布,以及 + slot、视角和标注编辑能力的开放范围。 +
+
+
+
+
+ +
+ 关闭后不执行 CanvasOperationPlan,也不输出 shadow + 操作。 +
+
+ + setSiteSettings({ + ...siteSettings, + canvasAgentFeatureSettings: { + ...siteSettings.canvasAgentFeatureSettings, + operationProtocolEnabled: checked, + }, + }) + } + /> +
+ +
+ + +
+ Shadow 只记录操作计划;权威写回会直接调用 mutation + API 更新画布。 +
+
+ +
+
+ +
+ 允许 Agent 创建或填充画布生成槽位。 +
+
+ + setSiteSettings({ + ...siteSettings, + canvasAgentFeatureSettings: { + ...siteSettings.canvasAgentFeatureSettings, + slotEnabled: checked, + }, + }) + } + /> +
+ +
+
+ +
+ 允许 Agent 临时缩放、平移并聚焦当前编辑对象。 +
+
+ + setSiteSettings({ + ...siteSettings, + canvasAgentFeatureSettings: { + ...siteSettings.canvasAgentFeatureSettings, + viewOperationEnabled: checked, + }, + }) + } + /> +
+ +
+
+ +
+ 允许 Agent 读取画布批注、备注和框选区域并生成编辑任务。 +
+
+ + setSiteSettings({ + ...siteSettings, + canvasAgentFeatureSettings: { + ...siteSettings.canvasAgentFeatureSettings, + annotationEditEnabled: checked, + }, + }) + } + /> +
+
+
+
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 961334d99..7b7a7e3c2 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -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 +): 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( + 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 diff --git a/lib/canvas/agent-feature-flags.ts b/lib/canvas/agent-feature-flags.ts index 869946597..118a4bb53 100644 --- a/lib/canvas/agent-feature-flags.ts +++ b/lib/canvas/agent-feature-flags.ts @@ -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 && diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 00ede5be0..8581d0e88 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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" },