feat: add specialist direct executor
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-07 16:59:16 +08:00
parent bbdda81686
commit 449dd19d5f
9 changed files with 1746 additions and 289 deletions

View File

@ -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 `![thumb|||${originalUrl}|||${imageSize}](${thumbnailUrl})`;
}
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`,

View File

@ -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 },
);
}
}

View File

@ -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 {

View 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,
};
}

View File

@ -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
View 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
View 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;
}

View File

@ -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);

View File

@ -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,
};
}