fix(agent): retire multi-angle tools and use reference-image generation for viewpoint edits

This commit is contained in:
134 2026-10-21 11:53:31 +08:00
parent bab213abe1
commit 6a73c876cc
9 changed files with 53 additions and 12 deletions

View File

@ -123,7 +123,7 @@ export function getCanvasAgentAccessManifest(
id: capability.id,
workspace: "canvas",
title: capability.title,
authorized: true,
authorized: capability.agent.exposed,
previouslyExposed:
!["image.add_to_chat", "image.layout_replace", "image.download"].includes(
capability.id,
@ -146,7 +146,9 @@ export function getCanvasAgentAccessManifest(
: "server",
entry: capability.agent.toolName || capability.id.replaceAll(".", "_"),
note:
capability.risk === "destructive"
capability.id === "image.multi_angle"
? "旧多视角接口已废弃;转正面、换角度请使用 generateCanvasImages 并传入参考图片节点"
: capability.risk === "destructive"
? "需要用户明确要求及明确目标;模型不能自行确认"
: capability.executionMode === "hybrid"
? "参数齐全时执行,否则打开真实交互面板"
@ -158,11 +160,13 @@ export function getCanvasAgentAccessManifest(
id: `canvas.ui.${item.key}`,
workspace: VIDEO_ONLY_AGENT_TOOLBAR_KEYS.has(item.key) ? "video" : "canvas",
title: `${item.group} · ${item.label}`,
authorized: true,
authorized: item.key !== "imageMultiAngle",
previouslyExposed: false,
execution: "interactive",
entry: "openCanvasFeature",
note: "操作当前可见的真实入口;隐藏入口保持隐藏,文件选择、支付、登录、发布和发送消息由用户完成",
note: item.key === "imageMultiAngle"
? "旧多视角入口已废弃;换角度请使用 generateCanvasImages 并传入参考图片节点"
: "操作当前可见的真实入口;隐藏入口保持隐藏,文件选择、支付、登录、发布和发送消息由用户完成",
}),
),
...IMAGE_CANVAS_OPERATIONS.map(
@ -262,6 +266,6 @@ export function getCanvasAgentAccessManifest(
: undefined,
}),
),
].map(item => ({ ...item, authorized: isZcodeWorkspaceAuthorized(item.workspace),
].map(item => ({ ...item, authorized: item.authorized && isZcodeWorkspaceAuthorized(item.workspace),
...(!isZcodeWorkspaceAuthorized(item.workspace) ? { note: ["用户暂未授权视频画布能力", item.note].filter(Boolean).join(";") } : {}) }));
}

View File

@ -58,7 +58,7 @@ export function registerImageCanvasAccessTools(params: {
const feature = getCanvasAgentAccessManifest().find(
(item) => item.workspace === "canvas" && item.id === input.featureId,
);
if (!feature || !feature.id.startsWith("canvas.ui."))
if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具");
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))

View File

@ -5,6 +5,6 @@ export const LEGACY_AGENT_TOOL_MAP: Record<string, string> = {
analyzeTargetImage:"analyzeCanvasImages", analyzeImage:"analyzeCanvasImages", generateNewImage:"generateCanvasImages", generateImage:"generateCanvasImages",
editTargetImage:"editTargetImage", editImage:"editTargetImage", publishResultToCanvas:"editCanvasNodes", publishToCanvas:"editCanvasNodes",
removeBackground:"removeBackground", removeWatermark:"removeWatermark", enhanceImage:"enhanceImage", extendImage:"extendImage", resizeImage:"resizeImage",
angleEditImage:"angleEditImage", localEditImage:"localEditImage", boxCutout:"boxCutout", useSelectedCanvasImage:"readCanvas",
angleEditImage:"retired:use-generateCanvasImages-with-reference", localEditImage:"localEditImage", boxCutout:"boxCutout", useSelectedCanvasImage:"readCanvas",
useLatestResultImage:"readCanvas", useOriginalUploadImage:"readCanvas",
};

View File

@ -14,6 +14,7 @@ export function canvasRuntimeSystemPrompt(
"用户说第几张、左边、右边时结合画布实际位置和选中顺序定位,不能猜测不存在的节点。",
"图片分析请使用 analyzeCanvasImages,不要把文件名当成图片观察结果。",
"需要新图或背景重绘时用 generateCanvasImages;单纯改尺寸、抠图等优先使用对应工具。",
"转正面、换角度、旋转主体视角也使用 generateCanvasImages,传入真实 referenceNodeIds 和具体目标视角,保持产品结构与其他未要求修改的细节。旧多视角功能和 angleEditImage 已废弃,不得调用旧接口或打开旧面板。",
"最终交付的张数不包括中间产物。保留所有原图,结果放到原图旁边;不要删除、覆盖或偷偷移动原图。",
"同一张图片需要前一步结果时必须等待工具返回;彼此独立的图片可在同轮调用不同工具。",
"严禁声称未实际执行的操作已完成。交互面板已打开、任务已排队都不代表图片已完成。",

View File

@ -178,7 +178,7 @@ export async function buildCanvasRuntimeTools(params: {
register(
"generateCanvasImages",
"生成图片",
"使用本站配置的生图模型生成或修改图片。通过 referenceNodeIds 提供参考图;结果作为新节点放在参考图旁边,保留原图。",
"使用本站配置的生图模型生成或修改图片,包括转正面、换角度、旋转主体视角。通过 referenceNodeIds 提供参考图;结果作为新节点放在参考图旁边,保留原图。",
z.object({
prompt: z.string().min(1).max(8000),
referenceNodeIds: z.array(z.string().min(1).max(180)).max(8).default([]),

View File

@ -57,6 +57,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
"查询项目级能力先用 listProjectServices;技能先 loadProjectSkill。项目工具的任务受理、文件选择与后台缺失不等于处理完成。",
"需要澄清时调用 requestCanvasUserInput;不要使用终端交互。工具返回的 userUpdates 是用户的新补充,必须从下一步纳入。",
"工具错误涉及结果未确认时停止收费动作,不通过更换调用 ID 或另一个工具重复生成。用户明确要求修改的目标才能删除或覆盖。",

View File

@ -275,7 +275,9 @@ export const CANVAS_CAPABILITY_REGISTRY = [
modelPolicy: "feature-setting",
confirmation: "interactive",
inputSchema: angleArgsSchema,
agent: { toolName: "angleEditImage", aliases: ["多视角", "换角度", "旋转视角"] },
// Retained for old document compatibility; new Agent tasks use reference-image generation.
agent: { exposed: false, toolName: "angleEditImage", aliases: [] },
mcp: { exposed: false },
}),
defineCapability({
id: "image.generate_3d_model",

View File

@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { CANVAS_CAPABILITY_IDS } from "@/lib/canvas/capabilities/types";
import { CANVAS_CAPABILITY_REGISTRY } from "@/lib/canvas/capabilities/registry";
import {
@ -104,14 +105,38 @@ function videoHarness(objective = "使用参考图生成一条视频") {
return { state, nodes, generated, events, edits, tools, ports };
}
test("every registered image capability is authorized to the Agent including local and 3D operations", async () => {
expect(CANVAS_CAPABILITY_REGISTRY.every((item) => item.agent.exposed)).toBe(true);
test("supported image capabilities remain authorized while retired multi-angle tools are unavailable", async () => {
expect(CANVAS_CAPABILITY_REGISTRY.filter(item => !item.agent.exposed).map(item => item.id)).toEqual(["image.multi_angle"]);
expect(CANVAS_CAPABILITY_REGISTRY.find(item => item.id === "image.multi_angle")?.mcp.exposed).toBe(false);
expect(CANVAS_CAPABILITY_IDS).toHaveLength(25);
});
test("retired angle tools are absent even with explicit targets and toolbar visibility enabled", async () => {
const load = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/canvas/capabilities/runtime": { executeCanvasCapabilityRuntime: async () => { throw new Error("unexpected execution"); } },
});
const { createCanvasCapabilityTools } = load("lib/canvas/capabilities/ai-sdk-tools.ts");
const tools = await createCanvasCapabilityTools({ projectId: "p", userId: "u", requestUrl: "https://example.test", requestHeaders: new Headers(), selectedNodeIds: ["reference"], allowExplicitTargets: true });
expect(tools.angleEditImage).toBeUndefined();
expect(tools.editTargetImage).toBeDefined();
const { isCanvasCapabilityEnabled } = load("lib/canvas/capabilities/availability.ts");
const { getCanvasCapability } = load("lib/canvas/capabilities/registry.ts");
expect(await isCanvasCapabilityEnabled(getCanvasCapability("image.multi_angle"), "agent")).toBe(false);
});
test("Agent cannot open the retired angle panel through a generic UI command", async () => {
const events: unknown[] = [], definitions: any = {}, executable: any = {};
const load = serverLoader({ "@/lib/canvas/agent-mutations": {}, "./canvas": {} });
load("lib/ai/canvas-agent-runtime/image-access-tools.ts").registerImageCanvasAccessTools({ userId: "u", projectId: "p", runId: "r", selectedNodeIds: ["reference"], definitions, executable, emit: (...args: unknown[]) => events.push(args) });
await expect(executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel")).rejects.toThrow("图片画布界面入口");
expect(events).toHaveLength(0);
});
test("access manifest covers all configured video functions and node/UI actions without granting fake backends", () => {
const manifest = getCanvasAgentAccessManifest();
expect(new Set(manifest.map((item) => item.id)).size).toBe(manifest.length);
expect(manifest.filter(item => item.workspace === "canvas").every(item => item.authorized)).toBe(true);
expect(manifest.filter(item => item.workspace === "canvas" && !item.authorized).map(item => item.id)).toEqual(["image.multi_angle", "canvas.ui.imageMultiAngle"]);
const retired = summarizeAgentCapabilities(manifest).find(item => item.id === "image.multi_angle");
expect(retired).not.toHaveProperty("entry");
expect(manifest.find(item => item.id === "image.multi_angle")?.note).toContain("generateCanvasImages");
expect(manifest.filter(item => item.workspace === "video").every(item => !item.authorized)).toBe(true);
for (const key of Object.keys(defaultVideoCanvasFunctionConfig.functions))
expect(manifest.some((item) => item.id === `video.function.${key}`)).toBe(true);

View File

@ -119,3 +119,11 @@ test("an unavailable planner model recovers automatically with the user's image
expect(h.generated).toHaveLength(1);
expect(h.generated[0]).toMatchObject({ model: "system-default", imageSize: "4K", aspectRatio: "3:4", allowFailover: true, agentImageMode: "auto" });
});
test("front-view edits use reference-image generation with the user's selected model and parameters", async () => {
const h = harness(); const tools = await h.build();
await tools.executable.generateCanvasImages.execute({ prompt: "把柜子角度改为正面,保持柜子结构和材质", referenceNodeIds: ["reference"] }, "front-view");
expect(h.generated).toHaveLength(1);
expect(h.generated[0]).toMatchObject({ prompt: "把柜子角度改为正面,保持柜子结构和材质", referenceImages: ["https://test.example/reference.png"], model: "user-image-model", aspectRatio: "3:4", imageSize: "4K", imageCount: 3, allowFailover: false });
expect(h.operations[0].plan.preserveOriginal).toBe(true);
});