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 type { VisibilityType } from "@/components/visibility-selector";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import {
|
||||
BoxCutoutServiceError,
|
||||
executeBoxCutoutJob,
|
||||
} from "@/lib/ai/box-cutout/service";
|
||||
import {
|
||||
executeExtendJob,
|
||||
ExtendServiceError,
|
||||
@ -43,7 +47,10 @@ import { createDocument } from "@/lib/ai/tools/create-document";
|
||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
||||
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 { isProductionEnvironment } from "@/lib/constants";
|
||||
import {
|
||||
@ -74,6 +81,7 @@ import {
|
||||
import { db } from "@/lib/db";
|
||||
import type { DBMessage } 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 { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution";
|
||||
import type {
|
||||
@ -784,42 +792,139 @@ type AgentPreparedExecution = {
|
||||
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,
|
||||
plan?: AgentExecutionPlan,
|
||||
): DirectAgentToolCapability | null {
|
||||
const toolNames = new Set([
|
||||
...(execution?.toolCalls || []).map((tool) => tool.name),
|
||||
...(plan?.toolCalls || []).map((tool) => tool.name),
|
||||
]);
|
||||
): AgentPlannedToolCall[] {
|
||||
const merged = [...(execution?.toolCalls || []), ...(plan?.toolCalls || [])];
|
||||
const seen = new Set<string>();
|
||||
const result: AgentPlannedToolCall[] = [];
|
||||
|
||||
if (
|
||||
execution?.preferredCapability === "remove_background" ||
|
||||
execution?.routeSubIntent === "remove_background" ||
|
||||
toolNames.has("removeBackground")
|
||||
) {
|
||||
return "remove_background";
|
||||
for (const tool of merged) {
|
||||
const name = String(tool?.name || "").trim();
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push(tool);
|
||||
}
|
||||
|
||||
if (
|
||||
execution?.preferredCapability === "resize" ||
|
||||
execution?.routeSubIntent === "resize" ||
|
||||
toolNames.has("resizeImage")
|
||||
) {
|
||||
return "resize";
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapDirectToolNameToCapability(
|
||||
toolName: DirectAgentToolName,
|
||||
): 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 (
|
||||
execution?.preferredCapability === "extend" ||
|
||||
execution?.routeSubIntent === "extend" ||
|
||||
toolNames.has("extendImage")
|
||||
) {
|
||||
return "extend";
|
||||
}
|
||||
|
||||
return null;
|
||||
return {
|
||||
toolName: directToolName,
|
||||
capability: mapDirectToolNameToCapability(directToolName),
|
||||
sourceTools,
|
||||
publishToCanvas:
|
||||
execution?.shouldPublishToCanvas === true || toolNames.includes("publishToCanvas"),
|
||||
};
|
||||
}
|
||||
|
||||
function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string {
|
||||
@ -830,6 +935,12 @@ function getDirectAgentToolLabel(capability: DirectAgentToolCapability): string
|
||||
return "Agent工具:尺寸调整";
|
||||
case "extend":
|
||||
return "Agent工具:图像拓展";
|
||||
case "box_cutout":
|
||||
return "Agent工具:框选抠图";
|
||||
case "generate_image":
|
||||
return "Agent工具:图片生成";
|
||||
case "generate_video":
|
||||
return "Agent工具:视频生成";
|
||||
default:
|
||||
return "Agent工具";
|
||||
}
|
||||
@ -864,6 +975,12 @@ function getDirectAgentToolSuccessMessage(capability: DirectAgentToolCapability)
|
||||
return "已完成尺寸调整,结果如下:";
|
||||
case "extend":
|
||||
return "已完成图像拓展,结果如下:";
|
||||
case "box_cutout":
|
||||
return "已完成框选抠图,结果如下:";
|
||||
case "generate_image":
|
||||
return "已完成图片生成,结果如下:";
|
||||
case "generate_video":
|
||||
return "已完成视频生成,结果如下:";
|
||||
default:
|
||||
return "已完成处理,结果如下:";
|
||||
}
|
||||
@ -879,6 +996,12 @@ function getDirectAgentToolTargetMissingMessage(
|
||||
return "请先上传或选中一张图片,我才能继续帮你调整尺寸。";
|
||||
case "extend":
|
||||
return "请先上传或选中一张图片,我才能继续帮你做图像拓展。";
|
||||
case "box_cutout":
|
||||
return "请先选中一张图片并提供框选区域,我才能继续帮你做框选抠图。";
|
||||
case "generate_image":
|
||||
return "请先告诉我你想生成什么,或者提供参考图。";
|
||||
case "generate_video":
|
||||
return "请先告诉我你想生成什么视频,必要时再提供参考图。";
|
||||
default:
|
||||
return "请先提供要处理的图片。";
|
||||
}
|
||||
@ -903,6 +1026,12 @@ function getDirectAgentToolErrorMessage(
|
||||
return `尺寸调整暂时失败:${detail}`;
|
||||
case "extend":
|
||||
return `图像拓展暂时失败:${detail}`;
|
||||
case "box_cutout":
|
||||
return `框选抠图暂时失败:${detail}`;
|
||||
case "generate_image":
|
||||
return `图片生成暂时失败:${detail}`;
|
||||
case "generate_video":
|
||||
return `视频生成暂时失败:${detail}`;
|
||||
default:
|
||||
return `处理暂时失败:${detail}`;
|
||||
}
|
||||
@ -919,6 +1048,75 @@ function buildAgentToolImageMarkdown(params: {
|
||||
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([
|
||||
"alibaba-image",
|
||||
"ruxa-image",
|
||||
@ -3788,22 +3986,395 @@ export async function POST(request: Request) {
|
||||
return createSseResponse(clarificationStream);
|
||||
}
|
||||
|
||||
const directAgentToolCapability = shouldRunAgentConversation
|
||||
? resolveDirectAgentToolCapability(smartAutoExecution, smartAutoPlan)
|
||||
const directAgentExecution = shouldRunAgentConversation
|
||||
? resolveDirectAgentToolExecution(smartAutoExecution, smartAutoPlan)
|
||||
: null;
|
||||
const directAgentToolTargetImageUrl =
|
||||
requestTargetResolution?.targetImageUrls?.[0] ||
|
||||
requestResolvedMediaInput?.selectedImageUrls?.[0] ||
|
||||
requestResolvedMediaInput?.currentMessageImageUrls?.[0] ||
|
||||
requestWorkingState?.lastResolvedTargetImageUrls?.[0] ||
|
||||
requestWorkingState?.lastGeneratedImageUrls?.[0] ||
|
||||
null;
|
||||
const directAgentToolTargetImageUrls = directAgentExecution
|
||||
? pickAgentSourceImageUrls({
|
||||
sourceTools: directAgentExecution.sourceTools,
|
||||
requestResolvedMediaInput,
|
||||
requestTargetResolution,
|
||||
requestWorkingState,
|
||||
})
|
||||
: [];
|
||||
const directAgentToolTargetImageUrl = directAgentToolTargetImageUrls[0] || null;
|
||||
|
||||
if (
|
||||
shouldRunAgentConversation &&
|
||||
!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) {
|
||||
const missingTargetMessage = getDirectAgentToolTargetMissingMessage(
|
||||
directAgentToolCapability,
|
||||
@ -3954,6 +4525,47 @@ export async function POST(request: Request) {
|
||||
resultThumbnailUrl = result.thumbnail_url;
|
||||
resultImageSize = "2K";
|
||||
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(
|
||||
@ -4000,7 +4612,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
if (smartAutoExecution?.shouldPublishToCanvas && contextPayload?.source === "canvas") {
|
||||
if (directAgentExecution.publishToCanvas && contextPayload?.source === "canvas") {
|
||||
console.log(
|
||||
`[Chat API] Agent publishToCanvas satisfied by assistant media auto-sync (${directAgentToolCapability})`,
|
||||
);
|
||||
@ -4021,7 +4633,7 @@ export async function POST(request: Request) {
|
||||
messageId: assistantMessageId,
|
||||
imageUrls: resultOriginalUrl ? [resultOriginalUrl] : [],
|
||||
prompt: requestTextContent || getDirectAgentToolLabel(directAgentToolCapability),
|
||||
sourceImageUrls: requestTargetResolution?.targetImageUrls || [],
|
||||
sourceImageUrls: directAgentToolTargetImageUrls,
|
||||
},
|
||||
`agent:${directAgentToolCapability}:canvas-media`,
|
||||
);
|
||||
@ -4044,7 +4656,8 @@ export async function POST(request: Request) {
|
||||
if (
|
||||
!(directToolError instanceof RmbgServiceError) &&
|
||||
!(directToolError instanceof ResizeServiceError) &&
|
||||
!(directToolError instanceof ExtendServiceError)
|
||||
!(directToolError instanceof ExtendServiceError) &&
|
||||
!(directToolError instanceof BoxCutoutServiceError)
|
||||
) {
|
||||
console.warn(
|
||||
`[Chat API] Direct agent tool ${directAgentToolCapability} threw unexpected error type`,
|
||||
|
||||
@ -1,26 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
RmbgServiceError,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
BoxCutoutServiceError,
|
||||
executeBoxCutoutJob,
|
||||
} from "@/lib/ai/box-cutout/service";
|
||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
type NormalizedCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type BoxCutoutRequest = {
|
||||
imageUrl?: string;
|
||||
cropRect?: Partial<NormalizedCropRect>;
|
||||
cropRect?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
paddingRatio?: number;
|
||||
prompt?: string;
|
||||
chatId?: string | null;
|
||||
@ -29,85 +26,6 @@ type BoxCutoutRequest = {
|
||||
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) {
|
||||
try {
|
||||
const session = await auth();
|
||||
@ -117,78 +35,32 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const body = (await request.json()) as BoxCutoutRequest;
|
||||
const rawImageUrl = String(body.imageUrl || "").trim();
|
||||
const cropRect = sanitizeNormalizedCropRect(body.cropRect);
|
||||
|
||||
if (!rawImageUrl) {
|
||||
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 paddingRatio = normalizePaddingRatio(body.paddingRatio);
|
||||
|
||||
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({
|
||||
const result = await executeBoxCutoutJob({
|
||||
userId: session.user.id,
|
||||
imageBuffer: croppedBuffer,
|
||||
imageBase64: croppedBuffer.toString("base64"),
|
||||
sourceImageUrl: resolvedImageUrl,
|
||||
imageUrl: resolvedImageUrl,
|
||||
cropRect: body.cropRect || {},
|
||||
paddingRatio: body.paddingRatio,
|
||||
prompt: body.prompt || "框选抠图",
|
||||
chatId: body.chatId || null,
|
||||
messageId: body.messageId || null,
|
||||
skipGallerySave: body.skipGallerySave === true,
|
||||
sourceNodeId: body.sourceNodeId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...rmbgResult,
|
||||
cropRectApplied: cropRect,
|
||||
paddedCropRectApplied: paddedCropRect,
|
||||
cropRectPixels,
|
||||
paddedCropRectPixels,
|
||||
sourceNodeId: body.sourceNodeId || null,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.detail || error.message,
|
||||
},
|
||||
{ status: error.status }
|
||||
{ status: error.status },
|
||||
);
|
||||
}
|
||||
|
||||
@ -198,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "框选抠图失败",
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { generateText } from "ai";
|
||||
import sharp from "sharp";
|
||||
import type { AnalysisResult } from "@/lib/ai/smart-analyzer";
|
||||
import type { RouteResult } from "@/lib/ai/model-router";
|
||||
import type { AgentSkillRouteResult } from "@/lib/ai/skill-router";
|
||||
import {
|
||||
getEnabledAgentToolDefinitionsForContext,
|
||||
type AgentProjectToolDefinition,
|
||||
@ -1210,6 +1211,7 @@ async function buildExecutionPlanWithAgent(
|
||||
state: AutoAgentStateType,
|
||||
analysis: AnalysisResult,
|
||||
availableTools: AgentProjectToolDefinition[],
|
||||
skillRoute?: AgentSkillRouteResult,
|
||||
): Promise<AgentExecutionPlan> {
|
||||
const fallbackPlan = buildFallbackPlan({
|
||||
analysis,
|
||||
@ -1275,6 +1277,7 @@ async function buildExecutionPlanWithAgent(
|
||||
5. shouldUseStructuredReply 只在 edit_image / generate_image / generate_video 时设为 true。
|
||||
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。
|
||||
8. 必须遵守当前专家路由约束;如果约束要求纯对话,则绝不能输出 generate_image / edit_image / generate_video。
|
||||
|
||||
输出格式:
|
||||
{
|
||||
@ -1306,6 +1309,12 @@ async function buildExecutionPlanWithAgent(
|
||||
`历史图数量:${state.historyImageCount}`,
|
||||
`画布选中图数量:${state.selectedImageCount}`,
|
||||
`基础分析: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)}` : "",
|
||||
historyContext ? `最近对话:\n${historyContext}` : "",
|
||||
`可用工具:\n${toolCatalog}`,
|
||||
@ -1318,7 +1327,23 @@ async function buildExecutionPlanWithAgent(
|
||||
} as any);
|
||||
|
||||
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(
|
||||
`[Auto Agent] Planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`,
|
||||
);
|
||||
@ -1766,13 +1791,36 @@ const analyzeNode = async (state: AutoAgentStateType) => {
|
||||
|
||||
const planNode = async (state: AutoAgentStateType) => {
|
||||
if (!state.analysis) return {};
|
||||
const availableTools = await getEnabledAgentToolDefinitionsForContext({
|
||||
const baseAvailableTools = await getEnabledAgentToolDefinitionsForContext({
|
||||
hasImage: state.hasImage,
|
||||
hasCanvasSelection: state.hasCanvasSelection || state.selectedImageCount > 0,
|
||||
hasHistoryImage: state.historyImageCount > 0 || state.imageFromHistory,
|
||||
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);
|
||||
|
||||
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";
|
||||
|
||||
type EnabledModelWithProvider = Awaited<
|
||||
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
|
||||
>[number];
|
||||
|
||||
// 使用动态导入避免循环依赖
|
||||
// import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
|
||||
@ -27,6 +31,94 @@ function hasVisionCapability(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 按优先级排序,第一个是首选
|
||||
const INTENT_MODEL_MAP: Record<
|
||||
@ -48,7 +140,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 视觉理解:优先千问3 VL(成本低效果好)
|
||||
vision: {
|
||||
providerTypes: ["alibaba", "gemini", "huanke-gemini"],
|
||||
providerTypes: ["alibaba", "zhipu", "openai", "custom", "gemini", "huanke-gemini", "volcano-multimodal"],
|
||||
requireVision: true,
|
||||
description: "图片理解",
|
||||
},
|
||||
@ -68,7 +160,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 普通对话:优先千问3
|
||||
chat: {
|
||||
providerTypes: ["alibaba", "openai", "local"],
|
||||
providerTypes: ["alibaba", "zhipu", "openai", "custom", "local", "gemini", "huanke-gemini", "volcano-multimodal"],
|
||||
description: "对话",
|
||||
},
|
||||
};
|
||||
@ -147,7 +239,7 @@ export async function routeToModel(params: {
|
||||
const [settings] = await db.select().from(siteSettings).limit(1);
|
||||
const configuredDefaultModelId = settings?.canvasAutoModeDefaultModelId;
|
||||
|
||||
if (configuredDefaultModelId) {
|
||||
if (configuredDefaultModelId && intent !== "chat") {
|
||||
const configuredModel = allModels.find(
|
||||
(m) => m.model.id === configuredDefaultModelId && m.model.isEnabled && !m.model.isSubModel
|
||||
);
|
||||
@ -182,6 +274,8 @@ export async function routeToModel(params: {
|
||||
} else {
|
||||
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 类型
|
||||
if (m.provider.type !== providerType) return false;
|
||||
|
||||
// 检查能力要求
|
||||
if (config.requireVision && !hasVisionCapability(m.model)) return false;
|
||||
if (config.requireImageGen && !m.model.supportsImageGen) return false;
|
||||
|
||||
// 排除子模型(只选主模型)
|
||||
if (m.model.isSubModel) return false;
|
||||
|
||||
// 必须启用
|
||||
if (!m.model.isEnabled) return false;
|
||||
|
||||
return true;
|
||||
return matchesIntentRequirements(m, {
|
||||
intent,
|
||||
requireVision: config.requireVision,
|
||||
requireImageGen: config.requireImageGen,
|
||||
});
|
||||
});
|
||||
|
||||
if (candidates.length > 0) {
|
||||
@ -223,17 +311,24 @@ export async function routeToModel(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底逻辑:返回任意可用的图片编辑模型
|
||||
const fallbackCandidates = allModels.filter(
|
||||
(m) =>
|
||||
!m.model.isSubModel &&
|
||||
m.model.isEnabled &&
|
||||
(m.model.supportsImageGen || m.model.supportsVision)
|
||||
);
|
||||
const fallbackCandidates = allModels.filter((item) => {
|
||||
if (intent === "chat") {
|
||||
return isConversationalCandidate(item, { requireVision: false });
|
||||
}
|
||||
if (intent === "vision") {
|
||||
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) {
|
||||
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 {
|
||||
id: fallback.model.id,
|
||||
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) {
|
||||
console.log("[Model Router] Using any available model:", anyModel.model.displayName);
|
||||
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,
|
||||
} 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[] {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
@ -232,6 +256,103 @@ function buildVideoOptions(
|
||||
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] 提示词:", prompt);
|
||||
|
||||
// 1. 🆕 智能选择最佳子模型
|
||||
// 先获取火山引擎视频主模型
|
||||
const allModels = await getEnabledAiModelsWithProvider();
|
||||
const volcanoMasterModel = allModels.find(
|
||||
m => m.provider.type === "volcano-video" && m.model.modelId === "volcano-video-master"
|
||||
);
|
||||
dataStream.writeData({
|
||||
type: "video-generation-start",
|
||||
data: {
|
||||
mode,
|
||||
model: "智能选择中",
|
||||
},
|
||||
});
|
||||
|
||||
if (!volcanoMasterModel) {
|
||||
throw new Error("未找到火山引擎视频主模型,请在系统设置中添加");
|
||||
}
|
||||
|
||||
// 准备参数用于智能选择
|
||||
const params = {
|
||||
const result = await executeDirectVideoGeneration({
|
||||
prompt,
|
||||
mode,
|
||||
images,
|
||||
resolution,
|
||||
ratio,
|
||||
duration,
|
||||
@ -297,75 +416,20 @@ export function generateVideo({ dataStream }: { dataStream: any }) {
|
||||
cameraFixed,
|
||||
seed,
|
||||
returnLastFrame,
|
||||
referenceImages: images,
|
||||
};
|
||||
|
||||
// 根据用户参数智能选择最佳子模型
|
||||
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,
|
||||
onProgress: async (chunk) => {
|
||||
dataStream.writeMessageAnnotation({
|
||||
type: "video-progress",
|
||||
content: chunk,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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] ✅ 视频生成完成");
|
||||
|
||||
// 5. 返回元数据(用于保存到数据库)
|
||||
return {
|
||||
success: true,
|
||||
videoUrl: videoMetadata?.temporaryVideoUrl,
|
||||
metadata: videoMetadata,
|
||||
videoUrl: result.videoUrl,
|
||||
metadata: result.metadata,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("[Generate Video Tool] ❌ 生成失败:", error);
|
||||
|
||||
@ -28,6 +28,7 @@ export interface ParsedImage {
|
||||
export interface CanvasGenerateImageResult {
|
||||
images: ParsedImage[];
|
||||
model: string;
|
||||
modelRef: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
@ -258,6 +259,7 @@ export async function generateCanvasImage(
|
||||
return {
|
||||
images: parsedImages,
|
||||
model: selectedModel.model.displayName,
|
||||
modelRef: selectedModel.model.id,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user