feat: route canvas agent angle variants

This commit is contained in:
134 2026-06-23 23:59:24 +08:00
parent 4588850572
commit 96827c760c
11 changed files with 332 additions and 14 deletions

View File

@ -81,6 +81,7 @@ export type AgentPreferredCapability =
| "enhance"
| "extend"
| "resize"
| "angle_variant"
| "box_cutout"
| "generate_image"
| "generate_video"
@ -251,6 +252,7 @@ function inferPreferredCapability(
if (firstTool === "enhanceImage") return "enhance";
if (firstTool === "extendImage") return "extend";
if (firstTool === "resizeImage") return "resize";
if (firstTool === "angleEditImage") return "angle_variant";
if (firstTool === "boxCutout") return "box_cutout";
if (firstTool === "publishToCanvas" || firstTool === "publishResultToCanvas") return "publish_canvas";
if (firstTool === "generateVideo") return "generate_video";
@ -273,6 +275,9 @@ function inferPreferredCapability(
return "extend";
case "resize":
return "resize";
case "angle_variant":
case "generate_angle_variant":
return "angle_variant";
default:
return "generic_edit";
}
@ -1403,6 +1408,7 @@ function sanitizeAgentPlan(
"enhance",
"extend",
"resize",
"angle_variant",
"box_cutout",
"generate_image",
"generate_video",
@ -1584,7 +1590,7 @@ async function buildExecutionPlanWithAgent(
5. 优先保持一次只选择一个主工具;toolCalls 只从提供的工具列表里选。
6. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
7. targetSource 要忠实反映当前目标图来源:explicit_selection/current_upload/text_reference/working_memory/latest_result/clarification/none。
8. preferredCapability 要尽量具体,例如 remove_background / remove_watermark / enhance / extend / resize / box_cutout / publish_canvas。
8. preferredCapability 要尽量具体,例如 remove_background / remove_watermark / enhance / extend / resize / angle_variant / box_cutout / publish_canvas。
9. 必须优先使用新的强语义工具名:resolveTargetImage / analyzeTargetImage / editTargetImage / generateNewImage / publishResultToCanvas。
10. 必须遵守当前专家路由约束;如果约束要求纯对话,则绝不能输出 generate_image / edit_image / generate_video。
@ -1595,7 +1601,7 @@ async function buildExecutionPlanWithAgent(
"intent": "chat|vision|image_edit|image_gen|video_gen",
"subIntent": "可选,仅 image_edit 时填写",
"targetSource": "explicit_selection|current_upload|text_reference|working_memory|latest_result|clarification|none",
"preferredCapability": "respond|analyze|generic_edit|remove_background|remove_watermark|enhance|extend|resize|box_cutout|generate_image|generate_video|publish_canvas",
"preferredCapability": "respond|analyze|generic_edit|remove_background|remove_watermark|enhance|extend|resize|angle_variant|box_cutout|generate_image|generate_video|publish_canvas",
"responseMode": "direct_answer|tool_then_answer|tool_then_execute|clarify",
"shouldUseHistoryImage": true,
"shouldUseStructuredReply": false,

View File

@ -70,6 +70,7 @@ export function buildImageEditSpecialistRoute(params: {
"enhanceImage",
"extendImage",
"resizeImage",
"angleEditImage",
"boxCutout",
"useSelectedCanvasImage",
"useLatestResultImage",

View File

@ -0,0 +1,82 @@
export interface DirectAgentAngleEditOptions {
horizontalAngle: number;
verticalAngle: number;
zoom: number;
loraScale?: number;
}
export function clampAngleEditNumber(
value: number | undefined,
min: number,
max: number,
fallback: number,
) {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Number(value)));
}
function parseFirstNumberMatch(text: string, patterns: RegExp[]) {
for (const pattern of patterns) {
const match = text.match(pattern);
const value = Number(match?.[1]);
if (Number.isFinite(value)) return value;
}
return undefined;
}
export function inferAngleEditOptionsFromText(text: string): DirectAgentAngleEditOptions {
const normalizedText = String(text || "").toLowerCase().replace(/\s+/g, "");
const explicitHorizontal = parseFirstNumberMatch(normalizedText, [
/(?:水平角|水平|环绕|左右|horizontal|h)[::]?(-?\d{1,3})(?:度|°)?/,
/(?:旋转|转|转到|换成|改成)(-?\d{1,3})(?:度|°)/,
]);
const explicitVertical = parseFirstNumberMatch(normalizedText, [
/(?:垂直角|垂直|俯仰|上下|vertical|v)[::]?(-?\d{1,3})(?:度|°)?/,
]);
const explicitZoom = parseFirstNumberMatch(normalizedText, [
/(?:zoom|缩放|景别)[::]?(\d{1,2})(?:倍|x)?/,
]);
let horizontalAngle = explicitHorizontal ?? 45;
if (explicitHorizontal === undefined) {
if (/背面|背后|背视|后视|back/.test(normalizedText)) {
horizontalAngle = 180;
} else if (/左侧|左边|左视|left/.test(normalizedText)) {
horizontalAngle = 270;
} else if (/右侧|右边|右视|侧面|侧视|profile|side/.test(normalizedText)) {
horizontalAngle = 90;
} else if (/正面|正视|front/.test(normalizedText)) {
horizontalAngle = 0;
} else if (/三分之四|四分之三|斜侧|45度|45°/.test(normalizedText)) {
horizontalAngle = 45;
}
}
let verticalAngle = explicitVertical ?? 0;
if (explicitVertical === undefined) {
if (/俯视|俯拍|鸟瞰|高机位|top|overhead/.test(normalizedText)) {
verticalAngle = 45;
} else if (/仰视|仰拍|低机位|lowangle/.test(normalizedText)) {
verticalAngle = -20;
} else if (/平视|eyelevel/.test(normalizedText)) {
verticalAngle = 0;
}
}
let zoom = explicitZoom ?? 5;
if (explicitZoom === undefined) {
if (/特写|近景|拉近|推近|zoomin/.test(normalizedText)) {
zoom = 7;
} else if (/远景|全身|拉远|zoomout/.test(normalizedText)) {
zoom = 3;
} else if (/中景/.test(normalizedText)) {
zoom = 5;
}
}
return {
horizontalAngle: clampAngleEditNumber(horizontalAngle, 0, 360, 45),
verticalAngle: clampAngleEditNumber(verticalAngle, -30, 90, 0),
zoom: clampAngleEditNumber(zoom, 0, 10, 5),
};
}

View File

@ -16,6 +16,7 @@ export type DirectAgentToolCapability =
| "enhance"
| "resize"
| "extend"
| "angle_variant"
| "box_cutout"
| "generate_image"
| "generate_video";
@ -28,6 +29,7 @@ export type DirectAgentToolName =
| "enhanceImage"
| "resizeImage"
| "extendImage"
| "angleEditImage"
| "boxCutout"
| "generateImage"
| "generateVideo";
@ -98,6 +100,8 @@ function mapDirectToolNameToCapability(
return "resize";
case "extendImage":
return "extend";
case "angleEditImage":
return "angle_variant";
case "boxCutout":
return "box_cutout";
case "generateImage":
@ -199,6 +203,13 @@ export function resolveDirectAgentToolExecution(
if (preferredCapability === "extend" || routeSubIntent === "extend") {
return "extendImage";
}
if (
preferredCapability === "angle_variant" ||
routeSubIntent === "angle_variant" ||
routeSubIntent === "generate_angle_variant"
) {
return "angleEditImage";
}
if (preferredCapability === "box_cutout" || routeSubIntent === "box_cutout") {
return "boxCutout";
}
@ -252,15 +263,17 @@ export function resolveDirectAgentToolExecution(
? "resizeImage"
: hasToolCall("extendImage")
? "extendImage"
: hasToolCall("boxCutout")
? "boxCutout"
: hasToolCall("editTargetImage") || hasToolCall("editImage")
? preferredSpecificImageEditTool || "editImage"
: hasToolCall("generateNewImage") || hasToolCall("generateImage")
? "generateImage"
: hasToolCall("generateVideo")
? "generateVideo"
: null) ||
: hasToolCall("angleEditImage")
? "angleEditImage"
: hasToolCall("boxCutout")
? "boxCutout"
: hasToolCall("editTargetImage") || hasToolCall("editImage")
? preferredSpecificImageEditTool || "editImage"
: hasToolCall("generateNewImage") || hasToolCall("generateImage")
? "generateImage"
: hasToolCall("generateVideo")
? "generateVideo"
: null) ||
(() => {
if (
execution?.preferredCapability === "analyze" ||
@ -299,6 +312,13 @@ export function resolveDirectAgentToolExecution(
) {
return "extendImage";
}
if (
execution?.preferredCapability === "angle_variant" ||
execution?.routeSubIntent === "angle_variant" ||
execution?.routeSubIntent === "generate_angle_variant"
) {
return "angleEditImage";
}
if (
execution?.preferredCapability === "box_cutout" ||
execution?.routeSubIntent === "box_cutout"
@ -380,6 +400,7 @@ export function isDirectAgentBatchImageEditCapability(
case "enhance":
case "resize":
case "extend":
case "angle_variant":
return true;
default:
return false;
@ -410,6 +431,7 @@ export function isDirectAgentImageEditToolName(
| "removeWatermark"
| "resizeImage"
| "extendImage"
| "angleEditImage"
| "boxCutout" {
switch (toolName) {
case "editImage":
@ -418,6 +440,7 @@ export function isDirectAgentImageEditToolName(
case "removeWatermark":
case "resizeImage":
case "extendImage":
case "angleEditImage":
case "boxCutout":
return true;
default:
@ -441,6 +464,8 @@ export function getDirectAgentToolLabel(capability: DirectAgentToolCapability):
return "Agent工具:尺寸调整";
case "extend":
return "Agent工具:图像拓展";
case "angle_variant":
return "Agent工具:多视角";
case "box_cutout":
return "Agent工具:框选抠图";
case "generate_image":
@ -486,6 +511,8 @@ export function getDirectAgentToolSuccessMessage(
return "已完成尺寸调整,结果如下:";
case "extend":
return "已完成图像拓展,结果如下:";
case "angle_variant":
return "已完成多视角生成,结果如下:";
case "box_cutout":
return "已完成框选抠图,结果如下:";
case "generate_image":
@ -515,6 +542,8 @@ export function getDirectAgentToolTargetMissingMessage(
return "请先上传或选中一张图片,我才能继续帮你调整尺寸。";
case "extend":
return "请先上传或选中一张图片,我才能继续帮你做图像拓展。";
case "angle_variant":
return "请先上传或选中一张图片,我才能继续帮你生成多视角变体。";
case "box_cutout":
return "请先选中一张图片并提供框选区域,我才能继续帮你做框选抠图。";
case "generate_image":
@ -553,6 +582,8 @@ export function getDirectAgentToolErrorMessage(
return `尺寸调整暂时失败:${detail}`;
case "extend":
return `图像拓展暂时失败:${detail}`;
case "angle_variant":
return `多视角生成暂时失败:${detail}`;
case "box_cutout":
return `框选抠图暂时失败:${detail}`;
case "generate_image":

View File

@ -30,6 +30,11 @@ import {
} from "@/lib/ai/runtime/direct-agent-execution";
import { buildDirectAgentExecutionPrompt } from "@/lib/ai/runtime/optimized-prompt";
import { buildAgentToolImagesMarkdown } from "@/lib/ai/runtime/agent-media-markdown";
import {
clampAngleEditNumber,
inferAngleEditOptionsFromText,
type DirectAgentAngleEditOptions,
} from "@/lib/ai/runtime/angle-edit-options";
interface DirectAgentImageParamsSnapshot {
negativePrompt?: string;
@ -178,6 +183,44 @@ async function executeDirectAgentWatermarkRemoveJob(params: {
};
}
async function executeDirectAgentAngleEditJob(params: {
requestUrl: string;
requestHeaders: Headers;
imageUrl: string;
options: DirectAgentAngleEditOptions;
}) {
const endpoint = new URL("/api/angle-edit", params.requestUrl).toString();
const cookieHeader = params.requestHeaders.get("cookie") || "";
const result = await requestLongTaskResult({
endpoint,
payload: {
image_url: params.imageUrl,
horizontal_angle: params.options.horizontalAngle,
vertical_angle: params.options.verticalAngle,
zoom: params.options.zoom,
...(params.options.loraScale !== undefined
? { lora_scale: params.options.loraScale }
: {}),
},
timeoutMs: 20 * 60 * 1000,
headers: cookieHeader
? {
cookie: cookieHeader,
}
: undefined,
});
if (!result?.success || !result?.image_url) {
throw new Error(String(result?.error || result?.detail || "多视角生成失败"));
}
return {
originalUrl: String(result.image_url),
thumbnailUrl: String(result.thumbnail_url || result.image_url),
imageSize: "2K",
};
}
export function isDirectAgentImageEditServiceError(error: unknown) {
return (
error instanceof RmbgServiceError ||
@ -454,6 +497,61 @@ export async function executeDirectAgentImageEditTool(
});
}
if (params.capability === "angle_variant") {
const plannedArgs = findAgentToolCallArgs(
"angleEditImage",
params.smartAutoExecution,
params.smartAutoPlan,
);
const inferredOptions = inferAngleEditOptionsFromText(params.requestTextContent);
const options: DirectAgentAngleEditOptions = {
horizontalAngle: clampAngleEditNumber(
toFiniteNumber(
plannedArgs?.horizontal_angle ??
plannedArgs?.horizontalAngle ??
plannedArgs?.angle ??
inferredOptions.horizontalAngle,
),
0,
360,
inferredOptions.horizontalAngle,
),
verticalAngle: clampAngleEditNumber(
toFiniteNumber(
plannedArgs?.vertical_angle ??
plannedArgs?.verticalAngle ??
plannedArgs?.pitch ??
inferredOptions.verticalAngle,
),
-30,
90,
inferredOptions.verticalAngle,
),
zoom: clampAngleEditNumber(
toFiniteNumber(plannedArgs?.zoom ?? inferredOptions.zoom),
0,
10,
inferredOptions.zoom,
),
loraScale: toFiniteNumber(plannedArgs?.lora_scale ?? plannedArgs?.loraScale),
};
const result = await executeDirectAgentAngleEditJob({
requestUrl: params.requestUrl,
requestHeaders: params.requestHeaders,
imageUrl: params.sourceImageUrl,
options,
});
return finalizeDirectAgentImageEditResult({
userId: params.userId,
capability: params.capability,
originalUrl: result.originalUrl,
thumbnailUrl: result.thumbnailUrl,
imageSize: result.imageSize,
mediaAssetId: null,
});
}
if (params.capability === "box_cutout") {
const plannedArgs = findAgentToolCallArgs(
"boxCutout",

View File

@ -22,6 +22,7 @@ export type IntentArbiterPreferredCapability =
| "enhance"
| "extend"
| "resize"
| "angle_variant"
| "box_cutout"
| "generate_image"
| "generate_video"
@ -114,7 +115,7 @@ function isExplicitImageGenerationRequest(text: string): boolean {
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = String(text || "").toLowerCase();
return /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
return /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
}
@ -124,6 +125,7 @@ function inferDefaultImageEditSubIntent(text: string): string {
if (/去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(lowerText)) return "remove_watermark";
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background";
if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background";
if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) return "angle_variant";
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(lowerText)) return "extend";
if (/风格|卡通|动漫|油画|像素风|写实|插画/.test(lowerText)) return "style_transfer";
if (/颜色|调色|亮度|对比度|饱和度|改成.*色|换成.*色/.test(lowerText)) return "color_adjust";
@ -169,6 +171,9 @@ function mapPreferredCapability(
return "extend";
case "resize":
return "resize";
case "angle_variant":
case "generate_angle_variant":
return "angle_variant";
case "box_cutout":
return "box_cutout";
case "enhance":

View File

@ -110,6 +110,7 @@ export function routeAgentSkill(
"enhanceImage",
"extendImage",
"resizeImage",
"angleEditImage",
"boxCutout",
"useSelectedCanvasImage",
"useLatestResultImage",

View File

@ -34,6 +34,7 @@ function inferDefaultImageEditSubIntent(text: string): AnalysisResult["subIntent
if (/去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(lowerText)) return "remove_watermark";
if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background";
if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background";
if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) return "angle_variant";
if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(lowerText)) return "extend";
if (/风格|卡通|动漫|油画|像素风|写实|插画/.test(lowerText)) return "style_transfer";
if (/颜色|调色|亮度|对比度|饱和度|改成.*色|换成.*色/.test(lowerText)) return "color_adjust";
@ -138,7 +139,7 @@ export function isExplicitImageGenerationRequest(text: string): boolean {
function isLikelyImageEditRequest(text: string): boolean {
const normalizedText = text.toLowerCase();
return /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
return /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test(
normalizedText,
);
}
@ -388,6 +389,7 @@ const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI多模态Agent。用户
- replace_background: 换背景、改背景、白底
- enhance: 变清晰、高清、锐化、降噪
- extend: 扩展、拓展、延伸画布
- angle_variant: 多视角、换角度、侧面、背面、俯视、仰视
- inpaint: 去除某物、消除、擦掉、P掉、局部修改
- style_transfer: 风格化、卡通化、动漫风、改风格
- color_adjust: 调色、调整颜色、亮度、对比度
@ -768,7 +770,7 @@ async function analyzeWithHardcodedQwen3VlFallback(params: {
const prompt = [
"你是图片需求分析助手。请结合图片与用户意图,输出严格 JSON,不要输出其他内容。",
"intent 只能是 image_edit / vision / image_gen / video_gen / chat。",
"subIntent 可选:remove_background / remove_watermark / replace_background / enhance / extend / inpaint / style_transfer / color_adjust / redo / resize。",
"subIntent 可选:remove_background / remove_watermark / replace_background / enhance / extend / angle_variant / inpaint / style_transfer / color_adjust / redo / resize。",
"如果用户提到视频、首帧、尾帧、分镜,intent=video_gen。",
"有图片且用户在修改图片时,优先 intent=image_edit。",
"JSON 字段:intent, subIntent, confidence, conversationalReply, needsClarification, clarifyingQuestions。",
@ -2413,6 +2415,16 @@ export function fallbackKeywordAnalysis(
};
}
// 多视角/角度变体
if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) {
return {
intent: "image_edit",
subIntent: "angle_variant",
confidence: 0.9,
conversationalReply: "好的,我来帮你生成新的视角变体~",
};
}
// 尺寸调整
if (/缩放|放大|缩小|调整尺寸|裁剪|resize|crop/.test(lowerText)) {
return {

View File

@ -224,6 +224,13 @@ function normalizeEditCapability(
const normalizedText = String(text || "").toLowerCase();
const subIntent = String(analysis.subIntent || "").trim().toLowerCase();
if (subIntent === "angle_variant" || /多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(normalizedText)) {
return {
capability: "angle_variant",
toolName: "angleEditImage",
subIntent: "angle_variant",
};
}
if (subIntent === "remove_watermark" || /去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(normalizedText)) {
return {
capability: "remove_watermark",

View File

@ -23,6 +23,7 @@ export type AgentProjectToolName =
| "enhanceImage"
| "extendImage"
| "resizeImage"
| "angleEditImage"
| "boxCutout"
| "publishToCanvas"
| "useSelectedCanvasImage"
@ -199,6 +200,14 @@ const INTERNAL_AGENT_TOOLS: AgentProjectToolDefinition[] = [
requiresImage: true,
producesMedia: true,
},
{
name: "angleEditImage",
title: "多视角",
description: "根据水平角、俯仰角和景别缩放生成主体的新视角变体,结果作为新版本保留原图。",
category: "image",
requiresImage: true,
producesMedia: true,
},
{
name: "boxCutout",
title: "框选抠图",

View File

@ -130,6 +130,7 @@ import {
resolveDirectAgentToolExecution,
shouldRunDirectAgentBatchImageEdit,
} from "@/lib/ai/runtime/direct-agent-execution";
import { inferAngleEditOptionsFromText } from "@/lib/ai/runtime/angle-edit-options";
import {
applyCanvasOperationPlanToCanvasData,
CanvasAgentMutationError,
@ -1098,10 +1099,75 @@ test.describe("canvas chat pure helpers", () => {
userText: "把这几张都改成黄昏",
})
).toBe(false);
expect(
shouldRunDirectAgentBatchImageEdit({
capability: "angle_variant",
sourceImageUrls: [
"https://cdn.example.com/a.png",
"https://cdn.example.com/b.png",
],
userText: "这两张都生成右侧面视角",
})
).toBe(true);
expect(hasExplicitDirectAgentBatchImageEditIntent("只处理第一张")).toBe(false);
expect(isDirectAgentBatchImageEditCapability("box_cutout")).toBe(false);
});
test("routes angle variant requests through the dedicated direct image tool", () => {
const result = resolveDirectAgentToolExecution({
mode: "edit_image",
routeIntent: "image_edit",
routeSubIntent: "angle_variant",
targetSource: "explicit_selection",
preferredCapability: "angle_variant",
shouldUseStructuredReply: true,
shouldOptimizePrompt: true,
shouldApplyImagePipeline: true,
shouldApplyVideoPipeline: false,
shouldStayConversational: false,
shouldPublishToCanvas: false,
toolCalls: [
{
name: "resolveTargetImage",
args: { source: "selected_canvas" },
},
{
name: "editTargetImage",
reason: "planner only provided the generic edit tool",
},
],
availableTools: ["resolveTargetImage", "editTargetImage", "angleEditImage"],
});
expect(result).toMatchObject({
toolName: "angleEditImage",
capability: "angle_variant",
sourcePreferences: ["selected_canvas"],
});
expect(getDirectAgentToolLabel("angle_variant")).toBe("Agent工具:多视角");
expect(getDirectAgentToolSuccessMessage("angle_variant")).toBe(
"已完成多视角生成,结果如下:"
);
});
test("infers deterministic angle edit options from canvas agent text", () => {
expect(inferAngleEditOptionsFromText("生成右侧面近景视角")).toMatchObject({
horizontalAngle: 90,
verticalAngle: 0,
zoom: 7,
});
expect(inferAngleEditOptionsFromText("改成背面俯视远景")).toMatchObject({
horizontalAngle: 180,
verticalAngle: 45,
zoom: 3,
});
expect(inferAngleEditOptionsFromText("水平角120度,垂直角-10度,zoom8")).toMatchObject({
horizontalAngle: 120,
verticalAngle: -10,
zoom: 8,
});
});
test("plans watermark removal results as non-destructive canvas revisions", () => {
const result = buildCanvasAgentDirectWritebackPlan({
projectId: plannerContext.projectId || "",