diff --git a/lib/ai/auto-agent-graph.ts b/lib/ai/auto-agent-graph.ts index eab1db025..0284bf9b9 100644 --- a/lib/ai/auto-agent-graph.ts +++ b/lib/ai/auto-agent-graph.ts @@ -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, diff --git a/lib/ai/image-edit-subagent.ts b/lib/ai/image-edit-subagent.ts index e47c84685..56de2bcae 100644 --- a/lib/ai/image-edit-subagent.ts +++ b/lib/ai/image-edit-subagent.ts @@ -70,6 +70,7 @@ export function buildImageEditSpecialistRoute(params: { "enhanceImage", "extendImage", "resizeImage", + "angleEditImage", "boxCutout", "useSelectedCanvasImage", "useLatestResultImage", diff --git a/lib/ai/runtime/angle-edit-options.ts b/lib/ai/runtime/angle-edit-options.ts new file mode 100644 index 000000000..7c06bc07d --- /dev/null +++ b/lib/ai/runtime/angle-edit-options.ts @@ -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), + }; +} diff --git a/lib/ai/runtime/direct-agent-execution.ts b/lib/ai/runtime/direct-agent-execution.ts index 5f5ad2bb5..c15e8c9ac 100644 --- a/lib/ai/runtime/direct-agent-execution.ts +++ b/lib/ai/runtime/direct-agent-execution.ts @@ -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": diff --git a/lib/ai/runtime/direct-agent-image-edit-executor.ts b/lib/ai/runtime/direct-agent-image-edit-executor.ts index 9f3a0d6ba..5141cfab0 100644 --- a/lib/ai/runtime/direct-agent-image-edit-executor.ts +++ b/lib/ai/runtime/direct-agent-image-edit-executor.ts @@ -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", diff --git a/lib/ai/runtime/intent-arbiter.ts b/lib/ai/runtime/intent-arbiter.ts index e22a23aaf..d7200b719 100644 --- a/lib/ai/runtime/intent-arbiter.ts +++ b/lib/ai/runtime/intent-arbiter.ts @@ -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": diff --git a/lib/ai/skill-router.ts b/lib/ai/skill-router.ts index b3d38116c..377e8451d 100644 --- a/lib/ai/skill-router.ts +++ b/lib/ai/skill-router.ts @@ -110,6 +110,7 @@ export function routeAgentSkill( "enhanceImage", "extendImage", "resizeImage", + "angleEditImage", "boxCutout", "useSelectedCanvasImage", "useLatestResultImage", diff --git a/lib/ai/smart-analyzer.ts b/lib/ai/smart-analyzer.ts index 23e133e8f..3de90b42e 100644 --- a/lib/ai/smart-analyzer.ts +++ b/lib/ai/smart-analyzer.ts @@ -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 { diff --git a/lib/ai/specialists/index.ts b/lib/ai/specialists/index.ts index 85dc6ac14..8cf533493 100644 --- a/lib/ai/specialists/index.ts +++ b/lib/ai/specialists/index.ts @@ -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", diff --git a/lib/ai/tools-registry.ts b/lib/ai/tools-registry.ts index 00385ff80..c6d041c9c 100644 --- a/lib/ai/tools-registry.ts +++ b/lib/ai/tools-registry.ts @@ -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: "框选抠图", diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index d7a36e9f3..083e58c48 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -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 || "",