feat(agent): enforce visual analysis and extend auto timeouts to 120s
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-02-19 03:56:58 +08:00
parent 55b2c836b4
commit e5c4b3780c
3 changed files with 154 additions and 63 deletions

View File

@ -672,13 +672,14 @@ function sanitizeAgentOutputSettingsSection(params: {
const { message, userImageParams, hasUserCustomImageParams } = params;
if (!message?.trim()) return message;
const outputSectionRegex = /###\s*输出设置[\s\S]*?(?=\n###\s*|$)/;
const outputSectionRegex =
/(?:###\s*输出设置|【\s*输出设置\s*】)[\s\S]*?(?=\n(?:###\s*|【\s*[^】]+\s*】)|$)/;
if (!outputSectionRegex.test(message)) return message;
if (!hasUserCustomImageParams) {
return message.replace(
outputSectionRegex,
"### 输出设置\n- 参数:按用户面板当前设置执行(未自动改写)"
"【输出设置】\n- 参数:按用户面板当前设置执行(未自动改写)"
);
}
@ -701,7 +702,46 @@ function sanitizeAgentOutputSettingsSection(params: {
}
lines.push("- 其余参数:保持用户面板当前设置");
return message.replace(outputSectionRegex, `### 输出设置\n${lines.join("\n")}`);
return message.replace(outputSectionRegex, `【输出设置】\n${lines.join("\n")}`);
}
function normalizeAgentReportHeadingLine(line: string): string {
const trimmed = line.trim();
if (!trimmed) return line;
const markdownHeading = trimmed.match(/^#{1,6}\s*(.+?)\s*$/);
const plainTitle = markdownHeading?.[1]?.trim() || trimmed;
const normalizedTitle = plainTitle
.replace(/^[^\u4e00-\u9fa5A-Za-z0-9]+/, "")
.trim();
const knownTitles = [
"需求理解",
"图片分析",
"执行计划",
"输出设置",
"脚本规划",
"分镜规划",
"执行说明",
"视频生成完成",
];
if (knownTitles.some((title) => normalizedTitle === title)) {
return `【${normalizedTitle}】`;
}
if (markdownHeading) {
return `【${normalizedTitle || plainTitle}】`;
}
return line;
}
function normalizeAgentReportForDisplay(message?: string): string | undefined {
if (!message?.trim()) return message;
return message
.split("\n")
.map((line) => normalizeAgentReportHeadingLine(line))
.join("\n");
}
function guessImageMediaType(url: string): string {
@ -812,7 +852,7 @@ function extractMessageImageUrls(parts: any[]): string[] {
function shouldEnrichImageAnalysisText(message?: string): boolean {
if (!message) return false;
if (!/###\s*图片分析/.test(message)) return false;
if (!/(###\s*图片分析|【\s*图片分析\s*】)/.test(message)) return false;
return /(基于当前上传图片处理|基于你选中的原图处理|基于上一轮图片继续处理)/.test(message);
}
@ -820,12 +860,14 @@ function injectImageAnalysisSummary(message: string, summary: string): string {
const normalizedSummary = summary.replace(/\s+/g, " ").trim();
if (!normalizedSummary) return message;
const lines = message.split("\n");
const analysisHeaderIndex = lines.findIndex((line) => /^###\s*图片分析/.test(line.trim()));
const analysisHeaderIndex = lines.findIndex((line) =>
/^(###\s*图片分析|【\s*图片分析\s*】)/.test(line.trim())
);
if (analysisHeaderIndex < 0) return message;
for (let i = analysisHeaderIndex + 1; i < lines.length; i += 1) {
const current = lines[i]?.trim() || "";
if (/^###\s*/.test(current)) break;
if (/^(###\s*|【\s*[^】]+\s*】)/.test(current)) break;
if (/^- /.test(current)) {
lines[i] = `- ${normalizedSummary}`;
return lines.join("\n");
@ -837,6 +879,19 @@ function injectImageAnalysisSummary(message: string, summary: string): string {
return lines.join("\n");
}
function ensureImageAnalysisSummarySection(message: string, summary: string): string {
const normalizedSummary = summary.replace(/\s+/g, " ").trim();
if (!normalizedSummary) return message;
if (/(###\s*图片分析|【\s*图片分析\s*】)/.test(message)) {
return injectImageAnalysisSummary(message, normalizedSummary);
}
const trimmed = message.trim();
if (!trimmed) return `【图片分析】\n- ${normalizedSummary}`;
return `${trimmed}\n【图片分析】\n- ${normalizedSummary}`;
}
/**
* 自定义消息转换函数
* 注意:gemini-image 和 huanke-gemini 都需要 URL 格式(不是 Base64)
@ -1355,7 +1410,8 @@ let globalStreamContext: ResumableStreamContext | null = null;
const inFlightChatRequests = new Map<string, number>();
const IN_FLIGHT_REQUEST_TTL_MS = 5 * 60 * 1000;
const DUPLICATE_REQUEST_WAIT_MS = 2 * 60 * 1000;
const SMART_AUTO_GRAPH_TIMEOUT_MS = 45000;
const SMART_AUTO_GRAPH_TIMEOUT_MS = 120000;
const SMART_AUTO_FALLBACK_TIMEOUT_MS = 120000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@ -1905,7 +1961,7 @@ export async function POST(request: Request) {
context: undefined,
}),
"fallbackAnalyzeIntent",
15000,
SMART_AUTO_FALLBACK_TIMEOUT_MS,
);
if (rescueAnalysis.conversationalReply?.trim()) {
smartAutoFriendlyMessage = rescueAnalysis.conversationalReply.trim();
@ -1947,7 +2003,7 @@ export async function POST(request: Request) {
context: typeof autoContext === "string" ? autoContext : undefined,
}),
"fallbackVisualSummary",
15000,
SMART_AUTO_FALLBACK_TIMEOUT_MS,
);
if (summary?.trim()) {
fallbackVisualSummary = summary.trim();
@ -2138,44 +2194,59 @@ export async function POST(request: Request) {
});
}
if (
shouldRunAgentConversation &&
!smartAutoNeedsClarification &&
smartAutoFriendlyMessage &&
shouldEnrichImageAnalysisText(smartAutoFriendlyMessage)
) {
try {
const currentImageUrls = extractMessageImageUrls(message.parts as any[]);
const summaryImageUrls =
currentImageUrls.length > 0 ? currentImageUrls : smartAutoHistoryImages;
if (shouldRunAgentConversation && !smartAutoNeedsClarification) {
const currentImageUrls = extractMessageImageUrls(message.parts as any[]);
const summaryImageUrls =
currentImageUrls.length > 0 ? currentImageUrls : smartAutoHistoryImages;
const shouldRequireImageAnalysis = summaryImageUrls.length > 0;
if (summaryImageUrls.length > 0) {
if (shouldRequireImageAnalysis) {
try {
const { summarizeImageForExecutionReport } = await import("@/lib/ai/smart-analyzer");
const summary = await withOperationTimeout(
summarizeImageForExecutionReport({
text: message.parts
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" "),
imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined,
}),
"enrichImageAnalysisSummary",
8000,
);
if (summary?.trim()) {
smartAutoFriendlyMessage = injectImageAnalysisSummary(
smartAutoFriendlyMessage,
summary.trim(),
const latestUserText = message.parts
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join(" ");
let summary: string | null = null;
const summaryTimeouts = [120000];
for (const timeoutMs of summaryTimeouts) {
summary = await withOperationTimeout(
summarizeImageForExecutionReport({
text: latestUserText,
imageUrls: summaryImageUrls,
context: typeof autoContext === "string" ? autoContext : undefined,
}),
"enrichImageAnalysisSummary",
timeoutMs,
);
console.log("[Smart Auto Mode] ✅ Enriched image analysis summary");
if (summary?.trim()) break;
}
if (summary?.trim()) {
const baseMessage =
smartAutoFriendlyMessage ||
buildAutoModeFallbackMessage({
text: latestUserText,
hasImage: true,
fromHistory: currentImageUrls.length === 0 && summaryImageUrls.length > 0,
imageSummary: summary.trim(),
});
smartAutoFriendlyMessage = ensureImageAnalysisSummarySection(baseMessage, summary.trim());
console.log("[Smart Auto Mode] ✅ Image analysis summary ready");
} else if (smartAutoFriendlyMessage && shouldEnrichImageAnalysisText(smartAutoFriendlyMessage)) {
console.warn("[Smart Auto Mode] ⚠️ Image summary unavailable, keep original analysis text");
}
} catch (enrichError) {
console.warn("[Smart Auto Mode] Skip image analysis enrichment:", enrichError);
}
} catch (enrichError) {
console.warn("[Smart Auto Mode] Skip image analysis enrichment:", enrichError);
}
}
if (shouldRunAgentConversation && smartAutoFriendlyMessage) {
smartAutoFriendlyMessage = normalizeAgentReportForDisplay(smartAutoFriendlyMessage);
}
const session = await auth();
if (!session?.user) {

View File

@ -105,7 +105,8 @@ async function withTimeoutFallback<T>(
const AGENT_VISION_MAX_IMAGES = 4;
const AGENT_VISION_MAX_SIDE = 500;
const AGENT_VISION_FETCH_TIMEOUT_MS = 10_000;
const AGENT_OPERATION_TIMEOUT_MS = 120_000;
const AGENT_VISION_FETCH_TIMEOUT_MS = AGENT_OPERATION_TIMEOUT_MS;
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
@ -514,7 +515,10 @@ function buildStrategicFriendlyMessage(
const isStructuredReply =
/###\s*需求理解/.test(existingReply) ||
/###\s*图片分析/.test(existingReply) ||
/###\s*执行计划/.test(existingReply);
/###\s*执行计划/.test(existingReply) ||
/【\s*需求理解\s*】/.test(existingReply) ||
/【\s*图片分析\s*】/.test(existingReply) ||
/【\s*执行计划\s*】/.test(existingReply);
if (isStructuredReply || existingReply.length >= 24) {
return Promise.resolve(existingReply);
}
@ -522,7 +526,7 @@ function buildStrategicFriendlyMessage(
return withTimeoutFallback(
generateAgentConversation(analysis, state),
12000,
AGENT_OPERATION_TIMEOUT_MS,
existingReply || null,
);
}
@ -727,7 +731,7 @@ async function generateAgentConversation(
],
temperature: 0.2,
maxTokens: 360,
abortSignal: AbortSignal.timeout(12000),
abortSignal: AbortSignal.timeout(AGENT_OPERATION_TIMEOUT_MS),
} as any);
visualAnalysis = visionText.trim();
@ -751,12 +755,12 @@ async function generateAgentConversation(
输出要求:
- 不要说“看不到图片/无法访问图片”。
- 不暴露内部实现、模型路由、算法细节。
- 输出 Markdown,控制在 8-14 行。
- 有图片时必须按顺序输出以下 Markdown H3 标题(逐字一致):
### 需求理解
### 图片分析
### 执行计划
### 输出设置
- 输出控制在 8-14 行,标题行不要使用 # 号。
- 有图片时必须按顺序输出以下标题(逐字一致):
【需求理解】
【图片分析】
【执行计划】
【输出设置】
- “图片分析”必须基于视觉分析结果写具体观察,不允许写泛化空话。
- 只有明确拿到原图分辨率时,才能写具体像素尺寸;否则只写宽高比/清晰度目标。`;
@ -780,7 +784,7 @@ async function generateAgentConversation(
prompt,
temperature: 0.35,
maxTokens: 520,
abortSignal: AbortSignal.timeout(12000),
abortSignal: AbortSignal.timeout(AGENT_OPERATION_TIMEOUT_MS),
} as any);
const cleaned = text.trim();
@ -854,7 +858,7 @@ async function recommendImageParamsWithAgent(
prompt: requestText,
temperature: 0.15,
maxTokens: 220,
abortSignal: AbortSignal.timeout(10000),
abortSignal: AbortSignal.timeout(AGENT_OPERATION_TIMEOUT_MS),
} as any);
const parsed = parseJsonBlock<AgentRecommendedImageParams>(text);
@ -939,7 +943,7 @@ async function recommendVideoPlanWithAgent(
prompt,
temperature: 0.2,
maxTokens: 720,
abortSignal: AbortSignal.timeout(12000),
abortSignal: AbortSignal.timeout(AGENT_OPERATION_TIMEOUT_MS),
} as any);
const parsed = parseJsonBlock<Record<string, unknown>>(text);
@ -1011,7 +1015,7 @@ async function refineRouteWithAgent(
${candidatesText}`,
temperature: 0.1,
maxTokens: 120,
abortSignal: AbortSignal.timeout(8000),
abortSignal: AbortSignal.timeout(AGENT_OPERATION_TIMEOUT_MS),
} as any);
const parsed = parseJsonBlock<{ modelId?: string; reason?: string }>(text);
@ -1108,12 +1112,27 @@ const optimizeNode = async (state: AutoAgentStateType) => {
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 { optimizePrompt, optimizePromptWithLLM } = await import("./prompt-optimizer");
const optimizedFromLlm = await withTimeoutFallback<
Awaited<ReturnType<typeof optimizePromptWithLLM>> | null
>(
optimizePromptWithLLM({
originalPrompt: state.text,
intent: state.analysis.intent,
subIntent: state.analysis.subIntent,
historyMessages: state.historyMessages,
context: state.context || undefined,
}),
AGENT_OPERATION_TIMEOUT_MS,
null,
);
const optimized =
optimizedFromLlm ||
optimizePrompt({
originalPrompt: state.text,
intent: state.analysis.intent,
subIntent: state.analysis.subIntent,
});
const mergedParams = sanitizeRecommendedImageParams(
{
...optimized.recommendedImageParams,

View File

@ -240,7 +240,8 @@ const ANALYZER_SYSTEM_PROMPT = `你是一个友好的AI图片编辑助手。用
const ANALYZER_MAX_IMAGE_COUNT = 2;
const ANALYZER_IMAGE_MAX_SIDE = 500;
const ANALYZER_IMAGE_FETCH_TIMEOUT_MS = 10000;
const ANALYZER_TIMEOUT_MS = 120000;
const ANALYZER_IMAGE_FETCH_TIMEOUT_MS = ANALYZER_TIMEOUT_MS;
const ZHIPU_VISION_MODEL_CANDIDATES = [
"glm-4.6v-flash",
"glm-4.6v",
@ -668,7 +669,7 @@ export async function summarizeImageForExecutionReport(params: {
],
temperature: 0.1,
maxTokens: 120,
abortSignal: AbortSignal.timeout(8000),
abortSignal: AbortSignal.timeout(ANALYZER_TIMEOUT_MS),
} as any);
const normalized = summaryText?.replace(/\s+/g, " ").trim();
@ -971,7 +972,7 @@ ${historyText}
],
temperature: 0.1,
maxTokens: 360,
abortSignal: AbortSignal.timeout(12000),
abortSignal: AbortSignal.timeout(ANALYZER_TIMEOUT_MS),
}
: {
model,
@ -979,7 +980,7 @@ ${historyText}
prompt: userMessage,
temperature: 0.1,
maxTokens: 300,
abortSignal: AbortSignal.timeout(10000),
abortSignal: AbortSignal.timeout(ANALYZER_TIMEOUT_MS),
};
const { text: responseText } = await generateText(requestPayload as any);