fix(agent): harden multimodal analysis routing and fallback
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-06 12:50:54 +08:00
parent bfcb777ba4
commit 889cf33b63
2 changed files with 234 additions and 67 deletions

View File

@ -90,6 +90,13 @@ function compactText(text: string, max = 48) {
return `${normalized.slice(0, max)}...`;
}
function getEnvInt(name: string, fallback: number, min: number, max: number): number {
const raw = process.env[name];
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
async function withTimeoutFallback<T>(
promise: Promise<T>,
timeoutMs: number,
@ -110,8 +117,18 @@ async function withTimeoutFallback<T>(
const AGENT_VISION_MAX_IMAGES = 4;
const AGENT_VISION_MAX_SIDE = 500;
const AGENT_OPERATION_TIMEOUT_MS = 120_000;
const AGENT_VISION_FETCH_TIMEOUT_MS = AGENT_OPERATION_TIMEOUT_MS;
const AGENT_OPERATION_TIMEOUT_MS = getEnvInt(
"AGENT_OPERATION_TIMEOUT_MS",
18000,
8000,
120000,
);
const AGENT_VISION_FETCH_TIMEOUT_MS = getEnvInt(
"AGENT_VISION_FETCH_TIMEOUT_MS",
12000,
3000,
60000,
);
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
@ -540,6 +557,10 @@ type EnabledModelWithProvider = Awaited<
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
>[number];
function isAgentGenerateTextProviderType(providerType: string): boolean {
return ["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
}
function isLikelyVisionCapableModel(item: EnabledModelWithProvider) {
if (!item.model.isEnabled || item.model.isSubModel) return false;
if (item.model.supportsVision) return true;
@ -576,6 +597,30 @@ function isLikelyVisionCapableModel(item: EnabledModelWithProvider) {
return false;
}
function getAgentModelIncompatibleReason(params: {
item: EnabledModelWithProvider;
requireVision: boolean;
}): string | null {
const providerType = String(params.item.provider.type || "");
if (!params.item.model.isEnabled || params.item.model.isSubModel) {
return "disabled_or_submodel";
}
if (!isAgentGenerateTextProviderType(providerType)) {
return `unsupported_provider:${providerType || "unknown"}`;
}
if (params.requireVision && !isLikelyVisionCapableModel(params.item)) {
return "vision_capability_missing";
}
return null;
}
function isAgentGenerateTextCompatibleModel(params: {
item: EnabledModelWithProvider;
requireVision: boolean;
}): boolean {
return !getAgentModelIncompatibleReason(params);
}
function shouldPreferRemoteVisionUrls(item?: EnabledModelWithProvider) {
if (!item) return false;
const providerType = String(item.provider.type || "");
@ -600,7 +645,9 @@ function shouldPreferRemoteVisionUrls(item?: EnabledModelWithProvider) {
}
function pickQwenVisionModel(models: EnabledModelWithProvider[]) {
const visionModels = models.filter((item) => isLikelyVisionCapableModel(item));
const visionModels = models.filter((item) =>
isAgentGenerateTextCompatibleModel({ item, requireVision: true }),
);
const zhipuVision = visionModels.find(
(item) =>
item.provider.type === "zhipu" ||
@ -626,7 +673,10 @@ function pickAgentVisionModel(
agentModel: EnabledModelWithProvider | undefined,
models: EnabledModelWithProvider[],
) {
if (agentModel && isLikelyVisionCapableModel(agentModel)) {
if (
agentModel &&
isAgentGenerateTextCompatibleModel({ item: agentModel, requireVision: true })
) {
return agentModel;
}
@ -658,32 +708,24 @@ function pickAgentReasoningModel(
agentModel: EnabledModelWithProvider | undefined,
models: EnabledModelWithProvider[],
) {
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
if (
agentModel &&
agentModel.model.isEnabled &&
isAiModelMultimodal(agentModel.model)
isAgentGenerateTextCompatibleModel({ item: agentModel, requireVision: false })
) {
return agentModel;
}
if (agentModel && isReasoningCompatibleProvider(agentModel.provider.type)) return agentModel;
const zhipuModel = models.find(
const candidates = models.filter((item) =>
isAgentGenerateTextCompatibleModel({ item, requireVision: false }),
);
const zhipuModel = candidates.find(
(item) =>
item.provider.type === "zhipu" &&
item.model.isEnabled &&
!item.model.isSubModel &&
isReasoningCompatibleProvider(item.provider.type)
!item.model.isSubModel
);
if (zhipuModel) return zhipuModel;
return models.find(
(item) =>
item.model.isEnabled &&
!item.model.isSubModel &&
isReasoningCompatibleProvider(item.provider.type)
);
return candidates[0];
}
function parseJsonBlock<T = Record<string, unknown>>(text: string): T | null {
@ -738,6 +780,17 @@ async function generateAgentConversation(
`[Auto Agent] Preferred analysis model: ${primaryAgentModel.model.displayName} (${primaryAgentModel.provider.type})`,
);
}
if (primaryAgentModel) {
const reasoningIncompatibleReason = getAgentModelIncompatibleReason({
item: primaryAgentModel,
requireVision: false,
});
if (reasoningIncompatibleReason) {
console.warn(
`[Auto Agent] Preferred model incompatible for reasoning, skip direct use: ${primaryAgentModel.model.displayName} (${primaryAgentModel.provider.type}), reason=${reasoningIncompatibleReason}`,
);
}
}
const reasoningModelConfig = pickAgentReasoningModel(primaryAgentModel, allModels);
if (!reasoningModelConfig) {
return analysis.conversationalReply || null;

View File

@ -247,7 +247,12 @@ function getEnvInt(name: string, fallback: number, min: number, max: number): nu
const ANALYZER_MAX_IMAGE_COUNT = 2;
const ANALYZER_IMAGE_MAX_SIDE = 500;
const ANALYZER_TIMEOUT_MS = 120000;
const ANALYZER_TIMEOUT_MS = getEnvInt(
"AGENT_ANALYZER_TIMEOUT_MS",
18000,
8000,
120000,
);
const ANALYZER_IMAGE_FETCH_TIMEOUT_MS = getEnvInt(
"AGENT_IMAGE_FETCH_TIMEOUT_MS",
15000,
@ -256,8 +261,8 @@ const ANALYZER_IMAGE_FETCH_TIMEOUT_MS = getEnvInt(
);
const ANALYZER_QWEN_FALLBACK_TIMEOUT_MS = getEnvInt(
"AGENT_QWEN_FALLBACK_TIMEOUT_MS",
35000,
10000,
12000,
6000,
90000,
);
const ANALYZER_QWEN_FALLBACK_RETRIES = getEnvInt(
@ -266,6 +271,12 @@ const ANALYZER_QWEN_FALLBACK_RETRIES = getEnvInt(
0,
1,
);
const ANALYZER_QWEN_FALLBACK_MAX_CANDIDATES = getEnvInt(
"AGENT_QWEN_FALLBACK_MAX_CANDIDATES",
2,
1,
4,
);
const ZHIPU_VISION_MODEL_CANDIDATES = [
"glm-4.6v-flash",
"glm-4.6v",
@ -396,33 +407,50 @@ async function buildAnalyzerVisionImagesByMode(params: {
return buildAnalyzerVisionImages(urls);
}
async function getQwen3VlFallbackImageCandidates(imageUrls?: string[]): Promise<string[]> {
const firstImageUrl = (imageUrls || []).find((url) => isHttpUrl(url));
if (!firstImageUrl) return [];
async function getQwen3VlFallbackImageCandidates(params: {
imageUrls?: string[];
visionImages?: Array<{ type: "image"; image: string }>;
}): Promise<string[]> {
const allInputs = [
...(params.visionImages || []).map((item) => String(item.image || "").trim()),
...((params.imageUrls || []).map((url) => String(url || "").trim())),
].filter(Boolean);
const candidates: string[] = [];
const inlineDataUrl = await toAnalyzerDataUrl(firstImageUrl);
if (inlineDataUrl) {
candidates.push(inlineDataUrl);
const uniqueInputs = Array.from(new Set(allInputs));
const inlineInputs = uniqueInputs.filter((value) => value.startsWith("data:image/"));
const remoteInputs = uniqueInputs.filter((value) => isHttpUrl(value));
if (inlineInputs.length === 0 && remoteInputs.length === 0) return [];
const candidates: string[] = [...inlineInputs];
for (const remoteUrl of remoteInputs.slice(0, ANALYZER_MAX_IMAGE_COUNT)) {
const inlineDataUrl = await toAnalyzerDataUrl(remoteUrl);
if (inlineDataUrl) {
candidates.push(inlineDataUrl);
}
candidates.push(remoteUrl);
}
candidates.push(firstImageUrl);
return Array.from(new Set(candidates));
return Array.from(new Set(candidates)).slice(0, ANALYZER_QWEN_FALLBACK_MAX_CANDIDATES);
}
async function analyzeWithHardcodedQwen3VlFallback(params: {
text: string;
hasImage: boolean;
imageUrls?: string[];
visionImages?: Array<{ type: "image"; image: string }>;
historyMessages?: HistoryMessage[];
context?: string;
reason: string;
}): Promise<AnalysisResult | null> {
if (!params.hasImage) return null;
const imageCandidates = await getQwen3VlFallbackImageCandidates(params.imageUrls);
const imageCandidates = await getQwen3VlFallbackImageCandidates({
imageUrls: params.imageUrls,
visionImages: params.visionImages,
});
if (imageCandidates.length === 0) {
console.warn(
`[Smart Analyzer] qwen3-vl fallback skipped: no valid image url (${params.reason})`,
`[Smart Analyzer] qwen3-vl fallback skipped: no valid image input (${params.reason})`,
);
return null;
}
@ -450,7 +478,7 @@ async function analyzeWithHardcodedQwen3VlFallback(params: {
try {
const { callQwen3Vl } = await import("./qwen3-vl");
console.warn(
`[Smart Analyzer] Primary analysis failed, fallback to hardcoded qwen3-vl (${params.reason})`,
`[Smart Analyzer] Primary analysis failed, fallback to hardcoded qwen3-vl (${params.reason}, candidates=${imageCandidates.length})`,
);
for (const imageInput of imageCandidates) {
const inputMode = imageInput.startsWith("data:image/") ? "inline" : "remote";
@ -488,8 +516,12 @@ async function summarizeWithHardcodedQwen3VlFallback(params: {
text?: string;
context?: string;
imageUrls?: string[];
visionImages?: Array<{ type: "image"; image: string }>;
}): Promise<string | null> {
const imageCandidates = await getQwen3VlFallbackImageCandidates(params.imageUrls);
const imageCandidates = await getQwen3VlFallbackImageCandidates({
imageUrls: params.imageUrls,
visionImages: params.visionImages,
});
if (imageCandidates.length === 0) return null;
const prompt = [
@ -933,6 +965,7 @@ export async function summarizeImageForExecutionReport(params: {
text,
context,
imageUrls,
visionImages,
});
if (forcedSummary) return forcedSummary;
}
@ -1027,6 +1060,7 @@ export async function summarizeImageForExecutionReport(params: {
text,
context,
imageUrls,
visionImages,
});
if (qwenFallbackSummary) return qwenFallbackSummary;
@ -1073,12 +1107,40 @@ type VisionSummaryModel = Awaited<
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
>[number];
function isVisionSummaryCandidateModel(modelWithProvider: VisionSummaryModel): boolean {
const providerType = String(modelWithProvider.provider?.type || "");
if (["fal-video", "volcano-video", "qianwen-video", "doubao-video", "ruxa-video"].includes(providerType)) {
return false;
function isAnalyzerGenerateTextProviderType(providerType: string): boolean {
return ["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
}
function getAnalyzerModelIncompatibleReason(params: {
modelWithProvider: VisionSummaryModel;
requireVision: boolean;
}): string | null {
const providerType = String(params.modelWithProvider.provider?.type || "");
const model = params.modelWithProvider.model;
if (!model?.isEnabled || model.isSubModel) {
return "disabled_or_submodel";
}
return isLikelyVisionCapableModel(modelWithProvider);
if (!isAnalyzerGenerateTextProviderType(providerType)) {
return `unsupported_provider:${providerType || "unknown"}`;
}
if (params.requireVision && !isLikelyVisionCapableModel(params.modelWithProvider)) {
return "vision_capability_missing";
}
return null;
}
function isAnalyzerGenerateTextCompatibleModel(params: {
modelWithProvider: VisionSummaryModel;
requireVision: boolean;
}): boolean {
return !getAnalyzerModelIncompatibleReason(params);
}
function isVisionSummaryCandidateModel(modelWithProvider: VisionSummaryModel): boolean {
return isAnalyzerGenerateTextCompatibleModel({
modelWithProvider,
requireVision: true,
});
}
function collectVisionSummaryCandidates(params: {
@ -1282,27 +1344,32 @@ async function analyzWithLLM(
)
: undefined;
const agentModel = preferredModel || configuredAgentModel;
const isReasoningCompatibleProvider = (providerType: string) =>
["zhipu", "alibaba", "openai", "custom", "local", "volcano-multimodal", "volcano-vision"].includes(providerType);
const analyzerTextCandidates = allModels.filter((item) =>
isAnalyzerGenerateTextCompatibleModel({
modelWithProvider: item,
requireVision: false,
}),
);
const analyzerVisionCandidates = analyzerTextCandidates.filter((item) =>
isLikelyVisionCapableModel(item),
);
const isLikelyZhipuVisionModel = (displayName: string, modelId: string, providerType: string) =>
providerType === "zhipu" ||
/(glm[-_.]?4v|glm[-_.]?4\.?1v|glm[-_.]?4\.?5v|vision)/i.test(
`${displayName} ${modelId}`,
);
const zhipuVisionModel = allModels.find(
const zhipuVisionModel = analyzerVisionCandidates.find(
(m) =>
isLikelyVisionCapableModel(m) &&
isLikelyZhipuVisionModel(m.model.displayName, m.model.modelId, m.provider.type),
);
const qwenVisionModel = allModels.find(
const qwenVisionModel = analyzerVisionCandidates.find(
(m) =>
m.provider.type === "alibaba" &&
isLikelyVisionCapableModel(m) &&
/(qwen|通义|vl)/i.test(`${m.model.displayName} ${m.model.modelId}`)
);
const fallbackVisionModel =
qwenVisionModel ||
allModels.find(
analyzerVisionCandidates.find(
(m) =>
m.provider.type === "alibaba" &&
isLikelyVisionCapableModel(m),
@ -1310,21 +1377,57 @@ async function analyzWithLLM(
const preferredVisionModel =
zhipuVisionModel ||
fallbackVisionModel ||
allModels.find((m) => isLikelyVisionCapableModel(m));
analyzerVisionCandidates[0];
const usableAgentModel =
agentModel && isReasoningCompatibleProvider(agentModel.provider.type)
agentModel &&
isAnalyzerGenerateTextCompatibleModel({
modelWithProvider: agentModel,
requireVision: false,
})
? agentModel
: undefined;
const multimodalAgentVisionModel =
agentModel &&
isLikelyVisionCapableModel(agentModel)
isAnalyzerGenerateTextCompatibleModel({
modelWithProvider: agentModel,
requireVision: true,
})
? agentModel
: undefined;
if (preferredModel) {
const preferredIncompatibleReason = getAnalyzerModelIncompatibleReason({
modelWithProvider: preferredModel,
requireVision: hasImage,
});
if (preferredIncompatibleReason) {
console.warn(
`[Smart Analyzer] Preferred analyzer model incompatible, skip: ${preferredModel.model.displayName} (${preferredModel.provider.type}), reason=${preferredIncompatibleReason}`,
);
}
}
if (agentModel && !usableAgentModel) {
const agentIncompatibleReason = getAnalyzerModelIncompatibleReason({
modelWithProvider: agentModel,
requireVision: false,
});
if (agentIncompatibleReason) {
console.warn(
`[Smart Analyzer] Agent analyzer model incompatible, skip: ${agentModel.model.displayName} (${agentModel.provider.type}), reason=${agentIncompatibleReason}`,
);
}
}
const selectedAnalyzerModel = hasImage
? multimodalAgentVisionModel || preferredVisionModel || usableAgentModel
? multimodalAgentVisionModel ||
preferredVisionModel ||
analyzerVisionCandidates[0] ||
usableAgentModel ||
analyzerTextCandidates[0]
: usableAgentModel ||
allModels.find((m) => m.provider.type === "zhipu" && !m.model.isSubModel) ||
preferredVisionModel;
analyzerTextCandidates.find((m) => m.provider.type === "zhipu" && !m.model.isSubModel) ||
analyzerTextCandidates[0];
if (preferredModel && selectedAnalyzerModel?.model.id === preferredModel.model.id) {
console.log(
@ -1332,12 +1435,33 @@ async function analyzWithLLM(
);
}
console.log(
`[Smart Analyzer] Analyzer candidate stats: text=${analyzerTextCandidates.length}, vision=${analyzerVisionCandidates.length}, selected=${selectedAnalyzerModel?.model.displayName || "none"} (${selectedAnalyzerModel?.provider?.type || "none"})`,
);
const preferRemoteVisionUrls = hasImage
? shouldPreferRemoteVisionUrls(selectedAnalyzerModel)
: false;
const visionImages = hasImage
? await buildAnalyzerVisionImagesByMode({
imageUrls,
preferRemoteUrls: preferRemoteVisionUrls,
})
: [];
if (visionImages.length > 0) {
const inlineCount = visionImages.filter((item) => item.image.startsWith("data:image/")).length;
console.log(
`[Smart Analyzer] Prepared analyzer images: total=${visionImages.length}, inline=${inlineCount}, remote=${visionImages.length - inlineCount}, preferRemote=${preferRemoteVisionUrls}`,
);
}
if (!selectedAnalyzerModel) {
console.log("[Smart Analyzer] No available analyzer model found");
return analyzeWithHardcodedQwen3VlFallback({
text,
hasImage,
imageUrls,
visionImages,
historyMessages,
context,
reason: "no_available_model",
@ -1352,6 +1476,7 @@ async function analyzWithLLM(
text,
hasImage,
imageUrls,
visionImages,
historyMessages,
context,
reason: "force_qwen3vl_multimodal_flag_only",
@ -1365,6 +1490,7 @@ async function analyzWithLLM(
text,
hasImage,
imageUrls,
visionImages,
historyMessages,
context,
reason: "model_runtime_unavailable",
@ -1398,20 +1524,6 @@ ${historyText}
? `${contextMessage}用户上传了图片,请求内容:"${text}"`
: `${contextMessage}用户请求(无图片):"${text}"`;
const preferRemoteVisionUrls = shouldPreferRemoteVisionUrls(selectedAnalyzerModel);
const visionImages = hasImage
? await buildAnalyzerVisionImagesByMode({
imageUrls,
preferRemoteUrls: preferRemoteVisionUrls,
})
: [];
if (visionImages.length > 0) {
const inlineCount = visionImages.filter((item) => item.image.startsWith("data:image/")).length;
console.log(
`[Smart Analyzer] Prepared analyzer images: total=${visionImages.length}, inline=${inlineCount}, remote=${visionImages.length - inlineCount}, preferRemote=${preferRemoteVisionUrls}`,
);
}
const zhipuProviderFromAgent =
usableAgentModel &&
usableAgentModel.provider &&
@ -1493,6 +1605,7 @@ ${historyText}
text,
hasImage,
imageUrls,
visionImages,
historyMessages,
context,
reason: "primary_non_json_response",
@ -1510,6 +1623,7 @@ ${historyText}
text,
hasImage,
imageUrls,
visionImages,
historyMessages,
context,
reason: "primary_analysis_failed",