feat: add specialist direct executor
This commit is contained in:
parent
bbdda81686
commit
449dd19d5f
@ -20,6 +20,10 @@ import { getUsage } from "tokenlens/helpers";
|
|||||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
import { auth, type UserType } from "@/app/(auth)/auth";
|
||||||
import type { VisibilityType } from "@/components/visibility-selector";
|
import type { VisibilityType } from "@/components/visibility-selector";
|
||||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||||
|
import {
|
||||||
|
BoxCutoutServiceError,
|
||||||
|
executeBoxCutoutJob,
|
||||||
|
} from "@/lib/ai/box-cutout/service";
|
||||||
import {
|
import {
|
||||||
executeExtendJob,
|
executeExtendJob,
|
||||||
ExtendServiceError,
|
ExtendServiceError,
|
||||||
@ -43,7 +47,10 @@ import { createDocument } from "@/lib/ai/tools/create-document";
|
|||||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||||
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
||||||
import { updateDocument } from "@/lib/ai/tools/update-document";
|
import { updateDocument } from "@/lib/ai/tools/update-document";
|
||||||
import { generateVideo } from "@/lib/ai/tools/generate-video";
|
import {
|
||||||
|
executeDirectVideoGeneration,
|
||||||
|
generateVideo,
|
||||||
|
} from "@/lib/ai/tools/generate-video";
|
||||||
import { getEnabledTools } from "@/lib/ai/tools-registry";
|
import { getEnabledTools } from "@/lib/ai/tools-registry";
|
||||||
import { isProductionEnvironment } from "@/lib/constants";
|
import { isProductionEnvironment } from "@/lib/constants";
|
||||||
import {
|
import {
|
||||||
@ -74,6 +81,7 @@ import {
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
import { generatedImage, message as messageTable } from "@/lib/db/schema";
|
import { generatedImage, message as messageTable } from "@/lib/db/schema";
|
||||||
|
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||||
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
|
import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input";
|
||||||
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
|
import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
|
||||||
import type {
|
import type {
|
||||||
@ -784,42 +792,139 @@ type AgentPreparedExecution = {
|
|||||||
availableTools: string[];
|
availableTools: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type DirectAgentToolCapability = "remove_background" | "resize" | "extend";
|
type DirectAgentToolCapability =
|
||||||
|
| "remove_background"
|
||||||
|
| "resize"
|
||||||
|
| "extend"
|
||||||
|
| "box_cutout"
|
||||||
|
| "generate_image"
|
||||||
|
| "generate_video";
|
||||||
|
|
||||||
function resolveDirectAgentToolCapability(
|
type DirectAgentToolName =
|
||||||
|
| "removeBackground"
|
||||||
|
| "resizeImage"
|
||||||
|
| "extendImage"
|
||||||
|
| "boxCutout"
|
||||||
|
| "generateImage"
|
||||||
|
| "generateVideo";
|
||||||
|
|
||||||
|
type DirectAgentSourceToolName =
|
||||||
|
| "useSelectedCanvasImage"
|
||||||
|
| "useLatestResultImage"
|
||||||
|
| "useOriginalUploadImage";
|
||||||
|
|
||||||
|
type ResolvedDirectAgentToolExecution = {
|
||||||
|
toolName: DirectAgentToolName;
|
||||||
|
capability: DirectAgentToolCapability;
|
||||||
|
sourceTools: DirectAgentSourceToolName[];
|
||||||
|
publishToCanvas: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function collectAgentToolCalls(
|
||||||
execution?: AgentPreparedExecution,
|
execution?: AgentPreparedExecution,
|
||||||
plan?: AgentExecutionPlan,
|
plan?: AgentExecutionPlan,
|
||||||
): DirectAgentToolCapability | null {
|
): AgentPlannedToolCall[] {
|
||||||
const toolNames = new Set([
|
const merged = [...(execution?.toolCalls || []), ...(plan?.toolCalls || [])];
|
||||||
...(execution?.toolCalls || []).map((tool) => tool.name),
|
const seen = new Set<string>();
|
||||||
...(plan?.toolCalls || []).map((tool) => tool.name),
|
const result: AgentPlannedToolCall[] = [];
|
||||||
]);
|
|
||||||
|
|
||||||
if (
|
for (const tool of merged) {
|
||||||
execution?.preferredCapability === "remove_background" ||
|
const name = String(tool?.name || "").trim();
|
||||||
execution?.routeSubIntent === "remove_background" ||
|
if (!name || seen.has(name)) continue;
|
||||||
toolNames.has("removeBackground")
|
seen.add(name);
|
||||||
) {
|
result.push(tool);
|
||||||
return "remove_background";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return result;
|
||||||
execution?.preferredCapability === "resize" ||
|
}
|
||||||
execution?.routeSubIntent === "resize" ||
|
|
||||||
toolNames.has("resizeImage")
|
function mapDirectToolNameToCapability(
|
||||||
) {
|
toolName: DirectAgentToolName,
|
||||||
return "resize";
|
): DirectAgentToolCapability {
|
||||||
|
switch (toolName) {
|
||||||
|
case "removeBackground":
|
||||||
|
return "remove_background";
|
||||||
|
case "resizeImage":
|
||||||
|
return "resize";
|
||||||
|
case "extendImage":
|
||||||
|
return "extend";
|
||||||
|
case "boxCutout":
|
||||||
|
return "box_cutout";
|
||||||
|
case "generateImage":
|
||||||
|
return "generate_image";
|
||||||
|
case "generateVideo":
|
||||||
|
return "generate_video";
|
||||||
|
default:
|
||||||
|
return "generate_image";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDirectAgentToolExecution(
|
||||||
|
execution?: AgentPreparedExecution,
|
||||||
|
plan?: AgentExecutionPlan,
|
||||||
|
): ResolvedDirectAgentToolExecution | null {
|
||||||
|
const mergedToolCalls = collectAgentToolCalls(execution, plan);
|
||||||
|
const toolNames = mergedToolCalls.map((tool) => tool.name);
|
||||||
|
const sourceTools = toolNames.filter((toolName): toolName is DirectAgentSourceToolName =>
|
||||||
|
toolName === "useSelectedCanvasImage" ||
|
||||||
|
toolName === "useLatestResultImage" ||
|
||||||
|
toolName === "useOriginalUploadImage",
|
||||||
|
);
|
||||||
|
|
||||||
|
const directToolName =
|
||||||
|
toolNames.find((toolName): toolName is DirectAgentToolName =>
|
||||||
|
toolName === "removeBackground" ||
|
||||||
|
toolName === "resizeImage" ||
|
||||||
|
toolName === "extendImage" ||
|
||||||
|
toolName === "boxCutout" ||
|
||||||
|
toolName === "generateImage" ||
|
||||||
|
toolName === "generateVideo",
|
||||||
|
) ||
|
||||||
|
(() => {
|
||||||
|
if (
|
||||||
|
execution?.preferredCapability === "remove_background" ||
|
||||||
|
execution?.routeSubIntent === "remove_background"
|
||||||
|
) {
|
||||||
|
return "removeBackground";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
execution?.preferredCapability === "resize" ||
|
||||||
|
execution?.routeSubIntent === "resize"
|
||||||
|
) {
|
||||||
|
return "resizeImage";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
execution?.preferredCapability === "extend" ||
|
||||||
|
execution?.routeSubIntent === "extend"
|
||||||
|
) {
|
||||||
|
return "extendImage";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
execution?.preferredCapability === "box_cutout" ||
|
||||||
|
execution?.routeSubIntent === "box_cutout"
|
||||||
|
) {
|
||||||
|
return "boxCutout";
|
||||||
|
}
|
||||||
|
if (execution?.mode === "generate_image") {
|
||||||
|
return "generateImage";
|
||||||
|
}
|
||||||
|
if (execution?.mode === "generate_video") {
|
||||||
|
return "generateVideo";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!directToolName) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return {
|
||||||
execution?.preferredCapability === "extend" ||
|
toolName: directToolName,
|
||||||
execution?.routeSubIntent === "extend" ||
|
capability: mapDirectToolNameToCapability(directToolName),
|
||||||
toolNames.has("extendImage")
|
sourceTools,
|
||||||
) {
|
publishToCanvas:
|
||||||
return "extend";
|
execution?.shouldPublishToCanvas === true || toolNames.includes("publishToCanvas"),
|
||||||
}
|
};
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string {
|
function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string {
|
||||||
@ -830,6 +935,12 @@ function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string
|
|||||||
return "Agent工具:尺寸调整";
|
return "Agent工具:尺寸调整";
|
||||||
case "extend":
|
case "extend":
|
||||||
return "Agent工具:图像拓展";
|
return "Agent工具:图像拓展";
|
||||||
|
case "box_cutout":
|
||||||
|
return "Agent工具:框选抠图";
|
||||||
|
case "generate_image":
|
||||||
|
return "Agent工具:图片生成";
|
||||||
|
case "generate_video":
|
||||||
|
return "Agent工具:视频生成";
|
||||||
default:
|
default:
|
||||||
return "Agent工具";
|
return "Agent工具";
|
||||||
}
|
}
|
||||||
@ -864,6 +975,12 @@ function getDirectAgentToolSuccessMessage(capability: DirectAgentToolCapability)
|
|||||||
return "已完成尺寸调整,结果如下:";
|
return "已完成尺寸调整,结果如下:";
|
||||||
case "extend":
|
case "extend":
|
||||||
return "已完成图像拓展,结果如下:";
|
return "已完成图像拓展,结果如下:";
|
||||||
|
case "box_cutout":
|
||||||
|
return "已完成框选抠图,结果如下:";
|
||||||
|
case "generate_image":
|
||||||
|
return "已完成图片生成,结果如下:";
|
||||||
|
case "generate_video":
|
||||||
|
return "已完成视频生成,结果如下:";
|
||||||
default:
|
default:
|
||||||
return "已完成处理,结果如下:";
|
return "已完成处理,结果如下:";
|
||||||
}
|
}
|
||||||
@ -879,6 +996,12 @@ function getDirectAgentToolTargetMissingMessage(
|
|||||||
return "请先上传或选中一张图片,我才能继续帮你调整尺寸。";
|
return "请先上传或选中一张图片,我才能继续帮你调整尺寸。";
|
||||||
case "extend":
|
case "extend":
|
||||||
return "请先上传或选中一张图片,我才能继续帮你做图像拓展。";
|
return "请先上传或选中一张图片,我才能继续帮你做图像拓展。";
|
||||||
|
case "box_cutout":
|
||||||
|
return "请先选中一张图片并提供框选区域,我才能继续帮你做框选抠图。";
|
||||||
|
case "generate_image":
|
||||||
|
return "请先告诉我你想生成什么,或者提供参考图。";
|
||||||
|
case "generate_video":
|
||||||
|
return "请先告诉我你想生成什么视频,必要时再提供参考图。";
|
||||||
default:
|
default:
|
||||||
return "请先提供要处理的图片。";
|
return "请先提供要处理的图片。";
|
||||||
}
|
}
|
||||||
@ -903,6 +1026,12 @@ function getDirectAgentToolErrorMessage(
|
|||||||
return `尺寸调整暂时失败:${detail}`;
|
return `尺寸调整暂时失败:${detail}`;
|
||||||
case "extend":
|
case "extend":
|
||||||
return `图像拓展暂时失败:${detail}`;
|
return `图像拓展暂时失败:${detail}`;
|
||||||
|
case "box_cutout":
|
||||||
|
return `框选抠图暂时失败:${detail}`;
|
||||||
|
case "generate_image":
|
||||||
|
return `图片生成暂时失败:${detail}`;
|
||||||
|
case "generate_video":
|
||||||
|
return `视频生成暂时失败:${detail}`;
|
||||||
default:
|
default:
|
||||||
return `处理暂时失败:${detail}`;
|
return `处理暂时失败:${detail}`;
|
||||||
}
|
}
|
||||||
@ -919,6 +1048,75 @@ function buildAgentToolImageMarkdown(params: {
|
|||||||
return ``;
|
return ``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAgentToolImagesMarkdown(
|
||||||
|
items: Array<{ originalUrl: string; thumbnailUrl?: string | null; imageSize?: string }>,
|
||||||
|
) {
|
||||||
|
return items
|
||||||
|
.map((item) =>
|
||||||
|
buildAgentToolImageMarkdown({
|
||||||
|
originalUrl: item.originalUrl,
|
||||||
|
thumbnailUrl: item.thumbnailUrl,
|
||||||
|
imageSize: item.imageSize,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentToolVideoMarkdown(videoUrl: string) {
|
||||||
|
const normalizedUrl = String(videoUrl || "").trim();
|
||||||
|
if (!normalizedUrl) return "";
|
||||||
|
return [
|
||||||
|
`<video src="${normalizedUrl}" controls style="max-width: 100%; border-radius: 8px;"></video>`,
|
||||||
|
`[下载视频](${normalizedUrl})`,
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAgentSourceImageUrls(params: {
|
||||||
|
sourceTools: DirectAgentSourceToolName[];
|
||||||
|
requestResolvedMediaInput?: ResolvedChatMediaInput | null;
|
||||||
|
requestTargetResolution?: ResolvedTargetAssets | null;
|
||||||
|
requestWorkingState?: {
|
||||||
|
lastResolvedTargetImageUrls?: string[] | null;
|
||||||
|
lastGeneratedImageUrls?: string[] | null;
|
||||||
|
lastUploadedImageUrls?: string[] | null;
|
||||||
|
} | null;
|
||||||
|
}): string[] {
|
||||||
|
const preferredBuckets: string[][] = [];
|
||||||
|
const sourceTools = params.sourceTools || [];
|
||||||
|
|
||||||
|
if (sourceTools.includes("useSelectedCanvasImage")) {
|
||||||
|
preferredBuckets.push(params.requestResolvedMediaInput?.selectedImageUrls || []);
|
||||||
|
}
|
||||||
|
if (sourceTools.includes("useLatestResultImage")) {
|
||||||
|
preferredBuckets.push(params.requestWorkingState?.lastGeneratedImageUrls || []);
|
||||||
|
preferredBuckets.push(params.requestResolvedMediaInput?.historyAssistantImageUrls || []);
|
||||||
|
}
|
||||||
|
if (sourceTools.includes("useOriginalUploadImage")) {
|
||||||
|
preferredBuckets.push(params.requestResolvedMediaInput?.currentMessageImageUrls || []);
|
||||||
|
preferredBuckets.push(params.requestWorkingState?.lastUploadedImageUrls || []);
|
||||||
|
preferredBuckets.push(params.requestResolvedMediaInput?.historyUserImageUrls || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
preferredBuckets.push(
|
||||||
|
params.requestTargetResolution?.targetImageUrls || [],
|
||||||
|
params.requestResolvedMediaInput?.selectedImageUrls || [],
|
||||||
|
params.requestResolvedMediaInput?.currentMessageImageUrls || [],
|
||||||
|
params.requestWorkingState?.lastResolvedTargetImageUrls || [],
|
||||||
|
params.requestWorkingState?.lastGeneratedImageUrls || [],
|
||||||
|
params.requestResolvedMediaInput?.historyAssistantImageUrls || [],
|
||||||
|
params.requestResolvedMediaInput?.historyUserImageUrls || [],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
preferredBuckets
|
||||||
|
.flat()
|
||||||
|
.map((url) => String(url || "").trim())
|
||||||
|
.filter((url) => /^https?:\/\//i.test(url)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||||
"alibaba-image",
|
"alibaba-image",
|
||||||
"ruxa-image",
|
"ruxa-image",
|
||||||
@ -3788,22 +3986,395 @@ export async function POST(request: Request) {
|
|||||||
return createSseResponse(clarificationStream);
|
return createSseResponse(clarificationStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
const directAgentToolCapability = shouldRunAgentConversation
|
const directAgentExecution = shouldRunAgentConversation
|
||||||
? resolveDirectAgentToolCapability(smartAutoExecution, smartAutoPlan)
|
? resolveDirectAgentToolExecution(smartAutoExecution, smartAutoPlan)
|
||||||
: null;
|
: null;
|
||||||
const directAgentToolTargetImageUrl =
|
const directAgentToolTargetImageUrls = directAgentExecution
|
||||||
requestTargetResolution?.targetImageUrls?.[0] ||
|
? pickAgentSourceImageUrls({
|
||||||
requestResolvedMediaInput?.selectedImageUrls?.[0] ||
|
sourceTools: directAgentExecution.sourceTools,
|
||||||
requestResolvedMediaInput?.currentMessageImageUrls?.[0] ||
|
requestResolvedMediaInput,
|
||||||
requestWorkingState?.lastResolvedTargetImageUrls?.[0] ||
|
requestTargetResolution,
|
||||||
requestWorkingState?.lastGeneratedImageUrls?.[0] ||
|
requestWorkingState,
|
||||||
null;
|
})
|
||||||
|
: [];
|
||||||
|
const directAgentToolTargetImageUrl = directAgentToolTargetImageUrls[0] || null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldRunAgentConversation &&
|
shouldRunAgentConversation &&
|
||||||
!smartAutoNeedsClarification &&
|
!smartAutoNeedsClarification &&
|
||||||
directAgentToolCapability
|
directAgentExecution?.toolName === "generateImage"
|
||||||
) {
|
) {
|
||||||
|
const generationPrompt =
|
||||||
|
String(smartAutoOptimizedPrompt || requestTextContent || "").trim() || "生成图片";
|
||||||
|
const directImageStream = createUIMessageStream({
|
||||||
|
execute: async ({ writer }) => {
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_START -->",
|
||||||
|
"agent:generate_image:start",
|
||||||
|
);
|
||||||
|
if (contextPayload?.source === "canvas") {
|
||||||
|
writeImageGenerationStatusChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
phase: "processing",
|
||||||
|
providerType: requestedModelProviderType,
|
||||||
|
modelName: finalModelId,
|
||||||
|
imageCount:
|
||||||
|
effectiveImageParams?.imageCount ||
|
||||||
|
defaultImageQualityParams.imageCount ||
|
||||||
|
1,
|
||||||
|
},
|
||||||
|
"agent:generate_image:status-processing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessageId = generateUUID();
|
||||||
|
try {
|
||||||
|
const result = await generateCanvasImage({
|
||||||
|
prompt: generationPrompt,
|
||||||
|
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||||
|
model: finalModelId,
|
||||||
|
aspectRatio:
|
||||||
|
effectiveImageParams?.aspectRatio ||
|
||||||
|
defaultImageQualityParams.aspectRatio,
|
||||||
|
imageSize:
|
||||||
|
effectiveImageParams?.imageSize ||
|
||||||
|
defaultImageQualityParams.imageSize,
|
||||||
|
imageCount:
|
||||||
|
effectiveImageParams?.imageCount ||
|
||||||
|
defaultImageQualityParams.imageCount,
|
||||||
|
referenceImages: directAgentToolTargetImageUrls,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageMarkdown = buildAgentToolImagesMarkdown(
|
||||||
|
result.images.map((image) => ({
|
||||||
|
originalUrl: image.originalUrl,
|
||||||
|
thumbnailUrl: image.thumbnailUrl,
|
||||||
|
imageSize:
|
||||||
|
effectiveImageParams?.imageSize ||
|
||||||
|
defaultImageQualityParams.imageSize,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const assistantText = prependImageModelBadge(
|
||||||
|
[
|
||||||
|
getDirectAgentToolSuccessMessage("generate_image"),
|
||||||
|
imageMarkdown,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
result.model,
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: assistantMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: assistantText }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveGeneratedImages(
|
||||||
|
assistantText,
|
||||||
|
session.user.id,
|
||||||
|
id,
|
||||||
|
assistantMessageId,
|
||||||
|
generationPrompt,
|
||||||
|
result.modelRef || finalModelId,
|
||||||
|
{
|
||||||
|
sourceImageUrls: directAgentToolTargetImageUrls,
|
||||||
|
relationType: "generated_from",
|
||||||
|
sourceType:
|
||||||
|
directAgentToolTargetImageUrls.length > 0
|
||||||
|
? "reference_generate"
|
||||||
|
: "generated",
|
||||||
|
projectId: contextPayload?.projectId || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
assistantText,
|
||||||
|
"agent:generate_image:result",
|
||||||
|
assistantMessageId,
|
||||||
|
);
|
||||||
|
if (contextPayload?.source === "canvas") {
|
||||||
|
writeCanvasMediaChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
imageUrls: result.images.map((image) => image.originalUrl),
|
||||||
|
prompt: generationPrompt,
|
||||||
|
sourceImageUrls: directAgentToolTargetImageUrls,
|
||||||
|
},
|
||||||
|
"agent:generate_image:canvas-media",
|
||||||
|
);
|
||||||
|
writeImageGenerationStatusChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
phase: "completed",
|
||||||
|
providerType: requestedModelProviderType,
|
||||||
|
modelName: result.model,
|
||||||
|
imageCount: result.images.length,
|
||||||
|
},
|
||||||
|
"agent:generate_image:status-completed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"agent:generate_image:end",
|
||||||
|
);
|
||||||
|
} catch (directToolError) {
|
||||||
|
console.error("[Chat API] Direct agent image generation failed:", directToolError);
|
||||||
|
const errorMessage = getDirectAgentToolErrorMessage(
|
||||||
|
"generate_image",
|
||||||
|
directToolError,
|
||||||
|
);
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: assistantMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: errorMessage }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
errorMessage,
|
||||||
|
"agent:generate_image:error",
|
||||||
|
assistantMessageId,
|
||||||
|
);
|
||||||
|
if (contextPayload?.source === "canvas") {
|
||||||
|
writeImageGenerationStatusChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
phase: "failed",
|
||||||
|
providerType: requestedModelProviderType,
|
||||||
|
modelName: finalModelId,
|
||||||
|
},
|
||||||
|
"agent:generate_image:status-failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
"<!-- IMAGE_GENERATION_END -->",
|
||||||
|
"agent:generate_image:end-error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSseResponse(directImageStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldRunAgentConversation &&
|
||||||
|
!smartAutoNeedsClarification &&
|
||||||
|
directAgentExecution?.toolName === "generateVideo"
|
||||||
|
) {
|
||||||
|
const generationPrompt =
|
||||||
|
String(smartAutoOptimizedPrompt || requestTextContent || "").trim() || "生成视频";
|
||||||
|
const directVideoStream = createUIMessageStream({
|
||||||
|
execute: async ({ writer }) => {
|
||||||
|
const assistantMessageId = generateUUID();
|
||||||
|
try {
|
||||||
|
const plannedArgs = findAgentToolCallArgs(
|
||||||
|
"generateVideo",
|
||||||
|
smartAutoExecution,
|
||||||
|
smartAutoPlan,
|
||||||
|
);
|
||||||
|
const requestedMode = String(
|
||||||
|
plannedArgs?.mode || effectiveVideoParams?.mode || "",
|
||||||
|
).trim() as
|
||||||
|
| "text-to-video"
|
||||||
|
| "first-frame"
|
||||||
|
| "first-last-frame"
|
||||||
|
| "reference-images";
|
||||||
|
const mode =
|
||||||
|
requestedMode ||
|
||||||
|
(directAgentToolTargetImageUrls.length >= 2
|
||||||
|
? "first-last-frame"
|
||||||
|
: directAgentToolTargetImageUrls.length >= 1
|
||||||
|
? "first-frame"
|
||||||
|
: "text-to-video");
|
||||||
|
const images =
|
||||||
|
mode === "first-last-frame"
|
||||||
|
? directAgentToolTargetImageUrls.slice(0, 2)
|
||||||
|
: mode === "first-frame"
|
||||||
|
? directAgentToolTargetImageUrls.slice(0, 1)
|
||||||
|
: mode === "reference-images"
|
||||||
|
? directAgentToolTargetImageUrls.slice(0, 4)
|
||||||
|
: [];
|
||||||
|
const directVideoRatio =
|
||||||
|
effectiveVideoParams?.ratio === "16:9" ||
|
||||||
|
effectiveVideoParams?.ratio === "4:3" ||
|
||||||
|
effectiveVideoParams?.ratio === "1:1" ||
|
||||||
|
effectiveVideoParams?.ratio === "3:4" ||
|
||||||
|
effectiveVideoParams?.ratio === "9:16" ||
|
||||||
|
effectiveVideoParams?.ratio === "21:9" ||
|
||||||
|
effectiveVideoParams?.ratio === "adaptive"
|
||||||
|
? effectiveVideoParams.ratio
|
||||||
|
: undefined;
|
||||||
|
const result = await executeDirectVideoGeneration({
|
||||||
|
prompt: generationPrompt,
|
||||||
|
mode,
|
||||||
|
images,
|
||||||
|
resolution: effectiveVideoParams?.resolution,
|
||||||
|
ratio: directVideoRatio,
|
||||||
|
duration: effectiveVideoParams?.duration,
|
||||||
|
preferredModelRef: finalModelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoUrl = String(result.videoUrl || "").trim();
|
||||||
|
if (!videoUrl) {
|
||||||
|
throw new Error("视频生成完成但未返回视频地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantText = prependImageModelBadge(
|
||||||
|
[
|
||||||
|
getDirectAgentToolSuccessMessage("generate_video"),
|
||||||
|
buildAgentToolVideoMarkdown(videoUrl),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n"),
|
||||||
|
result.modelDisplayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: assistantMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: assistantText }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: "temporary",
|
||||||
|
temporaryVideoUrl: videoUrl,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: (result.metadata || {}) as any,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await upsertConversationWorkingState({
|
||||||
|
userId: session.user.id,
|
||||||
|
chatId: id,
|
||||||
|
projectId: contextPayload?.projectId || null,
|
||||||
|
currentIntent: "video_gen",
|
||||||
|
lastMessageId: assistantMessageId,
|
||||||
|
});
|
||||||
|
} catch (memoryError) {
|
||||||
|
console.warn(
|
||||||
|
"[Chat API] Failed to update working state after direct video generation:",
|
||||||
|
memoryError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumeGeneratedVideoPoints({
|
||||||
|
userId: session.user.id,
|
||||||
|
chatId: id,
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
modelRefs: [result.modelRef || finalModelId],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoUrl) {
|
||||||
|
scheduleVideoArchive({
|
||||||
|
chatId: id,
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
text: assistantText,
|
||||||
|
temporaryVideoUrl: videoUrl,
|
||||||
|
taskId: assistantMessageId,
|
||||||
|
metadata: result.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
assistantText,
|
||||||
|
"agent:generate_video:result",
|
||||||
|
assistantMessageId,
|
||||||
|
);
|
||||||
|
if (contextPayload?.source === "canvas") {
|
||||||
|
writeCanvasMediaChunk(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
videoUrls: [videoUrl],
|
||||||
|
prompt: generationPrompt,
|
||||||
|
sourceImageUrls: directAgentToolTargetImageUrls,
|
||||||
|
},
|
||||||
|
"agent:generate_video:canvas-media",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (directToolError) {
|
||||||
|
console.error("[Chat API] Direct agent video generation failed:", directToolError);
|
||||||
|
const errorMessage = getDirectAgentToolErrorMessage(
|
||||||
|
"generate_video",
|
||||||
|
directToolError,
|
||||||
|
);
|
||||||
|
await saveMessages({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: assistantMessageId,
|
||||||
|
chatId: id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text: errorMessage }],
|
||||||
|
attachments: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
videoStorageStatus: null,
|
||||||
|
temporaryVideoUrl: null,
|
||||||
|
permanentVideoUrl: null,
|
||||||
|
archivedAt: null,
|
||||||
|
videoMetadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writeAssistantTextChunk(
|
||||||
|
writer,
|
||||||
|
errorMessage,
|
||||||
|
"agent:generate_video:error",
|
||||||
|
assistantMessageId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return createSseResponse(directVideoStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldRunAgentConversation &&
|
||||||
|
!smartAutoNeedsClarification &&
|
||||||
|
directAgentExecution &&
|
||||||
|
(directAgentExecution.toolName === "removeBackground" ||
|
||||||
|
directAgentExecution.toolName === "resizeImage" ||
|
||||||
|
directAgentExecution.toolName === "extendImage" ||
|
||||||
|
directAgentExecution.toolName === "boxCutout")
|
||||||
|
) {
|
||||||
|
const directAgentToolCapability = directAgentExecution.capability;
|
||||||
if (!directAgentToolTargetImageUrl) {
|
if (!directAgentToolTargetImageUrl) {
|
||||||
const missingTargetMessage = getDirectAgentToolTargetMissingMessage(
|
const missingTargetMessage = getDirectAgentToolTargetMissingMessage(
|
||||||
directAgentToolCapability,
|
directAgentToolCapability,
|
||||||
@ -3954,6 +4525,47 @@ export async function POST(request: Request) {
|
|||||||
resultThumbnailUrl = result.thumbnail_url;
|
resultThumbnailUrl = result.thumbnail_url;
|
||||||
resultImageSize = "2K";
|
resultImageSize = "2K";
|
||||||
resultAssetId = result.mediaAssetId || null;
|
resultAssetId = result.mediaAssetId || null;
|
||||||
|
} else if (directAgentToolCapability === "box_cutout") {
|
||||||
|
const plannedArgs = findAgentToolCallArgs(
|
||||||
|
"boxCutout",
|
||||||
|
smartAutoExecution,
|
||||||
|
smartAutoPlan,
|
||||||
|
);
|
||||||
|
const cropRect =
|
||||||
|
plannedArgs?.cropRect && typeof plannedArgs.cropRect === "object"
|
||||||
|
? (plannedArgs.cropRect as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!cropRect) {
|
||||||
|
throw new BoxCutoutServiceError(
|
||||||
|
"缺少框选区域,暂时无法执行框选抠图。",
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeBoxCutoutJob({
|
||||||
|
userId: session.user.id,
|
||||||
|
imageUrl: directAgentToolTargetImageUrl,
|
||||||
|
cropRect: {
|
||||||
|
x: toFiniteNumber(cropRect.x),
|
||||||
|
y: toFiniteNumber(cropRect.y),
|
||||||
|
width: toFiniteNumber(cropRect.width),
|
||||||
|
height: toFiniteNumber(cropRect.height),
|
||||||
|
},
|
||||||
|
paddingRatio: toFiniteNumber(plannedArgs?.paddingRatio),
|
||||||
|
prompt: requestTextContent || "框选抠图处理",
|
||||||
|
chatId: id,
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
sourceNodeId:
|
||||||
|
typeof plannedArgs?.sourceNodeId === "string"
|
||||||
|
? plannedArgs.sourceNodeId
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
resultOriginalUrl = result.original_url;
|
||||||
|
resultThumbnailUrl = result.thumbnail_url || null;
|
||||||
|
resultImageSize = "2K";
|
||||||
|
resultAssetId = result.mediaAssetId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantText = prependImageModelBadge(
|
const assistantText = prependImageModelBadge(
|
||||||
@ -4000,7 +4612,7 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (smartAutoExecution?.shouldPublishToCanvas && contextPayload?.source === "canvas") {
|
if (directAgentExecution.publishToCanvas && contextPayload?.source === "canvas") {
|
||||||
console.log(
|
console.log(
|
||||||
`[Chat API] Agent publishToCanvas satisfied by assistant media auto-sync (${directAgentToolCapability})`,
|
`[Chat API] Agent publishToCanvas satisfied by assistant media auto-sync (${directAgentToolCapability})`,
|
||||||
);
|
);
|
||||||
@ -4021,7 +4633,7 @@ export async function POST(request: Request) {
|
|||||||
messageId: assistantMessageId,
|
messageId: assistantMessageId,
|
||||||
imageUrls: resultOriginalUrl ? [resultOriginalUrl] : [],
|
imageUrls: resultOriginalUrl ? [resultOriginalUrl] : [],
|
||||||
prompt: requestTextContent || getDirectAgentToolLabel(directAgentToolCapability),
|
prompt: requestTextContent || getDirectAgentToolLabel(directAgentToolCapability),
|
||||||
sourceImageUrls: requestTargetResolution?.targetImageUrls || [],
|
sourceImageUrls: directAgentToolTargetImageUrls,
|
||||||
},
|
},
|
||||||
`agent:${directAgentToolCapability}:canvas-media`,
|
`agent:${directAgentToolCapability}:canvas-media`,
|
||||||
);
|
);
|
||||||
@ -4044,7 +4656,8 @@ export async function POST(request: Request) {
|
|||||||
if (
|
if (
|
||||||
!(directToolError instanceof RmbgServiceError) &&
|
!(directToolError instanceof RmbgServiceError) &&
|
||||||
!(directToolError instanceof ResizeServiceError) &&
|
!(directToolError instanceof ResizeServiceError) &&
|
||||||
!(directToolError instanceof ExtendServiceError)
|
!(directToolError instanceof ExtendServiceError) &&
|
||||||
|
!(directToolError instanceof BoxCutoutServiceError)
|
||||||
) {
|
) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[Chat API] Direct agent tool ${directAgentToolCapability} threw unexpected error type`,
|
`[Chat API] Direct agent tool ${directAgentToolCapability} threw unexpected error type`,
|
||||||
|
|||||||
@ -1,26 +1,23 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import sharp from "sharp";
|
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import {
|
||||||
executeRmbgJob,
|
BoxCutoutServiceError,
|
||||||
fetchImageBufferFromUrl,
|
executeBoxCutoutJob,
|
||||||
RmbgServiceError,
|
} from "@/lib/ai/box-cutout/service";
|
||||||
} from "@/lib/ai/rmbg/service";
|
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
type NormalizedCropRect = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BoxCutoutRequest = {
|
type BoxCutoutRequest = {
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
cropRect?: Partial<NormalizedCropRect>;
|
cropRect?: {
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
paddingRatio?: number;
|
paddingRatio?: number;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
chatId?: string | null;
|
chatId?: string | null;
|
||||||
@ -29,85 +26,6 @@ type BoxCutoutRequest = {
|
|||||||
sourceNodeId?: string;
|
sourceNodeId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_CROP_EDGE_PX = 24;
|
|
||||||
const DEFAULT_PADDING_RATIO = 0.08;
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(max, Math.max(min, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeNormalizedCropRect(input: Partial<NormalizedCropRect> | undefined): NormalizedCropRect | null {
|
|
||||||
if (!input) return null;
|
|
||||||
|
|
||||||
const x = Number(input.x);
|
|
||||||
const y = Number(input.y);
|
|
||||||
const width = Number(input.width);
|
|
||||||
const height = Number(input.height);
|
|
||||||
|
|
||||||
if (![x, y, width, height].every((value) => Number.isFinite(value))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized: NormalizedCropRect = {
|
|
||||||
x: clamp(x, 0, 1),
|
|
||||||
y: clamp(y, 0, 1),
|
|
||||||
width: clamp(width, 0, 1),
|
|
||||||
height: clamp(height, 0, 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (normalized.width <= 0 || normalized.height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized.width = Math.min(normalized.width, 1 - normalized.x);
|
|
||||||
normalized.height = Math.min(normalized.height, 1 - normalized.y);
|
|
||||||
|
|
||||||
if (normalized.width <= 0 || normalized.height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePaddingRatio(value: unknown) {
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return DEFAULT_PADDING_RATIO;
|
|
||||||
return clamp(numeric, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toPixelRect(
|
|
||||||
rect: NormalizedCropRect,
|
|
||||||
sourceWidth: number,
|
|
||||||
sourceHeight: number
|
|
||||||
) {
|
|
||||||
const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(sourceWidth - 1, 0));
|
|
||||||
const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(sourceHeight - 1, 0));
|
|
||||||
const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left);
|
|
||||||
const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top);
|
|
||||||
|
|
||||||
return { left, top, width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPadding(
|
|
||||||
rect: NormalizedCropRect,
|
|
||||||
paddingRatio: number
|
|
||||||
): NormalizedCropRect {
|
|
||||||
const padX = rect.width * paddingRatio;
|
|
||||||
const padY = rect.height * paddingRatio;
|
|
||||||
|
|
||||||
const x = clamp(rect.x - padX, 0, 1);
|
|
||||||
const y = clamp(rect.y - padY, 0, 1);
|
|
||||||
const right = clamp(rect.x + rect.width + padX, 0, 1);
|
|
||||||
const bottom = clamp(rect.y + rect.height + padY, 0, 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width: clamp(right - x, 0, 1),
|
|
||||||
height: clamp(bottom - y, 0, 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -117,78 +35,32 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = (await request.json()) as BoxCutoutRequest;
|
const body = (await request.json()) as BoxCutoutRequest;
|
||||||
const rawImageUrl = String(body.imageUrl || "").trim();
|
const rawImageUrl = String(body.imageUrl || "").trim();
|
||||||
const cropRect = sanitizeNormalizedCropRect(body.cropRect);
|
|
||||||
|
|
||||||
if (!rawImageUrl) {
|
if (!rawImageUrl) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cropRect) {
|
|
||||||
return NextResponse.json({ success: false, error: "缺少有效的 cropRect 参数" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
|
||||||
const paddingRatio = normalizePaddingRatio(body.paddingRatio);
|
const result = await executeBoxCutoutJob({
|
||||||
|
|
||||||
const sourceBuffer = await sharp(await fetchImageBufferFromUrl(resolvedImageUrl), { failOn: "none" })
|
|
||||||
.rotate()
|
|
||||||
.toBuffer();
|
|
||||||
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
|
|
||||||
const sourceMetadata = await sourceImage.metadata();
|
|
||||||
const sourceWidth = Number(sourceMetadata.width || 0);
|
|
||||||
const sourceHeight = Number(sourceMetadata.height || 0);
|
|
||||||
|
|
||||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
|
||||||
return NextResponse.json({ success: false, error: "无法识别原图尺寸" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
|
||||||
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: "框选区域过小,请重新框选" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
|
||||||
const paddedCropRectPixels = toPixelRect(paddedCropRect, sourceWidth, sourceHeight);
|
|
||||||
const croppedBuffer = await sourceImage
|
|
||||||
.extract({
|
|
||||||
left: paddedCropRectPixels.left,
|
|
||||||
top: paddedCropRectPixels.top,
|
|
||||||
width: paddedCropRectPixels.width,
|
|
||||||
height: paddedCropRectPixels.height,
|
|
||||||
})
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
const rmbgResult = await executeRmbgJob({
|
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
imageBuffer: croppedBuffer,
|
imageUrl: resolvedImageUrl,
|
||||||
imageBase64: croppedBuffer.toString("base64"),
|
cropRect: body.cropRect || {},
|
||||||
sourceImageUrl: resolvedImageUrl,
|
paddingRatio: body.paddingRatio,
|
||||||
prompt: body.prompt || "框选抠图",
|
prompt: body.prompt || "框选抠图",
|
||||||
chatId: body.chatId || null,
|
chatId: body.chatId || null,
|
||||||
messageId: body.messageId || null,
|
messageId: body.messageId || null,
|
||||||
skipGallerySave: body.skipGallerySave === true,
|
skipGallerySave: body.skipGallerySave === true,
|
||||||
|
sourceNodeId: body.sourceNodeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(result);
|
||||||
success: true,
|
|
||||||
...rmbgResult,
|
|
||||||
cropRectApplied: cropRect,
|
|
||||||
paddedCropRectApplied: paddedCropRect,
|
|
||||||
cropRectPixels,
|
|
||||||
paddedCropRectPixels,
|
|
||||||
sourceNodeId: body.sourceNodeId || null,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RmbgServiceError) {
|
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error.detail || error.message,
|
error: error.detail || error.message,
|
||||||
},
|
},
|
||||||
{ status: error.status }
|
{ status: error.status },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,7 +70,7 @@ export async function POST(request: NextRequest) {
|
|||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : "框选抠图失败",
|
error: error instanceof Error ? error.message : "框选抠图失败",
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { generateText } from "ai";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
|
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
|
||||||
import type { RouteResult } from "@/lib/ai/model-router";
|
import type { RouteResult } from "@/lib/ai/model-router";
|
||||||
|
import type { AgentSkillRouteResult } from "@/lib/ai/skill-router";
|
||||||
import {
|
import {
|
||||||
getEnabledAgentToolDefinitionsForContext,
|
getEnabledAgentToolDefinitionsForContext,
|
||||||
type AgentProjectToolDefinition,
|
type AgentProjectToolDefinition,
|
||||||
@ -1210,6 +1211,7 @@ async function buildExecutionPlanWithAgent(
|
|||||||
state: AutoAgentStateType,
|
state: AutoAgentStateType,
|
||||||
analysis: AnalysisResult,
|
analysis: AnalysisResult,
|
||||||
availableTools: AgentProjectToolDefinition[],
|
availableTools: AgentProjectToolDefinition[],
|
||||||
|
skillRoute?: AgentSkillRouteResult,
|
||||||
): Promise<AgentExecutionPlan> {
|
): Promise<AgentExecutionPlan> {
|
||||||
const fallbackPlan = buildFallbackPlan({
|
const fallbackPlan = buildFallbackPlan({
|
||||||
analysis,
|
analysis,
|
||||||
@ -1275,6 +1277,7 @@ async function buildExecutionPlanWithAgent(
|
|||||||
5. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
|
5. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
|
||||||
6. targetSource 要忠实反映当前目标图来源:explicit_selection/current_upload/text_reference/working_memory/latest_result/clarification/none。
|
6. targetSource 要忠实反映当前目标图来源:explicit_selection/current_upload/text_reference/working_memory/latest_result/clarification/none。
|
||||||
7. preferredCapability 要尽量具体,例如 remove_background / enhance / extend / resize / box_cutout / publish_canvas。
|
7. preferredCapability 要尽量具体,例如 remove_background / enhance / extend / resize / box_cutout / publish_canvas。
|
||||||
|
8. 必须遵守当前专家路由约束;如果约束要求纯对话,则绝不能输出 generate_image / edit_image / generate_video。
|
||||||
|
|
||||||
输出格式:
|
输出格式:
|
||||||
{
|
{
|
||||||
@ -1306,6 +1309,12 @@ async function buildExecutionPlanWithAgent(
|
|||||||
`历史图数量:${state.historyImageCount}`,
|
`历史图数量:${state.historyImageCount}`,
|
||||||
`画布选中图数量:${state.selectedImageCount}`,
|
`画布选中图数量:${state.selectedImageCount}`,
|
||||||
`基础分析:intent=${analysis.intent}; subIntent=${analysis.subIntent || "none"}; confidence=${analysis.confidence}`,
|
`基础分析:intent=${analysis.intent}; subIntent=${analysis.subIntent || "none"}; confidence=${analysis.confidence}`,
|
||||||
|
skillRoute
|
||||||
|
? `专家路由:${skillRoute.primary.specialistId}|原因:${skillRoute.reason}`
|
||||||
|
: "",
|
||||||
|
skillRoute?.planningHints?.length
|
||||||
|
? `专家约束:\n${skillRoute.planningHints.map((item, index) => `${index + 1}. ${item}`).join("\n")}`
|
||||||
|
: "",
|
||||||
state.context ? `画布上下文:${compactText(state.context, 600)}` : "",
|
state.context ? `画布上下文:${compactText(state.context, 600)}` : "",
|
||||||
historyContext ? `最近对话:\n${historyContext}` : "",
|
historyContext ? `最近对话:\n${historyContext}` : "",
|
||||||
`可用工具:\n${toolCatalog}`,
|
`可用工具:\n${toolCatalog}`,
|
||||||
@ -1318,7 +1327,23 @@ async function buildExecutionPlanWithAgent(
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
const parsed = parseJsonBlock<Record<string, unknown>>(text);
|
const parsed = parseJsonBlock<Record<string, unknown>>(text);
|
||||||
const planned = sanitizeAgentPlan(parsed, fallbackPlan, availableTools);
|
let planned = sanitizeAgentPlan(parsed, fallbackPlan, availableTools);
|
||||||
|
if (skillRoute) {
|
||||||
|
const { refinePlanWithSpecialist } = await import("./specialists");
|
||||||
|
for (const specialist of [skillRoute.primary, ...skillRoute.overlays]) {
|
||||||
|
planned = refinePlanWithSpecialist(specialist, {
|
||||||
|
text: state.text,
|
||||||
|
analysis,
|
||||||
|
plan: planned,
|
||||||
|
availableTools,
|
||||||
|
hasImage: state.hasImage,
|
||||||
|
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
|
||||||
|
targetResolutionMode: state.targetResolutionMode,
|
||||||
|
mediaSource: state.mediaSource,
|
||||||
|
selectedImageCount: state.selectedImageCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`[Auto Agent] Planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`,
|
`[Auto Agent] Planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`,
|
||||||
);
|
);
|
||||||
@ -1766,13 +1791,36 @@ const analyzeNode = async (state: AutoAgentStateType) => {
|
|||||||
|
|
||||||
const planNode = async (state: AutoAgentStateType) => {
|
const planNode = async (state: AutoAgentStateType) => {
|
||||||
if (!state.analysis) return {};
|
if (!state.analysis) return {};
|
||||||
const availableTools = await getEnabledAgentToolDefinitionsForContext({
|
const baseAvailableTools = await getEnabledAgentToolDefinitionsForContext({
|
||||||
hasImage: state.hasImage,
|
hasImage: state.hasImage,
|
||||||
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
|
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
|
||||||
hasHistoryImage: state.historyImageCount > 0 || state.imageFromHistory,
|
hasHistoryImage: state.historyImageCount > 0 || state.imageFromHistory,
|
||||||
hasCurrentUpload: state.currentUploadImageCount > 0,
|
hasCurrentUpload: state.currentUploadImageCount > 0,
|
||||||
});
|
});
|
||||||
const plan = await buildExecutionPlanWithAgent(state, state.analysis, availableTools);
|
const { filterToolsForSkillRoute, routeAgentSkill } = await import("./skill-router");
|
||||||
|
const skillRoute = routeAgentSkill({
|
||||||
|
text: state.text,
|
||||||
|
analysis: state.analysis,
|
||||||
|
hasImage: state.hasImage,
|
||||||
|
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
|
||||||
|
targetResolutionMode: state.targetResolutionMode,
|
||||||
|
mediaSource: state.mediaSource,
|
||||||
|
selectedImageCount: state.selectedImageCount,
|
||||||
|
});
|
||||||
|
const availableTools = filterToolsForSkillRoute(baseAvailableTools, skillRoute);
|
||||||
|
console.log("[Auto Agent] Skill route:", {
|
||||||
|
specialist: skillRoute.primary.specialistId,
|
||||||
|
domain: skillRoute.primary.domain,
|
||||||
|
overlays: skillRoute.overlays.map((item) => item.specialistId),
|
||||||
|
allowlist: skillRoute.toolAllowlist,
|
||||||
|
reason: skillRoute.reason,
|
||||||
|
});
|
||||||
|
const plan = await buildExecutionPlanWithAgent(
|
||||||
|
state,
|
||||||
|
state.analysis,
|
||||||
|
availableTools,
|
||||||
|
skillRoute,
|
||||||
|
);
|
||||||
const alignedAnalysis = alignAnalysisWithPlan(state.analysis, plan);
|
const alignedAnalysis = alignAnalysisWithPlan(state.analysis, plan);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
210
lib/ai/box-cutout/service.ts
Normal file
210
lib/ai/box-cutout/service.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
import {
|
||||||
|
executeRmbgJob,
|
||||||
|
fetchImageBufferFromUrl,
|
||||||
|
} from "@/lib/ai/rmbg/service";
|
||||||
|
|
||||||
|
export type NormalizedCropRect = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ExecuteBoxCutoutJobParams {
|
||||||
|
userId: string;
|
||||||
|
imageUrl: string;
|
||||||
|
cropRect: Partial<NormalizedCropRect>;
|
||||||
|
paddingRatio?: number;
|
||||||
|
prompt?: string;
|
||||||
|
chatId?: string | null;
|
||||||
|
messageId?: string | null;
|
||||||
|
skipGallerySave?: boolean;
|
||||||
|
sourceNodeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteBoxCutoutJobResult {
|
||||||
|
success: true;
|
||||||
|
original_url: string;
|
||||||
|
thumbnail_url?: string | null;
|
||||||
|
generatedImageId?: string | null;
|
||||||
|
mediaAssetId?: string | null;
|
||||||
|
cropRectApplied: NormalizedCropRect;
|
||||||
|
paddedCropRectApplied: NormalizedCropRect;
|
||||||
|
cropRectPixels: {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
paddedCropRectPixels: {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
sourceNodeId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoxCutoutServiceError extends Error {
|
||||||
|
status: number;
|
||||||
|
detail?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status = 400, detail?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "BoxCutoutServiceError";
|
||||||
|
this.status = status;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_CROP_EDGE_PX = 24;
|
||||||
|
const DEFAULT_PADDING_RATIO = 0.08;
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeNormalizedCropRect(
|
||||||
|
input: Partial<NormalizedCropRect> | undefined,
|
||||||
|
): NormalizedCropRect | null {
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
const x = Number(input.x);
|
||||||
|
const y = Number(input.y);
|
||||||
|
const width = Number(input.width);
|
||||||
|
const height = Number(input.height);
|
||||||
|
|
||||||
|
if (![x, y, width, height].every((value) => Number.isFinite(value))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: NormalizedCropRect = {
|
||||||
|
x: clamp(x, 0, 1),
|
||||||
|
y: clamp(y, 0, 1),
|
||||||
|
width: clamp(width, 0, 1),
|
||||||
|
height: clamp(height, 0, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (normalized.width <= 0 || normalized.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.width = Math.min(normalized.width, 1 - normalized.x);
|
||||||
|
normalized.height = Math.min(normalized.height, 1 - normalized.y);
|
||||||
|
|
||||||
|
if (normalized.width <= 0 || normalized.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBoxCutoutPaddingRatio(value: unknown) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return DEFAULT_PADDING_RATIO;
|
||||||
|
return clamp(numeric, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPixelRect(
|
||||||
|
rect: NormalizedCropRect,
|
||||||
|
sourceWidth: number,
|
||||||
|
sourceHeight: number,
|
||||||
|
) {
|
||||||
|
const left = clamp(Math.round(rect.x * sourceWidth), 0, Math.max(sourceWidth - 1, 0));
|
||||||
|
const top = clamp(Math.round(rect.y * sourceHeight), 0, Math.max(sourceHeight - 1, 0));
|
||||||
|
const width = clamp(Math.round(rect.width * sourceWidth), 1, sourceWidth - left);
|
||||||
|
const height = clamp(Math.round(rect.height * sourceHeight), 1, sourceHeight - top);
|
||||||
|
|
||||||
|
return { left, top, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPadding(
|
||||||
|
rect: NormalizedCropRect,
|
||||||
|
paddingRatio: number,
|
||||||
|
): NormalizedCropRect {
|
||||||
|
const padX = rect.width * paddingRatio;
|
||||||
|
const padY = rect.height * paddingRatio;
|
||||||
|
|
||||||
|
const x = clamp(rect.x - padX, 0, 1);
|
||||||
|
const y = clamp(rect.y - padY, 0, 1);
|
||||||
|
const right = clamp(rect.x + rect.width + padX, 0, 1);
|
||||||
|
const bottom = clamp(rect.y + rect.height + padY, 0, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: clamp(right - x, 0, 1),
|
||||||
|
height: clamp(bottom - y, 0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBoxCutoutJob(
|
||||||
|
params: ExecuteBoxCutoutJobParams,
|
||||||
|
): Promise<ExecuteBoxCutoutJobResult> {
|
||||||
|
const imageUrl = String(params.imageUrl || "").trim();
|
||||||
|
if (!imageUrl) {
|
||||||
|
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
||||||
|
if (!cropRect) {
|
||||||
|
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
||||||
|
const sourceBuffer = await sharp(await fetchImageBufferFromUrl(imageUrl), {
|
||||||
|
failOn: "none",
|
||||||
|
})
|
||||||
|
.rotate()
|
||||||
|
.toBuffer();
|
||||||
|
const sourceImage = sharp(sourceBuffer, { failOn: "none" });
|
||||||
|
const sourceMetadata = await sourceImage.metadata();
|
||||||
|
const sourceWidth = Number(sourceMetadata.width || 0);
|
||||||
|
const sourceHeight = Number(sourceMetadata.height || 0);
|
||||||
|
|
||||||
|
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||||
|
throw new BoxCutoutServiceError("无法识别原图尺寸", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
||||||
|
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
|
||||||
|
throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
||||||
|
const paddedCropRectPixels = toPixelRect(paddedCropRect, sourceWidth, sourceHeight);
|
||||||
|
const croppedBuffer = await sourceImage
|
||||||
|
.extract({
|
||||||
|
left: paddedCropRectPixels.left,
|
||||||
|
top: paddedCropRectPixels.top,
|
||||||
|
width: paddedCropRectPixels.width,
|
||||||
|
height: paddedCropRectPixels.height,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const result = await executeRmbgJob({
|
||||||
|
userId: params.userId,
|
||||||
|
imageBuffer: croppedBuffer,
|
||||||
|
imageBase64: croppedBuffer.toString("base64"),
|
||||||
|
sourceImageUrl: imageUrl,
|
||||||
|
prompt: params.prompt || "框选抠图",
|
||||||
|
chatId: params.chatId || null,
|
||||||
|
messageId: params.messageId || null,
|
||||||
|
skipGallerySave: params.skipGallerySave === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
original_url: result.original_url,
|
||||||
|
thumbnail_url: result.thumbnail_url,
|
||||||
|
generatedImageId: result.generatedImageId,
|
||||||
|
mediaAssetId: result.mediaAssetId,
|
||||||
|
cropRectApplied: cropRect,
|
||||||
|
paddedCropRectApplied: paddedCropRect,
|
||||||
|
cropRectPixels,
|
||||||
|
paddedCropRectPixels,
|
||||||
|
sourceNodeId: params.sourceNodeId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
import { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
||||||
|
|
||||||
|
type EnabledModelWithProvider = Awaited<
|
||||||
|
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
|
||||||
|
>[number];
|
||||||
|
|
||||||
// 使用动态导入避免循环依赖
|
// 使用动态导入避免循环依赖
|
||||||
// import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
// import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
|
|
||||||
@ -27,6 +31,94 @@ function hasVisionCapability(model: {
|
|||||||
return Boolean(model.supportsVision) || isAiModelMultimodal(model);
|
return Boolean(model.supportsVision) || isAiModelMultimodal(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHAT_PROVIDER_TYPES = [
|
||||||
|
"alibaba",
|
||||||
|
"zhipu",
|
||||||
|
"openai",
|
||||||
|
"custom",
|
||||||
|
"local",
|
||||||
|
"gemini",
|
||||||
|
"huanke-gemini",
|
||||||
|
"volcano-multimodal",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const VIDEO_PROVIDER_TYPES = new Set([
|
||||||
|
"doubao-video",
|
||||||
|
"volcano-video",
|
||||||
|
"qianwen-video",
|
||||||
|
"fal-video",
|
||||||
|
"ruxa-video",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
||||||
|
"alibaba-image",
|
||||||
|
"ruxa-image",
|
||||||
|
"fal-image",
|
||||||
|
"openai-image",
|
||||||
|
"midjourney",
|
||||||
|
"gemini-image",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isVideoProviderType(providerType: string) {
|
||||||
|
return VIDEO_PROVIDER_TYPES.has(providerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConversationalCandidate(
|
||||||
|
item: EnabledModelWithProvider,
|
||||||
|
options?: { requireVision?: boolean },
|
||||||
|
) {
|
||||||
|
const providerType = String(item.provider.type || "");
|
||||||
|
if (!item.model.isEnabled || item.model.isSubModel) return false;
|
||||||
|
if (isVideoProviderType(providerType)) return false;
|
||||||
|
|
||||||
|
const requireVision = Boolean(options?.requireVision);
|
||||||
|
const matchesPreferredProvider = CHAT_PROVIDER_TYPES.includes(
|
||||||
|
providerType as (typeof CHAT_PROVIDER_TYPES)[number],
|
||||||
|
);
|
||||||
|
const hasVision = hasVisionCapability(item.model);
|
||||||
|
|
||||||
|
if (matchesPreferredProvider) {
|
||||||
|
return requireVision ? hasVision || providerType === "volcano-multimodal" : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IMAGE_ONLY_PROVIDER_TYPES.has(providerType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireVision) {
|
||||||
|
return hasVision;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !item.model.supportsImageGen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesIntentRequirements(
|
||||||
|
item: EnabledModelWithProvider,
|
||||||
|
params: {
|
||||||
|
intent: string;
|
||||||
|
requireVision?: boolean;
|
||||||
|
requireImageGen?: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!item.model.isEnabled || item.model.isSubModel) return false;
|
||||||
|
|
||||||
|
if (params.intent === "chat") {
|
||||||
|
return isConversationalCandidate(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.intent === "vision") {
|
||||||
|
return (
|
||||||
|
!isVideoProviderType(String(item.provider.type || "")) &&
|
||||||
|
hasVisionCapability(item.model)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requireVision && !hasVisionCapability(item.model)) return false;
|
||||||
|
if (params.requireImageGen && !item.model.supportsImageGen) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// 意图到模型的映射配置
|
// 意图到模型的映射配置
|
||||||
// providerTypes 按优先级排序,第一个是首选
|
// providerTypes 按优先级排序,第一个是首选
|
||||||
const INTENT_MODEL_MAP: Record<
|
const INTENT_MODEL_MAP: Record<
|
||||||
@ -48,7 +140,7 @@ const INTENT_MODEL_MAP: Record<
|
|||||||
|
|
||||||
// 视觉理解:优先千问3 VL(成本低效果好)
|
// 视觉理解:优先千问3 VL(成本低效果好)
|
||||||
vision: {
|
vision: {
|
||||||
providerTypes: ["alibaba", "gemini", "huanke-gemini"],
|
providerTypes: ["alibaba", "zhipu", "openai", "custom", "gemini", "huanke-gemini", "volcano-multimodal"],
|
||||||
requireVision: true,
|
requireVision: true,
|
||||||
description: "图片理解",
|
description: "图片理解",
|
||||||
},
|
},
|
||||||
@ -68,7 +160,7 @@ const INTENT_MODEL_MAP: Record<
|
|||||||
|
|
||||||
// 普通对话:优先千问3
|
// 普通对话:优先千问3
|
||||||
chat: {
|
chat: {
|
||||||
providerTypes: ["alibaba", "openai", "local"],
|
providerTypes: ["alibaba", "zhipu", "openai", "custom", "local", "gemini", "huanke-gemini", "volcano-multimodal"],
|
||||||
description: "对话",
|
description: "对话",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -147,7 +239,7 @@ export async function routeToModel(params: {
|
|||||||
const [settings] = await db.select().from(siteSettings).limit(1);
|
const [settings] = await db.select().from(siteSettings).limit(1);
|
||||||
const configuredDefaultModelId = settings?.canvasAutoModeDefaultModelId;
|
const configuredDefaultModelId = settings?.canvasAutoModeDefaultModelId;
|
||||||
|
|
||||||
if (configuredDefaultModelId) {
|
if (configuredDefaultModelId && intent !== "chat") {
|
||||||
const configuredModel = allModels.find(
|
const configuredModel = allModels.find(
|
||||||
(m) => m.model.id === configuredDefaultModelId && m.model.isEnabled && !m.model.isSubModel
|
(m) => m.model.id === configuredDefaultModelId && m.model.isEnabled && !m.model.isSubModel
|
||||||
);
|
);
|
||||||
@ -182,6 +274,8 @@ export async function routeToModel(params: {
|
|||||||
} else {
|
} else {
|
||||||
console.log("[Model Router] ⚠️ Configured default model not found or disabled, falling back to intent routing");
|
console.log("[Model Router] ⚠️ Configured default model not found or disabled, falling back to intent routing");
|
||||||
}
|
}
|
||||||
|
} else if (configuredDefaultModelId && intent === "chat") {
|
||||||
|
console.log("[Model Router] ℹ️ Skip configured default image model for chat intent");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按优先级查找匹配的模型
|
// 按优先级查找匹配的模型
|
||||||
@ -190,17 +284,11 @@ export async function routeToModel(params: {
|
|||||||
// 匹配 provider 类型
|
// 匹配 provider 类型
|
||||||
if (m.provider.type !== providerType) return false;
|
if (m.provider.type !== providerType) return false;
|
||||||
|
|
||||||
// 检查能力要求
|
return matchesIntentRequirements(m, {
|
||||||
if (config.requireVision && !hasVisionCapability(m.model)) return false;
|
intent,
|
||||||
if (config.requireImageGen && !m.model.supportsImageGen) return false;
|
requireVision: config.requireVision,
|
||||||
|
requireImageGen: config.requireImageGen,
|
||||||
// 排除子模型(只选主模型)
|
});
|
||||||
if (m.model.isSubModel) return false;
|
|
||||||
|
|
||||||
// 必须启用
|
|
||||||
if (!m.model.isEnabled) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
@ -223,17 +311,24 @@ export async function routeToModel(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底逻辑:返回任意可用的图片编辑模型
|
const fallbackCandidates = allModels.filter((item) => {
|
||||||
const fallbackCandidates = allModels.filter(
|
if (intent === "chat") {
|
||||||
(m) =>
|
return isConversationalCandidate(item, { requireVision: false });
|
||||||
!m.model.isSubModel &&
|
}
|
||||||
m.model.isEnabled &&
|
if (intent === "vision") {
|
||||||
(m.model.supportsImageGen || m.model.supportsVision)
|
return isConversationalCandidate(item, { requireVision: true });
|
||||||
);
|
}
|
||||||
|
return (
|
||||||
|
!item.model.isSubModel &&
|
||||||
|
item.model.isEnabled &&
|
||||||
|
!isVideoProviderType(String(item.provider.type || "")) &&
|
||||||
|
(item.model.supportsImageGen || hasVisionCapability(item.model))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (fallbackCandidates.length > 0) {
|
if (fallbackCandidates.length > 0) {
|
||||||
const fallback = fallbackCandidates[0];
|
const fallback = fallbackCandidates[0];
|
||||||
console.log("[Model Router] Using fallback (with image capability):", fallback.model.displayName);
|
console.log("[Model Router] Using intent-safe fallback:", fallback.model.displayName);
|
||||||
return {
|
return {
|
||||||
id: fallback.model.id,
|
id: fallback.model.id,
|
||||||
displayName: fallback.model.displayName,
|
displayName: fallback.model.displayName,
|
||||||
@ -244,7 +339,17 @@ export async function routeToModel(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 最终兜底:返回任意可用模型
|
// 最终兜底:返回任意可用模型
|
||||||
const anyModel = allModels.find((m) => !m.model.isSubModel && m.model.isEnabled);
|
const anyModel =
|
||||||
|
(intent === "chat"
|
||||||
|
? allModels.find(
|
||||||
|
(m) =>
|
||||||
|
!m.model.isSubModel &&
|
||||||
|
m.model.isEnabled &&
|
||||||
|
!isVideoProviderType(String(m.provider.type || "")) &&
|
||||||
|
!m.model.supportsImageGen,
|
||||||
|
)
|
||||||
|
: undefined) ||
|
||||||
|
allModels.find((m) => !m.model.isSubModel && m.model.isEnabled);
|
||||||
if (anyModel) {
|
if (anyModel) {
|
||||||
console.log("[Model Router] Using any available model:", anyModel.model.displayName);
|
console.log("[Model Router] Using any available model:", anyModel.model.displayName);
|
||||||
return {
|
return {
|
||||||
|
|||||||
243
lib/ai/skill-router.ts
Normal file
243
lib/ai/skill-router.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import type {
|
||||||
|
AgentProjectToolDefinition,
|
||||||
|
AgentProjectToolName,
|
||||||
|
} from "@/lib/ai/tools-registry";
|
||||||
|
import type {
|
||||||
|
AgentSpecialistContext,
|
||||||
|
AgentSpecialistRoute,
|
||||||
|
} from "@/lib/ai/specialists";
|
||||||
|
|
||||||
|
export interface AgentSkillRouteResult {
|
||||||
|
primary: AgentSpecialistRoute;
|
||||||
|
overlays: AgentSpecialistRoute[];
|
||||||
|
toolAllowlist: AgentProjectToolName[];
|
||||||
|
planningHints: string[];
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompactText(text: string) {
|
||||||
|
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeToolNames(toolNames: AgentProjectToolName[]) {
|
||||||
|
return Array.from(new Set(toolNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPureGreeting(text: string) {
|
||||||
|
const normalized = normalizeCompactText(text);
|
||||||
|
if (!normalized) return false;
|
||||||
|
return /^(你好|您好|hello|hi|嗨|哈喽|在吗|早上好|中午好|下午好|晚上好|深夜好|谢谢|感谢|收到|好的|ok|okay|明白了|了解了)$/.test(
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCanvasContext(context: AgentSpecialistContext) {
|
||||||
|
return (
|
||||||
|
context.hasCanvasSelection ||
|
||||||
|
(context.selectedImageCount || 0) > 0 ||
|
||||||
|
context.mediaSource === "canvas_selection" ||
|
||||||
|
context.targetResolutionMode === "explicit_selection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionsCanvas(text: string) {
|
||||||
|
return /画布|发布到画布|同步到画布|发送到画布|放到画布/.test(
|
||||||
|
String(text || "").toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoute(
|
||||||
|
route: AgentSpecialistRoute,
|
||||||
|
toolAllowlist: AgentProjectToolName[],
|
||||||
|
planningHints: string[],
|
||||||
|
): AgentSpecialistRoute {
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
toolAllowlist: dedupeToolNames(toolAllowlist),
|
||||||
|
planningHints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeAgentSkill(
|
||||||
|
context: AgentSpecialistContext,
|
||||||
|
): AgentSkillRouteResult {
|
||||||
|
const greetingTurn = isPureGreeting(context.text);
|
||||||
|
|
||||||
|
const conversationRoute = createRoute(
|
||||||
|
{
|
||||||
|
domain: "conversation",
|
||||||
|
specialistId: "conversation-coordinator",
|
||||||
|
reason: greetingTurn ? "纯打招呼/寒暄" : "普通对话回合",
|
||||||
|
planningHints: [],
|
||||||
|
},
|
||||||
|
["respondDirect"],
|
||||||
|
[
|
||||||
|
"当前回合是纯对话,不要调用生图、生视频或修图工具。",
|
||||||
|
"即使当前有图片,也只能先自然回复,不能直接执行。",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visionRoute = createRoute(
|
||||||
|
{
|
||||||
|
domain: "vision",
|
||||||
|
specialistId: "vision-analyst",
|
||||||
|
reason: "当前回合以看图分析为主",
|
||||||
|
planningHints: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"analyzeImage",
|
||||||
|
"respondDirect",
|
||||||
|
"useSelectedCanvasImage",
|
||||||
|
"useLatestResultImage",
|
||||||
|
"useOriginalUploadImage",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"当前回合重点是识图、分析、解释或给建议,不要误切到生图。",
|
||||||
|
"如有目标图,优先围绕目标图做分析,再给用户结论。",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageEditRoute = createRoute(
|
||||||
|
{
|
||||||
|
domain: "image_edit",
|
||||||
|
specialistId: "image-edit-specialist",
|
||||||
|
reason: "当前回合是修图/抠图/增强类任务",
|
||||||
|
planningHints: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"editImage",
|
||||||
|
"removeBackground",
|
||||||
|
"enhanceImage",
|
||||||
|
"extendImage",
|
||||||
|
"resizeImage",
|
||||||
|
"boxCutout",
|
||||||
|
"useSelectedCanvasImage",
|
||||||
|
"useLatestResultImage",
|
||||||
|
"useOriginalUploadImage",
|
||||||
|
"publishToCanvas",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"当前回合必须收敛到修图链路,优先选择最具体的修图工具。",
|
||||||
|
"除非用户明确要求生成新图,否则不要把修图误判成生图。",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageGenerateRoute = createRoute(
|
||||||
|
{
|
||||||
|
domain: "image_generate",
|
||||||
|
specialistId: "image-generate-specialist",
|
||||||
|
reason: "当前回合是生图任务",
|
||||||
|
planningHints: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"generateImage",
|
||||||
|
"useSelectedCanvasImage",
|
||||||
|
"useLatestResultImage",
|
||||||
|
"useOriginalUploadImage",
|
||||||
|
"publishToCanvas",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"当前回合是生成新图,聚焦生成链路,不要退回修图工具。",
|
||||||
|
"如用户带参考图,仅把参考图作为风格/构图条件,不要误当纯分析。",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoRoute = createRoute(
|
||||||
|
{
|
||||||
|
domain: "video_generate",
|
||||||
|
specialistId: "video-specialist",
|
||||||
|
reason: "当前回合是视频生成任务",
|
||||||
|
planningHints: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"generateVideo",
|
||||||
|
"useSelectedCanvasImage",
|
||||||
|
"useLatestResultImage",
|
||||||
|
"useOriginalUploadImage",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"当前回合是视频任务,只规划视频生成相关动作。",
|
||||||
|
"如果信息不足可以先澄清,但不要误切回图片生成。",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasOverlay = createRoute(
|
||||||
|
{
|
||||||
|
domain: "canvas",
|
||||||
|
specialistId: "canvas-specialist",
|
||||||
|
reason: "检测到画布上下文,需要优先绑定目标图并回传结果",
|
||||||
|
planningHints: [],
|
||||||
|
},
|
||||||
|
[
|
||||||
|
"useSelectedCanvasImage",
|
||||||
|
"useLatestResultImage",
|
||||||
|
"useOriginalUploadImage",
|
||||||
|
"publishToCanvas",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"如果用户在无限画布中操作,优先绑定选中图或最近结果图。",
|
||||||
|
"修图/生图结果优先同步回画布,避免只停留在聊天气泡。",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let primary = conversationRoute;
|
||||||
|
|
||||||
|
if (!greetingTurn) {
|
||||||
|
switch (context.analysis.intent) {
|
||||||
|
case "vision":
|
||||||
|
primary = visionRoute;
|
||||||
|
break;
|
||||||
|
case "image_edit":
|
||||||
|
primary = imageEditRoute;
|
||||||
|
break;
|
||||||
|
case "image_gen":
|
||||||
|
primary = imageGenerateRoute;
|
||||||
|
break;
|
||||||
|
case "video_gen":
|
||||||
|
primary = videoRoute;
|
||||||
|
break;
|
||||||
|
case "chat":
|
||||||
|
default:
|
||||||
|
primary = conversationRoute;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlays =
|
||||||
|
primary.domain === "conversation"
|
||||||
|
? []
|
||||||
|
: hasCanvasContext(context) || mentionsCanvas(context.text)
|
||||||
|
? [canvasOverlay]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const toolAllowlist = dedupeToolNames([
|
||||||
|
...(primary.toolAllowlist || []),
|
||||||
|
...overlays.flatMap((item) => item.toolAllowlist || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const planningHints = [
|
||||||
|
...primary.planningHints,
|
||||||
|
...overlays.flatMap((item) => item.planningHints),
|
||||||
|
];
|
||||||
|
|
||||||
|
const reason = [primary.reason, ...overlays.map((item) => item.reason)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
overlays,
|
||||||
|
toolAllowlist,
|
||||||
|
planningHints,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterToolsForSkillRoute(
|
||||||
|
availableTools: AgentProjectToolDefinition[],
|
||||||
|
route: AgentSkillRouteResult,
|
||||||
|
) {
|
||||||
|
if (!route.toolAllowlist.length) return availableTools;
|
||||||
|
const allowSet = new Set(route.toolAllowlist);
|
||||||
|
return availableTools.filter((tool) => allowSet.has(tool.name));
|
||||||
|
}
|
||||||
300
lib/ai/specialists/index.ts
Normal file
300
lib/ai/specialists/index.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
|
||||||
|
import type {
|
||||||
|
AgentExecutionPlan,
|
||||||
|
AgentPlanNextAction,
|
||||||
|
AgentPlanResponseMode,
|
||||||
|
AgentPlannedToolCall,
|
||||||
|
AgentPreferredCapability,
|
||||||
|
AgentTargetResolutionMode,
|
||||||
|
} from "@/lib/ai/auto-agent-graph";
|
||||||
|
import type {
|
||||||
|
AgentProjectToolDefinition,
|
||||||
|
AgentProjectToolName,
|
||||||
|
} from "@/lib/ai/tools-registry";
|
||||||
|
|
||||||
|
export type AgentSpecialistDomain =
|
||||||
|
| "conversation"
|
||||||
|
| "vision"
|
||||||
|
| "image_edit"
|
||||||
|
| "image_generate"
|
||||||
|
| "video_generate"
|
||||||
|
| "canvas";
|
||||||
|
|
||||||
|
export type AgentSpecialistId =
|
||||||
|
| "conversation-coordinator"
|
||||||
|
| "vision-analyst"
|
||||||
|
| "image-edit-specialist"
|
||||||
|
| "image-generate-specialist"
|
||||||
|
| "video-specialist"
|
||||||
|
| "canvas-specialist";
|
||||||
|
|
||||||
|
export interface AgentSpecialistRoute {
|
||||||
|
domain: AgentSpecialistDomain;
|
||||||
|
specialistId: AgentSpecialistId;
|
||||||
|
reason: string;
|
||||||
|
planningHints: string[];
|
||||||
|
toolAllowlist?: AgentProjectToolName[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSpecialistContext {
|
||||||
|
text: string;
|
||||||
|
analysis: AnalysisResult;
|
||||||
|
hasImage: boolean;
|
||||||
|
hasCanvasSelection: boolean;
|
||||||
|
targetResolutionMode: AgentTargetResolutionMode;
|
||||||
|
mediaSource?: "current_message" | "canvas_selection" | "history" | "none";
|
||||||
|
selectedImageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPlanRefineInput extends AgentSpecialistContext {
|
||||||
|
plan: AgentExecutionPlan;
|
||||||
|
availableTools: AgentProjectToolDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTool(
|
||||||
|
availableTools: AgentProjectToolDefinition[],
|
||||||
|
name: AgentProjectToolName,
|
||||||
|
) {
|
||||||
|
return availableTools.some((tool) => tool.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertTool(
|
||||||
|
toolCalls: AgentPlannedToolCall[],
|
||||||
|
name: AgentProjectToolName,
|
||||||
|
reason: string,
|
||||||
|
options?: {
|
||||||
|
prepend?: boolean;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const existing = toolCalls.find((tool) => tool.name === name);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.reason) {
|
||||||
|
existing.reason = reason;
|
||||||
|
}
|
||||||
|
if (!existing.args && options?.args) {
|
||||||
|
existing.args = options.args;
|
||||||
|
}
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTool = {
|
||||||
|
name,
|
||||||
|
reason,
|
||||||
|
args: options?.args,
|
||||||
|
} satisfies AgentPlannedToolCall;
|
||||||
|
|
||||||
|
return options?.prepend ? [nextTool, ...toolCalls] : [...toolCalls, nextTool];
|
||||||
|
}
|
||||||
|
|
||||||
|
function forcePlanShape(params: {
|
||||||
|
plan: AgentExecutionPlan;
|
||||||
|
nextAction: AgentPlanNextAction;
|
||||||
|
intent: AnalysisResult["intent"];
|
||||||
|
responseMode?: AgentPlanResponseMode;
|
||||||
|
preferredCapability?: AgentPreferredCapability;
|
||||||
|
subIntent?: string;
|
||||||
|
toolCalls: AgentPlannedToolCall[];
|
||||||
|
shouldUseStructuredReply?: boolean;
|
||||||
|
targetSource?: AgentTargetResolutionMode;
|
||||||
|
}): AgentExecutionPlan {
|
||||||
|
return {
|
||||||
|
...params.plan,
|
||||||
|
nextAction: params.nextAction,
|
||||||
|
intent: params.intent,
|
||||||
|
responseMode:
|
||||||
|
params.responseMode ?? params.plan.responseMode,
|
||||||
|
preferredCapability:
|
||||||
|
params.preferredCapability ?? params.plan.preferredCapability,
|
||||||
|
subIntent:
|
||||||
|
params.intent === "image_edit"
|
||||||
|
? params.subIntent ?? params.plan.subIntent
|
||||||
|
: undefined,
|
||||||
|
toolCalls: params.toolCalls,
|
||||||
|
shouldUseStructuredReply:
|
||||||
|
params.shouldUseStructuredReply ?? params.plan.shouldUseStructuredReply,
|
||||||
|
targetSource: params.targetSource ?? params.plan.targetSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditCapability(
|
||||||
|
analysis: AnalysisResult,
|
||||||
|
text: string,
|
||||||
|
): {
|
||||||
|
capability: AgentPreferredCapability;
|
||||||
|
toolName: AgentProjectToolName;
|
||||||
|
subIntent?: string;
|
||||||
|
} {
|
||||||
|
const normalizedText = String(text || "").toLowerCase();
|
||||||
|
const subIntent = String(analysis.subIntent || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
if (subIntent === "remove_background" || /抠图|去背|去背景|透明底/.test(normalizedText)) {
|
||||||
|
return {
|
||||||
|
capability: "remove_background",
|
||||||
|
toolName: "removeBackground",
|
||||||
|
subIntent: "remove_background",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subIntent === "extend" || /扩图|拓图|补边|延展|加宽|加高/.test(normalizedText)) {
|
||||||
|
return {
|
||||||
|
capability: "extend",
|
||||||
|
toolName: "extendImage",
|
||||||
|
subIntent: "extend",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subIntent === "resize" || /尺寸|分辨率|改成.*x|改为.*x|比例|画布/.test(normalizedText)) {
|
||||||
|
return {
|
||||||
|
capability: "resize",
|
||||||
|
toolName: "resizeImage",
|
||||||
|
subIntent: "resize",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subIntent === "box_cutout" || /框选抠图|框选/.test(normalizedText)) {
|
||||||
|
return {
|
||||||
|
capability: "box_cutout",
|
||||||
|
toolName: "boxCutout",
|
||||||
|
subIntent: "box_cutout",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subIntent === "enhance" || /增强|放大|高清|超清|锐化|修复/.test(normalizedText)) {
|
||||||
|
return {
|
||||||
|
capability: "enhance",
|
||||||
|
toolName: "enhanceImage",
|
||||||
|
subIntent: "enhance",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capability: "generic_edit",
|
||||||
|
toolName: "editImage",
|
||||||
|
subIntent: analysis.subIntent || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refinePlanWithSpecialist(
|
||||||
|
specialist: AgentSpecialistRoute,
|
||||||
|
input: AgentPlanRefineInput,
|
||||||
|
): AgentExecutionPlan {
|
||||||
|
const { plan, availableTools, analysis, text } = input;
|
||||||
|
let toolCalls = [...plan.toolCalls];
|
||||||
|
|
||||||
|
if (specialist.domain === "conversation") {
|
||||||
|
const shouldClarify = plan.nextAction === "clarify";
|
||||||
|
if (hasTool(availableTools, "respondDirect")) {
|
||||||
|
toolCalls = [{ name: "respondDirect", reason: "纯对话由对话协调专家直接回复" }];
|
||||||
|
}
|
||||||
|
return forcePlanShape({
|
||||||
|
plan,
|
||||||
|
nextAction: shouldClarify ? "clarify" : "respond",
|
||||||
|
intent: "chat",
|
||||||
|
responseMode: shouldClarify ? "clarify" : "direct_answer",
|
||||||
|
preferredCapability: "respond",
|
||||||
|
toolCalls,
|
||||||
|
shouldUseStructuredReply: false,
|
||||||
|
targetSource: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specialist.domain === "vision") {
|
||||||
|
if (hasTool(availableTools, "analyzeImage")) {
|
||||||
|
toolCalls = [{ name: "analyzeImage", reason: "视觉分析任务由看图专家处理" }];
|
||||||
|
} else if (hasTool(availableTools, "respondDirect")) {
|
||||||
|
toolCalls = [{ name: "respondDirect", reason: "缺少看图工具时退回直接回答" }];
|
||||||
|
}
|
||||||
|
return forcePlanShape({
|
||||||
|
plan,
|
||||||
|
nextAction: "analyze_image",
|
||||||
|
intent: "vision",
|
||||||
|
responseMode: "tool_then_answer",
|
||||||
|
preferredCapability: "analyze",
|
||||||
|
toolCalls,
|
||||||
|
shouldUseStructuredReply: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specialist.domain === "image_edit") {
|
||||||
|
const editTarget = normalizeEditCapability(analysis, text);
|
||||||
|
if (hasTool(availableTools, editTarget.toolName)) {
|
||||||
|
toolCalls = [{ name: editTarget.toolName, reason: "修图专家选择最匹配的编辑能力" }];
|
||||||
|
} else if (hasTool(availableTools, "editImage")) {
|
||||||
|
toolCalls = [{ name: "editImage", reason: "修图专家回退到通用编辑能力" }];
|
||||||
|
}
|
||||||
|
return forcePlanShape({
|
||||||
|
plan,
|
||||||
|
nextAction: "edit_image",
|
||||||
|
intent: "image_edit",
|
||||||
|
subIntent: editTarget.subIntent,
|
||||||
|
responseMode: "tool_then_execute",
|
||||||
|
preferredCapability: editTarget.capability,
|
||||||
|
toolCalls,
|
||||||
|
shouldUseStructuredReply: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specialist.domain === "image_generate") {
|
||||||
|
if (hasTool(availableTools, "generateImage")) {
|
||||||
|
toolCalls = [{ name: "generateImage", reason: "生图专家执行图片生成" }];
|
||||||
|
}
|
||||||
|
return forcePlanShape({
|
||||||
|
plan,
|
||||||
|
nextAction: "generate_image",
|
||||||
|
intent: "image_gen",
|
||||||
|
responseMode: "tool_then_execute",
|
||||||
|
preferredCapability: "generate_image",
|
||||||
|
toolCalls,
|
||||||
|
shouldUseStructuredReply: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specialist.domain === "video_generate") {
|
||||||
|
if (hasTool(availableTools, "generateVideo")) {
|
||||||
|
toolCalls = [{ name: "generateVideo", reason: "视频专家执行视频生成" }];
|
||||||
|
}
|
||||||
|
return forcePlanShape({
|
||||||
|
plan,
|
||||||
|
nextAction: "generate_video",
|
||||||
|
intent: "video_gen",
|
||||||
|
responseMode: "tool_then_execute",
|
||||||
|
preferredCapability: "generate_video",
|
||||||
|
toolCalls,
|
||||||
|
shouldUseStructuredReply: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specialist.domain === "canvas") {
|
||||||
|
const isExecutionAction =
|
||||||
|
plan.nextAction === "edit_image" ||
|
||||||
|
plan.nextAction === "generate_image";
|
||||||
|
const normalizedTargetSource =
|
||||||
|
input.hasCanvasSelection && plan.targetSource === "none"
|
||||||
|
? "explicit_selection"
|
||||||
|
: plan.targetSource;
|
||||||
|
|
||||||
|
if (input.hasCanvasSelection && hasTool(availableTools, "useSelectedCanvasImage")) {
|
||||||
|
toolCalls = upsertTool(
|
||||||
|
toolCalls,
|
||||||
|
"useSelectedCanvasImage",
|
||||||
|
"画布专家优先使用当前选中的画布图片",
|
||||||
|
{ prepend: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isExecutionAction &&
|
||||||
|
hasTool(availableTools, "publishToCanvas")
|
||||||
|
) {
|
||||||
|
toolCalls = upsertTool(
|
||||||
|
toolCalls,
|
||||||
|
"publishToCanvas",
|
||||||
|
"画布专家要求结果同步回无限画布",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
toolCalls,
|
||||||
|
targetSource: normalizedTargetSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
@ -16,6 +16,30 @@ import type {
|
|||||||
VideoGenerationOptions,
|
VideoGenerationOptions,
|
||||||
} from "@/types/ai";
|
} from "@/types/ai";
|
||||||
|
|
||||||
|
export interface ExecuteDirectVideoGenerationParams {
|
||||||
|
prompt: string;
|
||||||
|
mode: VideoGenerationMode;
|
||||||
|
images?: string[];
|
||||||
|
resolution?: "480p" | "720p" | "1080p";
|
||||||
|
ratio?: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
|
||||||
|
duration?: number;
|
||||||
|
watermark?: boolean;
|
||||||
|
cameraFixed?: boolean;
|
||||||
|
seed?: number;
|
||||||
|
returnLastFrame?: boolean;
|
||||||
|
preferredModelRef?: string;
|
||||||
|
onProgress?: (chunk: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteDirectVideoGenerationResult {
|
||||||
|
success: boolean;
|
||||||
|
videoUrl?: string;
|
||||||
|
metadata?: Record<string, any> | null;
|
||||||
|
modelDisplayName: string;
|
||||||
|
modelId: string;
|
||||||
|
modelRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[] {
|
function inferSupportedModesFromModelId(modelId: string): VideoGenerationMode[] {
|
||||||
const normalized = modelId.toLowerCase();
|
const normalized = modelId.toLowerCase();
|
||||||
|
|
||||||
@ -232,6 +256,103 @@ function buildVideoOptions(
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolvePreferredVideoParentModelId(preferredModelRef?: string) {
|
||||||
|
const normalizedRef = String(preferredModelRef || "").trim();
|
||||||
|
if (!normalizedRef) return undefined;
|
||||||
|
|
||||||
|
const models = await getEnabledAiModelsWithProvider();
|
||||||
|
const matched = models.find(
|
||||||
|
(item) =>
|
||||||
|
item.provider.type === "volcano-video" &&
|
||||||
|
(item.model.id === normalizedRef || item.model.modelId === normalizedRef),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matched) return undefined;
|
||||||
|
if (matched.model.isSubModel && matched.model.parentModelId) {
|
||||||
|
return matched.model.parentModelId;
|
||||||
|
}
|
||||||
|
return matched.model.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDirectVideoGeneration(
|
||||||
|
params: ExecuteDirectVideoGenerationParams,
|
||||||
|
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||||
|
const prompt = String(params.prompt || "").trim();
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error("缺少视频生成提示词");
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredParentModelId = await resolvePreferredVideoParentModelId(
|
||||||
|
params.preferredModelRef,
|
||||||
|
);
|
||||||
|
const allModels = await getEnabledAiModelsWithProvider();
|
||||||
|
const fallbackParentModel =
|
||||||
|
allModels.find(
|
||||||
|
(item) =>
|
||||||
|
item.provider.type === "volcano-video" &&
|
||||||
|
!item.model.isSubModel &&
|
||||||
|
item.model.modelId === "volcano-video-master",
|
||||||
|
) ||
|
||||||
|
allModels.find(
|
||||||
|
(item) => item.provider.type === "volcano-video" && !item.model.isSubModel,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentModelId = preferredParentModelId || fallbackParentModel?.model.id;
|
||||||
|
if (!parentModelId) {
|
||||||
|
throw new Error("未找到可用的视频生成主模型");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generationParams = {
|
||||||
|
mode: params.mode,
|
||||||
|
resolution: params.resolution || "720p",
|
||||||
|
ratio: params.ratio || "16:9",
|
||||||
|
duration: params.duration || 5,
|
||||||
|
watermark: params.watermark ?? true,
|
||||||
|
cameraFixed: params.cameraFixed ?? false,
|
||||||
|
seed: params.seed,
|
||||||
|
returnLastFrame: params.returnLastFrame ?? false,
|
||||||
|
referenceImages: params.images,
|
||||||
|
} satisfies Partial<VideoGenerationOptions>;
|
||||||
|
|
||||||
|
const { model, capabilities } = await getCompatibleModel(
|
||||||
|
params.mode,
|
||||||
|
parentModelId,
|
||||||
|
generationParams,
|
||||||
|
);
|
||||||
|
|
||||||
|
validateParamsAgainstCapabilities(generationParams, capabilities);
|
||||||
|
|
||||||
|
const options = buildVideoOptions(
|
||||||
|
params.mode,
|
||||||
|
params.images,
|
||||||
|
prompt,
|
||||||
|
generationParams,
|
||||||
|
model.model.modelId,
|
||||||
|
);
|
||||||
|
|
||||||
|
let videoMetadata: Record<string, any> | null = null;
|
||||||
|
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
|
||||||
|
await params.onProgress?.(chunk);
|
||||||
|
const metadataMatch = chunk.match(/<!-- VIDEO_METADATA:(.*?) -->/);
|
||||||
|
if (metadataMatch) {
|
||||||
|
try {
|
||||||
|
videoMetadata = JSON.parse(metadataMatch[1]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Generate Video Tool] 解析元数据失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
videoUrl: videoMetadata?.temporaryVideoUrl || undefined,
|
||||||
|
metadata: videoMetadata,
|
||||||
|
modelDisplayName: String(model.model.displayName || model.model.modelId || "视频模型"),
|
||||||
|
modelId: String(model.model.modelId || model.model.id),
|
||||||
|
modelRef: String(model.model.id || model.model.modelId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频生成工具
|
* 视频生成工具
|
||||||
*/
|
*/
|
||||||
@ -276,20 +397,18 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
|
|||||||
console.log("[Generate Video Tool] 模式:", mode);
|
console.log("[Generate Video Tool] 模式:", mode);
|
||||||
console.log("[Generate Video Tool] 提示词:", prompt);
|
console.log("[Generate Video Tool] 提示词:", prompt);
|
||||||
|
|
||||||
// 1. 🆕 智能选择最佳子模型
|
dataStream.writeData({
|
||||||
// 先获取火山引擎视频主模型
|
type: "video-generation-start",
|
||||||
const allModels = await getEnabledAiModelsWithProvider();
|
data: {
|
||||||
const volcanoMasterModel = allModels.find(
|
mode,
|
||||||
m => m.provider.type === "volcano-video" && m.model.modelId === "volcano-video-master"
|
model: "智能选择中",
|
||||||
);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!volcanoMasterModel) {
|
const result = await executeDirectVideoGeneration({
|
||||||
throw new Error("未找到火山引擎视频主模型,请在系统设置中添加");
|
prompt,
|
||||||
}
|
|
||||||
|
|
||||||
// 准备参数用于智能选择
|
|
||||||
const params = {
|
|
||||||
mode,
|
mode,
|
||||||
|
images,
|
||||||
resolution,
|
resolution,
|
||||||
ratio,
|
ratio,
|
||||||
duration,
|
duration,
|
||||||
@ -297,75 +416,20 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
|
|||||||
cameraFixed,
|
cameraFixed,
|
||||||
seed,
|
seed,
|
||||||
returnLastFrame,
|
returnLastFrame,
|
||||||
referenceImages: images,
|
onProgress: async (chunk) => {
|
||||||
};
|
dataStream.writeMessageAnnotation({
|
||||||
|
type: "video-progress",
|
||||||
// 根据用户参数智能选择最佳子模型
|
content: chunk,
|
||||||
const { model, capabilities } = await selectBestSubModel(
|
});
|
||||||
volcanoMasterModel.model.id,
|
|
||||||
mode,
|
|
||||||
params
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"[Generate Video Tool] ✅ 智能选择模型:",
|
|
||||||
model.model.displayName,
|
|
||||||
`(${model.model.modelId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. 验证参数是否符合模型能力
|
|
||||||
validateParamsAgainstCapabilities(params, capabilities);
|
|
||||||
|
|
||||||
// 3. 构建视频生成选项
|
|
||||||
const options = buildVideoOptions(
|
|
||||||
mode,
|
|
||||||
images,
|
|
||||||
prompt,
|
|
||||||
params,
|
|
||||||
model.model.modelId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. 调用视频生成(流式输出)
|
|
||||||
dataStream.writeData({
|
|
||||||
type: "video-generation-start",
|
|
||||||
data: {
|
|
||||||
mode,
|
|
||||||
model: model.model.displayName,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiKey = model.provider.apiKey;
|
|
||||||
const baseURL = model.provider.baseUrl;
|
|
||||||
|
|
||||||
let videoMetadata: any = null;
|
|
||||||
|
|
||||||
// 使用异步生成器进行流式输出
|
|
||||||
for await (const chunk of doGenerateStream(apiKey, baseURL, options)) {
|
|
||||||
dataStream.writeMessageAnnotation({
|
|
||||||
type: "video-progress",
|
|
||||||
content: chunk,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 提取视频元数据(从注释中)
|
|
||||||
const metadataMatch = chunk.match(
|
|
||||||
/<!-- VIDEO_METADATA:(.*?) -->/
|
|
||||||
);
|
|
||||||
if (metadataMatch) {
|
|
||||||
try {
|
|
||||||
videoMetadata = JSON.parse(metadataMatch[1]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[Generate Video Tool] 解析元数据失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[Generate Video Tool] ✅ 视频生成完成");
|
console.log("[Generate Video Tool] ✅ 视频生成完成");
|
||||||
|
|
||||||
// 5. 返回元数据(用于保存到数据库)
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
videoUrl: videoMetadata?.temporaryVideoUrl,
|
videoUrl: result.videoUrl,
|
||||||
metadata: videoMetadata,
|
metadata: result.metadata,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Generate Video Tool] ❌ 生成失败:", error);
|
console.error("[Generate Video Tool] ❌ 生成失败:", error);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export interface ParsedImage {
|
|||||||
export interface CanvasGenerateImageResult {
|
export interface CanvasGenerateImageResult {
|
||||||
images: ParsedImage[];
|
images: ParsedImage[];
|
||||||
model: string;
|
model: string;
|
||||||
|
modelRef: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,6 +259,7 @@ export async function generateCanvasImage(
|
|||||||
return {
|
return {
|
||||||
images: parsedImages,
|
images: parsedImages,
|
||||||
model: selectedModel.model.displayName,
|
model: selectedModel.model.displayName,
|
||||||
|
modelRef: selectedModel.model.id,
|
||||||
prompt,
|
prompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user