ai-chatbot/lib/ai/auto-agent-graph.ts
Your Name c8f3a04f03
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
perf(agent): remove slow llm optimize routing from auto graph
2026-02-19 03:07:35 +08:00

1194 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Annotation, StateGraph, START, END } from "@langchain/langgraph";
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 { isAiModelMultimodal } from "@/lib/db/ai-model-flags";
interface HistoryMessage {
role: "user" | "assistant";
content: string;
}
export interface AgentRecommendedImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
negativePrompt?: string;
promptExtend?: boolean;
}
export interface AgentRecommendedVideoParams {
mode?: "text-to-video" | "first-frame" | "first-last-frame" | "reference-images";
resolution?: "480p" | "720p" | "1080p";
ratio?: "16:9" | "4:3" | "1:1" | "3:4" | "9:16" | "21:9" | "adaptive";
duration?: number;
generateAudio?: boolean;
cameraMove?: string;
}
export interface AgentVideoStoryboardShot {
shot: number;
title: string;
duration: number;
description: string;
prompt: string;
camera?: string;
}
export interface AgentVideoPlan {
script: string;
storyboard: AgentVideoStoryboardShot[];
finalPrompt: string;
negativePrompt?: string;
recommendedVideoParams?: AgentRecommendedVideoParams;
}
export interface AutoAgentInput {
text: string;
hasImage: boolean;
imageUrls?: string[];
imageFromHistory?: boolean;
historyMessages?: HistoryMessage[];
context?: string;
}
export interface AutoAgentResult {
analysis?: AnalysisResult;
friendlyMessage?: string;
optimizedPrompt?: string;
recommendedImageParams?: AgentRecommendedImageParams;
videoPlan?: AgentVideoPlan;
route?: RouteResult;
}
const AutoAgentState = Annotation.Root({
text: Annotation<string>,
hasImage: Annotation<boolean>,
imageUrls: Annotation<string[]>,
imageFromHistory: Annotation<boolean>,
historyMessages: Annotation<HistoryMessage[]>,
context: Annotation<string | null>,
analysis: Annotation<AnalysisResult | null>,
friendlyMessage: Annotation<string | null>,
optimizedPrompt: Annotation<string | null>,
recommendedImageParams: Annotation<AgentRecommendedImageParams | null>,
videoPlan: Annotation<AgentVideoPlan | null>,
route: Annotation<RouteResult | null>,
});
type AutoAgentStateType = typeof AutoAgentState.State;
function compactText(text: string, max = 48) {
const normalized = text.replace(/\s+/g, " ").trim();
if (normalized.length <= max) return normalized;
return `${normalized.slice(0, max)}...`;
}
async function withTimeoutFallback<T>(
promise: Promise<T>,
timeoutMs: number,
fallbackValue: T,
): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((resolve) => {
timeoutId = setTimeout(() => resolve(fallbackValue), timeoutMs);
}),
]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
const AGENT_VISION_MAX_IMAGES = 4;
const AGENT_VISION_MAX_SIDE = 500;
const AGENT_VISION_FETCH_TIMEOUT_MS = 10_000;
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
}
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
signal: controller.signal,
cache: "no-store",
headers: {
"user-agent": "ai-chatbot-auto-agent/1.0",
},
});
} finally {
clearTimeout(timeoutId);
}
}
async function toAgentVisionDataUrl(imageUrl: string): Promise<string | null> {
try {
const response = await fetchWithTimeout(imageUrl, AGENT_VISION_FETCH_TIMEOUT_MS);
if (!response.ok) {
console.warn(
`[Auto Agent] Failed to fetch image for inline vision payload (${response.status}): ${imageUrl}`,
);
return null;
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.startsWith("image/")) {
console.warn(`[Auto Agent] Skip non-image content-type for vision payload: ${contentType}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const optimized = await sharp(Buffer.from(arrayBuffer))
.rotate()
.resize({
width: AGENT_VISION_MAX_SIDE,
height: AGENT_VISION_MAX_SIDE,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({
quality: 80,
mozjpeg: true,
})
.toBuffer();
return `data:image/jpeg;base64,${optimized.toString("base64")}`;
} catch (error) {
console.warn("[Auto Agent] Failed to convert image URL to inline base64:", error);
return null;
}
}
async function buildAgentVisionInputs(
imageUrls: string[],
options?: {
preferRemoteUrls?: boolean;
},
) {
if (imageUrls.length === 0) return [] as Array<{ type: "image"; image: string }>;
if (options?.preferRemoteUrls) {
return imageUrls.map((url) => ({
type: "image" as const,
image: url,
}));
}
const convertedResults = await Promise.all(imageUrls.map((url) => toAgentVisionDataUrl(url)));
const inlineCount = convertedResults.filter(Boolean).length;
console.log(
`[Auto Agent] Prepared vision images: total=${imageUrls.length}, inline=${inlineCount}, remote=${imageUrls.length - inlineCount}, maxSide=${AGENT_VISION_MAX_SIDE}`,
);
return imageUrls.map((originalUrl, index) => ({
type: "image" as const,
image: convertedResults[index] || originalUrl,
}));
}
function normalizeAspectRatio(value?: string): string | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "auto") return "auto";
const raw = value.trim();
const allowed = new Set([
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
]);
return allowed.has(raw) ? raw : undefined;
}
function normalizeImageSize(value?: string): string | undefined {
if (!value) return undefined;
const normalized = value.trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
return normalized;
}
return undefined;
}
function normalizeVideoMode(value?: string): AgentRecommendedVideoParams["mode"] | undefined {
if (!value) return undefined;
if (value === "text-to-video") return "text-to-video";
if (value === "first-frame") return "first-frame";
if (value === "first-last-frame") return "first-last-frame";
if (value === "reference-images") return "reference-images";
return undefined;
}
function normalizeVideoResolution(
value?: string,
): AgentRecommendedVideoParams["resolution"] | undefined {
if (!value) return undefined;
const normalized = value.toLowerCase();
if (normalized === "480p") return "480p";
if (normalized === "720p") return "720p";
if (normalized === "1080p") return "1080p";
return undefined;
}
function normalizeVideoRatio(value?: string): AgentRecommendedVideoParams["ratio"] | undefined {
if (!value) return undefined;
const normalized = value.trim();
const allowed = new Set(["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"]);
return allowed.has(normalized) ? (normalized as AgentRecommendedVideoParams["ratio"]) : undefined;
}
function normalizeVideoDuration(value?: number): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.min(15, Math.max(3, Math.round(value)));
}
function sanitizeRecommendedVideoParams(
params: AgentRecommendedVideoParams | undefined,
): AgentRecommendedVideoParams | undefined {
if (!params) return undefined;
const safe: AgentRecommendedVideoParams = {};
const mode = normalizeVideoMode(params.mode);
const resolution = normalizeVideoResolution(params.resolution);
const ratio = normalizeVideoRatio(params.ratio);
const duration = normalizeVideoDuration(params.duration);
if (mode) safe.mode = mode;
if (resolution) safe.resolution = resolution;
if (ratio) safe.ratio = ratio;
if (duration) safe.duration = duration;
if (typeof params.generateAudio === "boolean") safe.generateAudio = params.generateAudio;
if (typeof params.cameraMove === "string" && params.cameraMove.trim()) {
safe.cameraMove = params.cameraMove.trim().slice(0, 32);
}
return Object.keys(safe).length > 0 ? safe : undefined;
}
function inferVideoParamsByText(text: string, hasImage: boolean): AgentRecommendedVideoParams {
const normalized = text.toLowerCase();
const durationMatch = normalized.match(/(\d{1,2})\s*秒/);
const duration = durationMatch ? normalizeVideoDuration(Number(durationMatch[1])) : undefined;
const mode = /首尾帧|首帧.*尾帧|结尾帧|尾帧/.test(normalized)
? "first-last-frame"
: hasImage
? "first-frame"
: "text-to-video";
const ratio = /竖屏|手机|9:16|竖版/.test(normalized)
? "9:16"
: /方形|方图|1:1/.test(normalized)
? "1:1"
: /超宽|21:9|电影感/.test(normalized)
? "21:9"
: "16:9";
const generateAudio = /配音|旁白|音效|有声|带声音/.test(normalized);
return {
mode,
ratio,
duration: duration || 5,
resolution: "720p",
generateAudio,
};
}
function buildVideoFinalPrompt(params: {
script: string;
storyboard: AgentVideoStoryboardShot[];
originalText: string;
}): string {
const script = params.script.trim();
const storyboard = params.storyboard
.slice(0, 8)
.map(
(shot) =>
`镜头${shot.shot}(${shot.duration}s): ${shot.prompt || shot.description || shot.title}`.trim()
)
.join("\n");
return [
`【用户目标】${params.originalText.trim() || "生成视频"}`,
script ? `【脚本】${script}` : "",
storyboard ? `【分镜】\n${storyboard}` : "",
"【执行要求】镜头衔接自然,人物与主体保持一致,动作清晰,避免突兀跳切。",
]
.filter(Boolean)
.join("\n\n")
.trim();
}
function heuristicVideoPlan(state: AutoAgentStateType): AgentVideoPlan {
const recommendedVideoParams = inferVideoParamsByText(state.text, state.hasImage);
const duration = recommendedVideoParams.duration || 5;
const perShot = Math.max(2, Math.round(duration / 3));
const storyboard: AgentVideoStoryboardShot[] = [
{
shot: 1,
title: "开场建立",
duration: perShot,
description: "建立主体与环境关系,明确视觉基调。",
prompt: `开场镜头,展示主体与环境,围绕「${compactText(state.text, 50)}」建立氛围。`,
camera: "稳定推进",
},
{
shot: 2,
title: "动作推进",
duration: perShot,
description: "主体动作或情绪推进,形成主要叙事段落。",
prompt: `中段镜头,突出主体动作与情绪变化,强化故事推进。`,
camera: "跟拍或环绕",
},
{
shot: 3,
title: "收束结尾",
duration: Math.max(2, duration - perShot * 2),
description: "情绪收束并输出最终视觉记忆点。",
prompt: "结尾镜头,画面收束,保留主体识别与情绪余韵。",
camera: "缓慢拉远",
},
];
const script = `围绕“${compactText(state.text, 80)}”制作一条节奏清晰、主体连续的短视频,包含开场建立、过程推进与结尾收束三段。`;
return {
script,
storyboard,
finalPrompt: buildVideoFinalPrompt({
script,
storyboard,
originalText: state.text,
}),
recommendedVideoParams,
};
}
function sanitizeVideoPlan(raw: unknown, state: AutoAgentStateType): AgentVideoPlan {
if (!raw || typeof raw !== "object") return heuristicVideoPlan(state);
const payload = raw as Record<string, any>;
const recommendedVideoParams = sanitizeRecommendedVideoParams(payload.recommendedVideoParams);
const script =
typeof payload.script === "string" && payload.script.trim()
? payload.script.trim().slice(0, 400)
: `围绕“${compactText(state.text, 80)}”生成视频。`;
const storyboardInput = Array.isArray(payload.storyboard) ? payload.storyboard : [];
const storyboard: AgentVideoStoryboardShot[] = storyboardInput
.map((item: any, idx) => {
const shot = Number.isFinite(item?.shot) ? Math.max(1, Math.round(item.shot)) : idx + 1;
const duration = normalizeVideoDuration(Number(item?.duration)) || 3;
const title =
typeof item?.title === "string" && item.title.trim()
? item.title.trim().slice(0, 24)
: `镜头${shot}`;
const description =
typeof item?.description === "string" && item.description.trim()
? item.description.trim().slice(0, 180)
: title;
const prompt =
typeof item?.prompt === "string" && item.prompt.trim()
? item.prompt.trim().slice(0, 260)
: description;
const camera =
typeof item?.camera === "string" && item.camera.trim()
? item.camera.trim().slice(0, 24)
: undefined;
return { shot, title, duration, description, prompt, camera };
})
.filter((item) => item.prompt);
const fallback = heuristicVideoPlan(state);
const safeStoryboard = storyboard.length > 0 ? storyboard.slice(0, 8) : fallback.storyboard;
const finalPrompt =
typeof payload.finalPrompt === "string" && payload.finalPrompt.trim()
? payload.finalPrompt.trim().slice(0, 1400)
: buildVideoFinalPrompt({
script,
storyboard: safeStoryboard,
originalText: state.text,
});
const negativePrompt =
typeof payload.negativePrompt === "string" && payload.negativePrompt.trim()
? payload.negativePrompt.trim().slice(0, 360)
: undefined;
return {
script,
storyboard: safeStoryboard,
finalPrompt,
negativePrompt,
recommendedVideoParams: recommendedVideoParams || fallback.recommendedVideoParams,
};
}
function sanitizeRecommendedImageParams(
params: AgentRecommendedImageParams | undefined,
intent?: AnalysisResult["intent"],
): AgentRecommendedImageParams | undefined {
if (!params) return undefined;
const safe: AgentRecommendedImageParams = {};
const normalizedAspectRatio = normalizeAspectRatio(params.aspectRatio);
const normalizedImageSize = normalizeImageSize(params.imageSize);
if (normalizedAspectRatio) safe.aspectRatio = normalizedAspectRatio;
if (normalizedImageSize) safe.imageSize = normalizedImageSize;
if (typeof params.imageCount === "number" && Number.isFinite(params.imageCount)) {
const min = 1;
const max = intent === "image_edit" ? 1 : 4;
safe.imageCount = Math.min(Math.max(Math.round(params.imageCount), min), max);
}
if (intent === "image_edit") {
safe.imageCount = 1;
}
if (typeof params.negativePrompt === "string" && params.negativePrompt.trim()) {
safe.negativePrompt = params.negativePrompt.trim().slice(0, 500);
}
if (typeof params.promptExtend === "boolean") {
safe.promptExtend = params.promptExtend;
}
if (intent === "image_edit") {
safe.promptExtend = false;
}
return Object.keys(safe).length > 0 ? safe : undefined;
}
function heuristicImageParams(
text: string,
analysis: AnalysisResult,
): AgentRecommendedImageParams | undefined {
const normalized = text.toLowerCase();
const params: AgentRecommendedImageParams = {
imageSize: "2K",
promptExtend: analysis.intent === "image_edit" ? false : true,
};
if (analysis.intent === "image_edit") {
params.imageCount = 1;
} else if (/多张|四张|4张|三张|3张/.test(normalized)) {
params.imageCount = /四张|4张/.test(normalized) ? 4 : 3;
} else if (/两张|2张|双图/.test(normalized)) {
params.imageCount = 2;
} else {
params.imageCount = 1;
}
if (/横图|横版|宽屏|封面|banner|海报/.test(normalized)) {
params.aspectRatio = "16:9";
} else if (/竖图|竖版|手机壁纸|全身|海报竖版/.test(normalized)) {
params.aspectRatio = "9:16";
} else if (/方图|方形|头像|logo|电商主图/.test(normalized)) {
params.aspectRatio = "1:1";
} else {
params.aspectRatio = "auto";
}
return sanitizeRecommendedImageParams(params, analysis.intent);
}
function buildStrategicFriendlyMessage(
analysis: AnalysisResult,
state: AutoAgentStateType,
): Promise<string | null> {
const fallbackQuestion = analysis.clarifyingQuestions?.[0];
const existingReply = analysis.conversationalReply?.trim();
if (analysis.needsClarification) {
return Promise.resolve(
analysis.conversationalReply || fallbackQuestion || "我先确认一下,你希望我优先调整哪一部分?"
);
}
if (existingReply) {
const isStructuredReply =
/###\s*需求理解/.test(existingReply) ||
/###\s*图片分析/.test(existingReply) ||
/###\s*执行计划/.test(existingReply);
if (isStructuredReply || existingReply.length >= 24) {
return Promise.resolve(existingReply);
}
}
return withTimeoutFallback(
generateAgentConversation(analysis, state),
12000,
existingReply || null,
);
}
type EnabledModelWithProvider = Awaited<
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
>[number];
function isLikelyVisionCapableModel(item: EnabledModelWithProvider) {
if (!item.model.isEnabled || item.model.isSubModel) return false;
if (item.model.supportsVision) return true;
if (isAiModelMultimodal(item.model)) return true;
const providerType = String(item.provider.type || "");
const modelText = `${item.model.displayName || ""} ${item.model.modelId || ""}`.toLowerCase();
if (providerType === "volcano-multimodal") return true;
if (
providerType === "zhipu" &&
/(glm[-_.]?4v|glm[-_.]?4\.?6v|vision|多模态)/i.test(modelText)
) {
return true;
}
if (
providerType === "alibaba" &&
/(qwen|vl|vision|通义|多模态)/i.test(modelText)
) {
return true;
}
if (
["custom", "openai", "local"].includes(providerType) &&
/(doubao[-_.]?seed[-_.]?2[-_.]?0|seed[-_.]?2[-_.]?0|qwen[-_.]?vl|glm[-_.]?4v|gpt[-_.]?4o|vision|vl|多模态)/i.test(
modelText,
)
) {
return true;
}
return false;
}
function shouldPreferRemoteVisionUrls(item?: EnabledModelWithProvider) {
if (!item) return false;
const providerType = String(item.provider.type || "");
const baseUrl = String((item.provider as { baseUrl?: string | null })?.baseUrl || "").toLowerCase();
const modelText = `${item.model.displayName || ""} ${item.model.modelId || ""}`.toLowerCase();
if (providerType === "volcano-multimodal" || providerType === "doubao-video" || providerType === "volcano-video") {
return true;
}
if (baseUrl.includes("ark.cn-beijing.volces.com")) {
return true;
}
if (/(doubao[-_.]?seed[-_.]?2[-_.]?0|seed[-_.]?2[-_.]?0)/i.test(modelText)) {
return true;
}
return false;
}
function pickQwenVisionModel(models: EnabledModelWithProvider[]) {
const visionModels = models.filter((item) => isLikelyVisionCapableModel(item));
const zhipuVision = visionModels.find(
(item) =>
item.provider.type === "zhipu" ||
/(glm[-_.]?4v|glm[-_.]?4\.?1v|glm[-_.]?4\.?5v|vision)/i.test(
`${item.model.displayName} ${item.model.modelId}`,
),
);
if (zhipuVision) return zhipuVision;
const qwenByName = visionModels.find(
(item) =>
item.provider.type === "alibaba" &&
/(qwen|通义|vl)/i.test(`${item.model.displayName} ${item.model.modelId}`)
);
if (qwenByName) return qwenByName;
const alibabaVision = visionModels.find((item) => item.provider.type === "alibaba");
if (alibabaVision) return alibabaVision;
return visionModels[0];
}
function pickAgentVisionModel(
agentModel: EnabledModelWithProvider | undefined,
models: EnabledModelWithProvider[],
) {
if (agentModel && isLikelyVisionCapableModel(agentModel)) {
return agentModel;
}
return pickQwenVisionModel(models);
}
function pickAgentReasoningModel(
agentModel: EnabledModelWithProvider | undefined,
models: EnabledModelWithProvider[],
) {
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
if (
agentModel &&
agentModel.model.isEnabled &&
isAiModelMultimodal(agentModel.model)
) {
return agentModel;
}
if (agentModel && isReasoningCompatibleProvider(agentModel.provider.type)) return agentModel;
const zhipuModel = models.find(
(item) =>
item.provider.type === "zhipu" &&
item.model.isEnabled &&
!item.model.isSubModel &&
isReasoningCompatibleProvider(item.provider.type)
);
if (zhipuModel) return zhipuModel;
return models.find(
(item) =>
item.model.isEnabled &&
!item.model.isSubModel &&
isReasoningCompatibleProvider(item.provider.type)
);
}
function parseJsonBlock<T = Record<string, unknown>>(text: string): T | null {
const match = text.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]) as T;
} catch {
return null;
}
}
async function generateAgentConversation(
analysis: AnalysisResult,
state: AutoAgentStateType
): Promise<string | null> {
try {
const {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const reasoningModelConfig = pickAgentReasoningModel(agentModel, allModels);
if (!reasoningModelConfig) {
return analysis.conversationalReply || null;
}
const reasoningModel = await getLanguageModelById(reasoningModelConfig.model.id);
if (!reasoningModel) {
return analysis.conversationalReply || null;
}
const history = state.historyMessages
.slice(-6)
.map((msg) => `${msg.role === "user" ? "用户" : "助手"}: ${compactText(msg.content, 120)}`)
.join("\n");
const validImageUrls = state.imageUrls
.filter((url) => isHttpUrl(url))
.slice(0, AGENT_VISION_MAX_IMAGES);
const imageInfo = state.hasImage
? `用户本轮可用图片数量:${state.imageUrls.length || 1}。图片来源:${state.imageFromHistory ? "历史消息复用" : "本轮上传"}。`
: "用户本轮没有上传图片。";
let visualAnalysis = "";
if (state.hasImage && validImageUrls.length > 0) {
try {
const visionModelConfig = pickAgentVisionModel(agentModel, allModels);
if (!visionModelConfig) {
throw new Error("missing_vision_model");
}
const visionModel = await getLanguageModelById(visionModelConfig.model.id);
if (!visionModel) {
throw new Error("missing_vision_runtime");
}
const visionPrompt = `请分析以下参考图片,给出可用于生图/修图执行前决策的结果。
要求:
1) 每张图输出 1 行,格式:图X:主体...;风格...;构图...;光影与色调...;材质/氛围...
2) 只写客观可见信息,不要编造品牌、尺寸、镜头参数。
3) 全部输出控制在 6 行内。`;
const preferRemoteVisionUrls = shouldPreferRemoteVisionUrls(visionModelConfig);
const visionInputs = await buildAgentVisionInputs(validImageUrls, {
preferRemoteUrls: preferRemoteVisionUrls,
});
const { text: visionText } = await generateText({
model: visionModel,
messages: [
{
role: "user" as const,
content: [{ type: "text" as const, text: visionPrompt }, ...visionInputs],
},
],
temperature: 0.2,
maxTokens: 360,
abortSignal: AbortSignal.timeout(12000),
} as any);
visualAnalysis = visionText.trim();
console.log(
`[Auto Agent] Vision analysis model: ${visionModelConfig.model.displayName} (${visionModelConfig.provider.type}), preferRemote=${preferRemoteVisionUrls}`
);
} catch (error) {
console.warn("[Auto Agent] Vision analysis failed, continue without visual summary");
visualAnalysis = "";
}
}
const systemPrompt = `你是一个真实执行中的 AI 生图/修图 Agent。你的核心目标是提升用户一次生成成功率。
你必须按以下 AGENT SKILLS 执行:
1) 需求拆解:把用户目标拆成可执行指令,优先满足用户明确要求。
2) 风险预判:主动识别可能导致失败的歧义(主体数量、视角、风格冲突、文字内容)。
3) 提示词策略:给出可落地的正向约束和必要的负向约束,避免空泛描述。
4) 参数策略:在输出设置中明确建议宽高比、清晰度档位、出图数量(若用户未指定)。
输出要求:
- 不要说“看不到图片/无法访问图片”。
- 不暴露内部实现、模型路由、算法细节。
- 输出 Markdown,控制在 8-14 行。
- 有图片时必须按顺序输出以下 Markdown H3 标题(逐字一致):
### 需求理解
### 图片分析
### 执行计划
### 输出设置
- “图片分析”必须基于视觉分析结果写具体观察,不允许写泛化空话。
- 只有明确拿到原图分辨率时,才能写具体像素尺寸;否则只写宽高比/清晰度目标。`;
const prompt = [
`【用户原始需求】${state.text || "(无文本)"}`,
`【意图分析】intent=${analysis.intent}; subIntent=${analysis.subIntent || "none"}; confidence=${analysis.confidence}`,
`【图片信息】${imageInfo}`,
visualAnalysis ? `【视觉模型预分析】\n${visualAnalysis}` : "",
state.context ? `【画布上下文】${compactText(state.context, 300)}` : "",
history ? `【最近对话】\n${history}` : "",
state.hasImage
? "请先做本轮图片分析,再输出执行前汇报。"
: "请输出本轮执行前汇报。",
]
.filter(Boolean)
.join("\n\n");
const { text } = await generateText({
model: reasoningModel,
system: systemPrompt,
prompt,
temperature: 0.35,
maxTokens: 520,
abortSignal: AbortSignal.timeout(12000),
} as any);
const cleaned = text.trim();
if (cleaned.length < 10) {
return analysis.conversationalReply || null;
}
console.log(
`[Auto Agent] Conversation model: ${reasoningModelConfig.model.displayName} (${reasoningModelConfig.provider.type})`
);
return cleaned;
} catch (error) {
return analysis.conversationalReply || null;
}
}
async function recommendImageParamsWithAgent(
analysis: AnalysisResult,
state: AutoAgentStateType,
): Promise<AgentRecommendedImageParams | undefined> {
if (analysis.intent !== "image_edit" && analysis.intent !== "image_gen") {
return undefined;
}
try {
const {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const plannerModelConfig = pickAgentReasoningModel(agentModel, allModels);
if (!plannerModelConfig) {
return heuristicImageParams(state.text, analysis);
}
const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
if (!plannerModel) {
return heuristicImageParams(state.text, analysis);
}
const history = state.historyMessages
.slice(-4)
.map((msg) => `${msg.role === "user" ? "用户" : "助手"}: ${compactText(msg.content, 120)}`)
.join("\n");
const requestText = [
`用户请求:${state.text || "(无文本)"}`,
`识别意图:${analysis.intent}/${analysis.subIntent || "none"}`,
`是否有参考图:${state.hasImage ? "是" : "否"}`,
history ? `最近对话:\n${history}` : "",
"请给出本次最稳妥的生图参数建议。",
]
.filter(Boolean)
.join("\n\n");
const { text } = await generateText({
model: plannerModel,
system: `你是生图参数规划 Agent。目标是提升首图命中率和稳定性。
只输出 JSON,不要输出解释。格式如下:
{
"aspectRatio": "1:1|2:3|3:2|3:4|4:3|4:5|5:4|9:16|16:9|21:9|auto",
"imageSize": "1K|2K|4K",
"imageCount": 1,
"negativePrompt": "可选,简洁",
"promptExtend": true
}
规则:
- image_edit 场景强制 imageCount=1。
- 若用户未指定比例,优先 auto。
- negativePrompt 仅在明显需要避坑时填写。`,
prompt: requestText,
temperature: 0.15,
maxTokens: 220,
abortSignal: AbortSignal.timeout(10000),
} as any);
const parsed = parseJsonBlock<AgentRecommendedImageParams>(text);
const sanitized = sanitizeRecommendedImageParams(parsed || undefined, analysis.intent);
if (sanitized) {
console.log(
`[Auto Agent] Parameter planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`
);
return sanitized;
}
return heuristicImageParams(state.text, analysis);
} catch {
return heuristicImageParams(state.text, analysis);
}
}
async function recommendVideoPlanWithAgent(
state: AutoAgentStateType,
): Promise<AgentVideoPlan> {
try {
const {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const { getLanguageModelById } = await import("./dynamic-provider");
const allModels = await getEnabledAiModelsWithProvider();
const agentModel = await getAgentAiModelWithProvider();
const plannerModelConfig = pickAgentReasoningModel(agentModel, allModels);
if (!plannerModelConfig) {
return heuristicVideoPlan(state);
}
const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
if (!plannerModel) {
return heuristicVideoPlan(state);
}
const history = state.historyMessages
.slice(-6)
.map((msg) => `${msg.role === "user" ? "用户" : "助手"}: ${compactText(msg.content, 120)}`)
.join("\n");
const prompt = [
`用户请求:${state.text || "生成视频"}`,
`是否有参考图:${state.hasImage ? "是" : "否"}(${state.imageUrls.length} 张)`,
history ? `最近对话:\n${history}` : "",
"请产出可直接用于视频生成的脚本与分镜。",
]
.filter(Boolean)
.join("\n\n");
const { text } = await generateText({
model: plannerModel,
system: `你是视频生成 Agent 规划器,目标是最大化一次出片成功率。
只输出 JSON,不要输出解释。格式:
{
"script": "120字内剧情脚本",
"recommendedVideoParams": {
"mode": "text-to-video|first-frame|first-last-frame|reference-images",
"resolution": "480p|720p|1080p",
"ratio": "16:9|4:3|1:1|3:4|9:16|21:9|adaptive",
"duration": 5,
"generateAudio": false,
"cameraMove": "none|static|handheld|dynamic-orbit"
},
"storyboard": [
{
"shot": 1,
"title": "镜头标题",
"duration": 3,
"description": "画面描述",
"camera": "运镜方式",
"prompt": "该镜头可直接喂给视频模型的提示词"
}
],
"negativePrompt": "可选",
"finalPrompt": "整合后的最终视频提示词"
}
要求:
- 分镜 2~6 个,时长总计与 duration 接近。
- 有参考图时优先 first-frame 或 first-last-frame。
- 避免抽象空话,必须可执行。`,
prompt,
temperature: 0.2,
maxTokens: 720,
abortSignal: AbortSignal.timeout(12000),
} as any);
const parsed = parseJsonBlock<Record<string, unknown>>(text);
const plan = sanitizeVideoPlan(parsed, state);
console.log(
`[Auto Agent] Video planner model: ${plannerModelConfig.model.displayName} (${plannerModelConfig.provider.type})`
);
return plan;
} catch {
return heuristicVideoPlan(state);
}
}
async function refineRouteWithAgent(
state: AutoAgentStateType,
baseRoute: RouteResult,
): Promise<RouteResult> {
if (!state.analysis || (state.analysis.intent !== "image_edit" && state.analysis.intent !== "image_gen")) {
return baseRoute;
}
try {
const { db } = await import("@/lib/db");
const { siteSettings } = await import("@/lib/db/schema");
const [settings] = await db.select().from(siteSettings).limit(1);
const configuredDefaultModelId = settings?.canvasAutoModeDefaultModelId || null;
if (configuredDefaultModelId && baseRoute.id === configuredDefaultModelId) {
console.log(
`[Auto Agent] Keep admin default model for auto mode: ${baseRoute.displayName}`
);
return baseRoute;
}
const {
getEnabledAiModelsWithProvider,
getAgentAiModelWithProvider,
} = await import("@/lib/db/queries");
const { getLanguageModelById } = await import("./dynamic-provider");
const { isAiModelAgentAuto } = await import("@/lib/db/ai-model-flags");
const allModelsRaw = await getEnabledAiModelsWithProvider(false);
const allModels = allModelsRaw.filter((item) => !isAiModelAgentAuto(item.model));
const imageCandidates = allModels
.filter((item) => item.model.supportsImageGen && item.model.isEnabled && !item.model.isSubModel)
.slice(0, 12);
if (imageCandidates.length <= 1) return baseRoute;
const agentModel = await getAgentAiModelWithProvider();
const plannerModelConfig = pickAgentReasoningModel(agentModel, allModelsRaw);
if (!plannerModelConfig) return baseRoute;
const plannerModel = await getLanguageModelById(plannerModelConfig.model.id);
if (!plannerModel) return baseRoute;
const candidatesText = imageCandidates
.map((item, index) => `${index + 1}. id=${item.model.id}; 名称=${item.model.displayName}; provider=${item.provider.type}`)
.join("\n");
const { text } = await generateText({
model: plannerModel,
system: `你是生图模型选择 Agent。目标是优先提高一次成功率。
只输出 JSON:
{
"modelId": "候选里的id",
"reason": "一句话中文理由,不超过24字"
}
如果无法判断,也必须给出候选中的 modelId。`,
prompt: `用户请求:${state.text || "(无文本)"}
意图:${state.analysis.intent}/${state.analysis.subIntent || "none"}
候选模型:
${candidatesText}`,
temperature: 0.1,
maxTokens: 120,
abortSignal: AbortSignal.timeout(8000),
} as any);
const parsed = parseJsonBlock<{ modelId?: string; reason?: string }>(text);
if (!parsed?.modelId) return baseRoute;
const chosen = imageCandidates.find((item) => item.model.id === parsed.modelId);
if (!chosen) return baseRoute;
return {
id: chosen.model.id,
displayName: chosen.model.displayName,
providerType: chosen.provider.type,
modelId: chosen.model.modelId,
reason: `Agent规划: ${(parsed.reason || chosen.model.displayName).slice(0, 40)}`,
};
} catch {
return baseRoute;
}
}
const analyzeNode = async (state: AutoAgentStateType) => {
const { analyzeIntent } = await import("./smart-analyzer");
const analysis = await analyzeIntent({
text: state.text,
hasImage: state.hasImage,
imageUrls: state.imageUrls,
imageFromHistory: state.imageFromHistory,
historyMessages: state.historyMessages,
context: state.context ?? undefined,
});
const fallbackQuestion = analysis.needsClarification
? analysis.clarifyingQuestions?.[0]
: undefined;
const strategicMessage = await buildStrategicFriendlyMessage(analysis, state);
return {
analysis,
friendlyMessage: strategicMessage || analysis.conversationalReply || fallbackQuestion || null,
};
};
const shouldOptimize = (state: AutoAgentStateType) => {
if (!state.analysis) return "route";
if (state.analysis.needsClarification) return "route";
if (state.analysis.intent === "video_gen") {
return "optimize";
}
if (state.analysis.intent === "image_gen") {
return "optimize";
}
if (state.analysis.intent === "image_edit" && state.analysis.subIntent) {
return "optimize";
}
return "route";
};
const optimizeNode = async (state: AutoAgentStateType) => {
if (!state.analysis) {
return {};
}
if (state.analysis.intent === "video_gen") {
const videoPlan = await recommendVideoPlanWithAgent(state);
const storyboardPreview = videoPlan.storyboard
.slice(0, 4)
.map(
(shot) =>
`- 镜头${shot.shot}(${shot.duration}s):${shot.title}|${compactText(
shot.description || shot.prompt,
64,
)}`
)
.join("\n");
const friendlyMessage = [
"### 需求理解",
`- ${compactText(state.text || "生成视频", 110)}`,
"### 脚本规划",
`- ${videoPlan.script}`,
"### 分镜规划",
storyboardPreview || "- 已生成分镜,执行时自动拼接。",
"### 执行说明",
"- 已基于上述脚本与分镜优化提示词并提交视频模型执行。",
].join("\n");
return {
friendlyMessage,
optimizedPrompt: videoPlan.finalPrompt,
videoPlan,
};
}
if (state.analysis.intent === "image_edit" && !state.analysis.subIntent) {
const recommendedImageParams = heuristicImageParams(state.text, state.analysis);
return { recommendedImageParams };
}
if (state.analysis.intent !== "image_edit" && state.analysis.intent !== "image_gen") {
return {};
}
const { optimizePrompt } = await import("./prompt-optimizer");
const optimized = optimizePrompt({
originalPrompt: state.text,
intent: state.analysis.intent,
subIntent: state.analysis.subIntent,
});
const mergedParams = sanitizeRecommendedImageParams(
{
...optimized.recommendedImageParams,
negativePrompt:
optimized.negativePrompt && optimized.negativePrompt.trim().length > 0
? optimized.negativePrompt
: optimized.recommendedImageParams?.negativePrompt,
},
state.analysis.intent,
) ?? heuristicImageParams(state.text, state.analysis);
return {
optimizedPrompt: optimized.optimizedPrompt,
recommendedImageParams: mergedParams,
};
};
const routeNode = async (state: AutoAgentStateType) => {
const { routeToModel } = await import("./model-router");
const route = await routeToModel({
intent: state.analysis?.intent || "chat",
subIntent: state.analysis?.subIntent,
hasImage: state.hasImage,
});
return { route };
};
let compiledGraph: ReturnType<typeof buildGraph> | null = null;
function buildGraph() {
return new StateGraph(AutoAgentState)
.addNode("analyze", analyzeNode)
.addNode("optimize", optimizeNode)
.addNode("routeModel", routeNode)
.addEdge(START, "analyze")
.addConditionalEdges("analyze", shouldOptimize, {
optimize: "optimize",
route: "routeModel",
})
.addEdge("optimize", "routeModel")
.addEdge("routeModel", END)
.compile();
}
function getGraph() {
if (!compiledGraph) {
compiledGraph = buildGraph();
}
return compiledGraph;
}
export async function runAutoAgentGraph(params: AutoAgentInput): Promise<AutoAgentResult> {
const graph = getGraph();
const result = await graph.invoke({
text: params.text,
hasImage: params.hasImage,
imageUrls: params.imageUrls ?? [],
imageFromHistory: params.imageFromHistory ?? false,
historyMessages: params.historyMessages ?? [],
context: params.context ?? null,
analysis: null,
friendlyMessage: null,
optimizedPrompt: null,
recommendedImageParams: null,
videoPlan: null,
route: null,
});
return {
analysis: result.analysis ?? undefined,
friendlyMessage: result.friendlyMessage ?? undefined,
optimizedPrompt: result.optimizedPrompt ?? undefined,
recommendedImageParams: result.recommendedImageParams ?? undefined,
videoPlan: result.videoPlan ?? undefined,
route: result.route ?? undefined,
};
}