diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index fcbfff74a..49ed6341f 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -121,7 +121,7 @@ type ToolConfig = { type AiProvider = { id: string; name: string; - type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu"; + type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal"; baseUrl: string; apiKey: string; isEnabled: boolean; @@ -154,6 +154,7 @@ type AiModel = { isDefault: boolean; sortOrder: number; isAgentModel?: boolean; + isMultimodal?: boolean; imageGenConfig?: ImageGenConfig; // 🆕 图片生成特性配置 }; @@ -365,6 +366,7 @@ const PROVIDER_TYPES = [ { value: "ruxa-image", label: "RUXA 生图模型" }, { value: "fal-image", label: "FAL 生图模型" }, { value: "zhipu", label: "智普官方 4.7" }, + { value: "volcano-multimodal", label: "火山引擎多模态" }, { value: "openai", label: "OpenAI 兼容" }, { value: "custom", label: "自定义 OpenAI 兼容" }, { value: "local", label: "本地模型" }, @@ -950,7 +952,7 @@ export function AdminSettingsClient() { const [providerForm, setProviderForm] = useState({ id: "", name: "", - type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu", + type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "ruxa-image" | "fal-image" | "fal-video" | "zhipu" | "volcano-multimodal", baseUrl: "", apiKey: "", isEnabled: true, @@ -971,6 +973,7 @@ export function AdminSettingsClient() { isEnabled: true, isDefault: false, isAgentModel: false, + isMultimodal: false, sortOrder: 0, fallbackModelId1: "", // 备用模型 ID 1 fallbackModelId2: "", // 备用模型 ID 2 @@ -1816,6 +1819,7 @@ export function AdminSettingsClient() { isEnabled: true, isDefault: false, isAgentModel: false, + isMultimodal: false, sortOrder: 0, fallbackModelId1: "", fallbackModelId2: "", @@ -2981,6 +2985,7 @@ export function AdminSettingsClient() { "fal-image": "bg-sky-500", "openai": "bg-green-500", "zhipu": "bg-lime-500", + "volcano-multimodal": "bg-fuchsia-500", "qianwen-video": "bg-cyan-500", "custom": "bg-gray-500", "local": "bg-yellow-500", @@ -5439,6 +5444,8 @@ export function AdminSettingsClient() { ? "https://fal.run" : !prev.baseUrl && value === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4" + : !prev.baseUrl && value === "volcano-multimodal" + ? "https://ark.cn-beijing.volces.com/api/v3" : prev.baseUrl, })) } @@ -5468,6 +5475,8 @@ export function AdminSettingsClient() { ? "https://fal.run" : providerForm.type === "zhipu" ? "https://open.bigmodel.cn/api/paas/v4" + : providerForm.type === "volcano-multimodal" + ? "https://ark.cn-beijing.volces.com/api/v3" : "https://api.openai.com/v1" } /> @@ -5792,6 +5801,13 @@ export function AdminSettingsClient() { /> +
+ setModelForm({ ...modelForm, isMultimodal: checked })} + /> + +
{/* 🆕 图片生成模型特性配置 */} {modelForm.supportsImageGen && ( @@ -5956,6 +5972,7 @@ export function AdminSettingsClient() { {model.displayName} {model.isDefault && 默认} {model.isAgentModel && Agent} + {model.isMultimodal && 多模态}
ID: {model.modelId} | 提供商: {provider?.name ?? "未知"} @@ -5976,6 +5993,7 @@ export function AdminSettingsClient() { isEnabled: model.isEnabled, isDefault: model.isDefault, isAgentModel: model.isAgentModel || false, + isMultimodal: model.isMultimodal || false, sortOrder: model.sortOrder || 0, fallbackModelId1: (model as any).fallbackModelId1 || "", fallbackModelId2: (model as any).fallbackModelId2 || "", diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 6e225697d..e58f89c93 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -10,7 +10,7 @@ import { getAiProviderById, } from "@/lib/db/queries"; import { ChatSDKError } from "@/lib/errors"; -import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags"; +import { isAiModelAgentAuto, isAiModelMultimodal } from "@/lib/db/ai-model-flags"; // 🆕 图片生成模型特性配置 Schema const imageGenConfigSchema = z.object({ @@ -39,6 +39,7 @@ const createModelSchema = z.object({ fallbackModelId1: z.string().max(128).optional(), fallbackModelId2: z.string().max(128).optional(), isAgentModel: z.boolean().optional(), + isMultimodal: z.boolean().optional(), imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置 }); @@ -57,6 +58,7 @@ const updateModelSchema = z.object({ fallbackModelId1: z.string().max(128).optional(), fallbackModelId2: z.string().max(128).optional(), isAgentModel: z.boolean().optional(), + isMultimodal: z.boolean().optional(), imageGenConfig: imageGenConfigSchema, // 🆕 图片生成特性配置 }); @@ -64,6 +66,7 @@ function normalizeModel(model: any) { return { ...model, isAgentModel: isAiModelAgentAuto(model), + isMultimodal: isAiModelMultimodal(model), }; } diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts index 5fe78ff67..2d0759d4c 100644 --- a/app/api/admin/ai-providers/route.ts +++ b/app/api/admin/ai-providers/route.ts @@ -24,9 +24,10 @@ const providerTypes = [ "fal-image", "fal-video", "zhipu", + "volcano-multimodal", ] as const; type ProviderType = (typeof providerTypes)[number]; -type StoredProviderType = Exclude; +type StoredProviderType = Exclude; const createProviderSchema = z.object({ name: z.string().min(1).max(128), @@ -46,22 +47,35 @@ const updateProviderSchema = z.object({ }); const ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; +const VOLCANO_MULTIMODAL_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"; function normalizeProviderTypeForStorage( type?: ProviderType ): StoredProviderType | undefined { if (!type) return undefined; - return type === "zhipu" ? "custom" : type; + if (type === "zhipu" || type === "volcano-multimodal") return "custom"; + return type; } function isZhipuBaseUrl(baseUrl: string) { return baseUrl.includes("open.bigmodel.cn"); } +function isVolcanoMultimodalBaseUrl(baseUrl: string) { + return /ark\.cn-beijing\.volces\.com/i.test(baseUrl); +} + function normalizeProviderTypeForResponse(provider: Record) { if (provider.type === "custom" && typeof provider.baseUrl === "string" && isZhipuBaseUrl(provider.baseUrl)) { return { ...provider, type: "zhipu" }; } + if ( + provider.type === "custom" && + typeof provider.baseUrl === "string" && + isVolcanoMultimodalBaseUrl(provider.baseUrl) + ) { + return { ...provider, type: "volcano-multimodal" }; + } return provider; } @@ -133,7 +147,12 @@ export async function POST(request: Request) { const provider = await createAiProvider({ ...data, type: storageType, - baseUrl: data.type === "zhipu" ? data.baseUrl || ZHIPU_BASE_URL : data.baseUrl, + baseUrl: + data.type === "zhipu" + ? data.baseUrl || ZHIPU_BASE_URL + : data.type === "volcano-multimodal" + ? data.baseUrl || VOLCANO_MULTIMODAL_BASE_URL + : data.baseUrl, updatedBy: session.user.id, }); diff --git a/lib/ai/auto-agent-graph.ts b/lib/ai/auto-agent-graph.ts index f5db25a86..2fae37353 100644 --- a/lib/ai/auto-agent-graph.ts +++ b/lib/ai/auto-agent-graph.ts @@ -2,6 +2,7 @@ import { Annotation, StateGraph, START, END } from "@langchain/langgraph"; import { generateText } from "ai"; 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"; @@ -410,6 +411,15 @@ type EnabledModelWithProvider = Awaited< function pickQwenVisionModel(models: EnabledModelWithProvider[]) { const visionModels = models.filter((item) => item.model.supportsVision && item.model.isEnabled); + 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" && @@ -422,6 +432,22 @@ function pickQwenVisionModel(models: EnabledModelWithProvider[]) { return visionModels[0]; } +function pickAgentVisionModel( + agentModel: EnabledModelWithProvider | undefined, + models: EnabledModelWithProvider[], +) { + if ( + agentModel && + agentModel.model.isEnabled && + agentModel.model.supportsVision && + isAiModelMultimodal(agentModel.model) + ) { + return agentModel; + } + + return pickQwenVisionModel(models); +} + function pickAgentReasoningModel( agentModel: EnabledModelWithProvider | undefined, models: EnabledModelWithProvider[], @@ -429,6 +455,14 @@ function pickAgentReasoningModel( 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) => @@ -493,7 +527,7 @@ async function generateAgentConversation( let visualAnalysis = ""; if (state.hasImage && validImageUrls.length > 0) { try { - const visionModelConfig = pickQwenVisionModel(allModels); + const visionModelConfig = pickAgentVisionModel(agentModel, allModels); if (!visionModelConfig) { throw new Error("missing_vision_model"); } diff --git a/lib/ai/dynamic-provider.ts b/lib/ai/dynamic-provider.ts index e59f3758f..b31015876 100644 --- a/lib/ai/dynamic-provider.ts +++ b/lib/ai/dynamic-provider.ts @@ -21,10 +21,16 @@ import { createAlibabaImage } from "./providers/alibaba-image"; import { createRuxaImage } from "./providers/ruxa-image"; import { createFalImage } from "./providers/fal-image"; import { createFalVideo } from "./providers/fal-video"; +import { createVolcanoMultimodal } from "./providers/volcano-multimodal"; // 缓存已创建的 provider 实例 const providerCache = new Map>(); +function isVolcanoMultimodalBaseUrl(baseUrl?: string | null) { + if (!baseUrl) return false; + return /ark\.cn-beijing\.volces\.com/i.test(baseUrl); +} + /** * 根据提供商类型创建对应的 AI Provider 实例 * 不同供应商使用OpenAI兼容的API格式,但可能有细微差异 @@ -178,6 +184,16 @@ function getOrCreateProvider(provider: Pick { + if (typeof value !== "string") return; + const url = value.trim(); + if (!url) return; + imageUrls.push(url); + }; + + const readPart = (part: any) => { + if (!part || typeof part !== "object") return; + if (part.type === "text" || part.type === "input_text") { + if (typeof part.text === "string" && part.text.trim()) textParts.push(part.text.trim()); + return; + } + if (part.type === "image" || part.type === "input_image") { + pushImageUrl(part.image ?? part.image_url); + return; + } + if (part.type === "image_url") { + if (typeof part.image_url === "string") { + pushImageUrl(part.image_url); + } else { + pushImageUrl(part.image_url?.url); + } + return; + } + if (part.type === "file") { + pushImageUrl(part.url ?? part.data); + } + }; + + if (typeof content === "string") { + if (content.trim()) textParts.push(content.trim()); + return { text: textParts.join("\n"), imageUrls }; + } + + if (Array.isArray(content)) { + for (const part of content) readPart(part); + return { text: textParts.join("\n"), imageUrls }; + } + + if (content && typeof content === "object") { + readPart(content); + } + + return { text: textParts.join("\n"), imageUrls }; +} + +function extractPromptAndImages(options: any) { + if (typeof options?.prompt === "string") { + return { text: options.prompt.trim(), imageUrls: [] as string[] }; + } + + const messages = Array.isArray(options?.messages) ? options.messages : []; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i]; + if (message?.role !== "user") continue; + const parsed = parseMessageParts(message.content); + if (parsed.text || parsed.imageUrls.length > 0) { + return parsed; + } + } + + const lastMessage = messages[messages.length - 1]; + if (lastMessage) { + return parseMessageParts(lastMessage.content); + } + + return { text: "", imageUrls: [] as string[] }; +} + +function extractOutputText(payload: any): string { + if (typeof payload?.output_text === "string" && payload.output_text.trim()) { + return payload.output_text.trim(); + } + + const chunks: string[] = []; + const output = Array.isArray(payload?.output) ? payload.output : []; + for (const item of output) { + const content = Array.isArray(item?.content) ? item.content : []; + for (const part of content) { + if (typeof part?.text === "string" && part.text.trim()) { + chunks.push(part.text.trim()); + } else if (typeof part?.output_text === "string" && part.output_text.trim()) { + chunks.push(part.output_text.trim()); + } + } + } + + return chunks.join("\n").trim(); +} + +async function callResponsesApi(params: { + endpoint: string; + apiKey: string; + modelId: string; + text: string; + imageUrls: string[]; + timeout: number; +}) { + const content = [ + ...params.imageUrls.map((url) => ({ type: "input_image", image_url: url })), + { type: "input_text", text: params.text || "请根据图片内容进行分析。" }, + ]; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeout); + + try { + const response = await fetch(params.endpoint, { + method: "POST", + signal: controller.signal, + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: params.modelId, + input: [ + { + role: "user", + content, + }, + ], + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.error) { + const message = + payload?.error?.message || payload?.message || `HTTP ${response.status}`; + throw new Error(message); + } + + const text = extractOutputText(payload); + if (!text) { + throw new Error("火山多模态未返回文本结果"); + } + + const usage = payload?.usage || {}; + return { + text, + usage: { + promptTokens: + Number(usage?.input_tokens) || + Number(usage?.prompt_tokens) || + 0, + completionTokens: + Number(usage?.output_tokens) || + Number(usage?.completion_tokens) || + 0, + }, + }; + } finally { + clearTimeout(timeoutId); + } +} + +export function createVolcanoMultimodal(config: VolcanoMultimodalConfig) { + const endpoint = normalizeResponsesEndpoint(config.baseURL); + const timeout = config.timeout ?? 60000; + + return { + provider: "volcano-multimodal", + chat: (modelId: string) => ({ + provider: "volcano-multimodal", + modelId, + specificationVersion: "v1" as const, + defaultObjectGenerationMode: "json" as const, + doGenerate: async (options: any) => { + const { text, imageUrls } = extractPromptAndImages(options); + const result = await callResponsesApi({ + endpoint, + apiKey: config.apiKey, + modelId, + text, + imageUrls, + timeout, + }); + return { + text: result.text, + finishReason: "stop" as const, + usage: result.usage, + }; + }, + doStream: async function* (options: any) { + const result = await this.doGenerate(options); + yield { + type: "text-delta" as const, + textDelta: result.text, + }; + yield { + type: "finish" as const, + finishReason: "stop" as const, + usage: result.usage, + }; + }, + }), + }; +} diff --git a/lib/ai/smart-analyzer.ts b/lib/ai/smart-analyzer.ts index 87f413e9f..0698cd557 100644 --- a/lib/ai/smart-analyzer.ts +++ b/lib/ai/smart-analyzer.ts @@ -7,6 +7,7 @@ import { generateText } from "ai"; import sharp from "sharp"; +import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; // 使用动态导入避免循环依赖问题 // import { getLanguageModelById } from "./dynamic-provider"; // import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; @@ -642,7 +643,9 @@ async function analyzWithLLM( } = await import("@/lib/db/queries"); const { getLanguageModelById } = await import("./dynamic-provider"); - // 图片识别固定走千问多模态;纯文本理解优先走 Agent 模型(建议智普) + // 优先规则: + // 1) 若 Agent 模型被标记为「多模态」,且当前请求包含图片,则识图+分析统一走该 Agent 模型 + // 2) 否则按既有视觉模型优先级选择 const allModels = await getEnabledAiModelsWithProvider(); const agentModel = await getAgentAiModelWithProvider(); const isReasoningCompatibleProvider = (providerType: string) => @@ -681,8 +684,16 @@ async function analyzWithLLM( agentModel && isReasoningCompatibleProvider(agentModel.provider.type) ? agentModel : undefined; + const multimodalAgentVisionModel = + agentModel && + agentModel.model.isEnabled && + !agentModel.model.isSubModel && + agentModel.model.supportsVision && + isAiModelMultimodal(agentModel.model) + ? agentModel + : undefined; const selectedAnalyzerModel = hasImage - ? preferredVisionModel + ? multimodalAgentVisionModel || preferredVisionModel : usableAgentModel || allModels.find((m) => m.provider.type === "zhipu" && !m.model.isSubModel) || preferredVisionModel; @@ -746,8 +757,15 @@ ${historyText} (m.provider.type === "zhipu" || isLikelyZhipuEndpoint(m.provider.baseUrl)), )?.provider; const zhipuProvider = zhipuProviderFromAgent || zhipuProviderFallback; + const forceSingleMultimodalAgent = Boolean(hasImage && multimodalAgentVisionModel); - if (hasImage && visionImages.length > 0 && zhipuProvider?.apiKey && zhipuProvider?.baseUrl) { + if ( + !forceSingleMultimodalAgent && + hasImage && + visionImages.length > 0 && + zhipuProvider?.apiKey && + zhipuProvider?.baseUrl + ) { const zhipuOfficialResult = await analyzeWithZhipuOfficialApi({ apiKey: zhipuProvider.apiKey, baseUrl: zhipuProvider.baseUrl, diff --git a/lib/db/ai-model-flags.ts b/lib/db/ai-model-flags.ts index 1d9b3e0d2..bcdb4fea9 100644 --- a/lib/db/ai-model-flags.ts +++ b/lib/db/ai-model-flags.ts @@ -1,4 +1,5 @@ const AGENT_AUTO_MODEL_KEY = "agentAutoModel"; +const MULTIMODAL_MODEL_KEY = "multimodalModel"; function toRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -60,3 +61,35 @@ export function withAgentAutoModelFlag( return Object.keys(next).length > 0 ? next : null; } + +export function isAiModelMultimodal(input: { + capabilities?: unknown; + defaultParams?: unknown; +}): boolean { + const defaultParams = toRecord(input.defaultParams); + if (typeof defaultParams?.[MULTIMODAL_MODEL_KEY] === "boolean") { + return defaultParams[MULTIMODAL_MODEL_KEY] as boolean; + } + + const capabilities = toRecord(input.capabilities); + if (typeof capabilities?.[MULTIMODAL_MODEL_KEY] === "boolean") { + return capabilities[MULTIMODAL_MODEL_KEY] as boolean; + } + + return false; +} + +export function withMultimodalModelFlag( + capabilities: unknown, + enabled: boolean +): Record | null { + const next = { ...(toRecord(capabilities) || {}) }; + + if (enabled) { + next[MULTIMODAL_MODEL_KEY] = true; + } else { + delete next[MULTIMODAL_MODEL_KEY]; + } + + return Object.keys(next).length > 0 ? next : null; +} diff --git a/lib/db/queries.ts b/lib/db/queries.ts index f6c2d575f..d0b25d518 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -55,6 +55,7 @@ import { generateHashedPassword } from "./utils"; import { isAiModelAgentAuto, withAgentAutoModelFlag, + withMultimodalModelFlag, } from "./ai-model-flags"; import { defaultPointsSystemConfig, @@ -1276,6 +1277,7 @@ export async function createAiModel({ fallbackModelId2, imageGenConfig, isAgentModel, + isMultimodal, }: { providerId: string; modelId: string; @@ -1291,6 +1293,7 @@ export async function createAiModel({ fallbackModelId2?: string; imageGenConfig?: ImageGenConfig; isAgentModel?: boolean; + isMultimodal?: boolean; }): Promise { try { // 如果设置为默认,先取消其他默认 @@ -1321,6 +1324,10 @@ export async function createAiModel({ isAgentModel === undefined ? null : withAgentAutoModelFlag(null, isAgentModel), + capabilities: + isMultimodal === undefined + ? null + : withMultimodalModelFlag(null, isMultimodal), }) .returning(); } catch (_error) { @@ -1347,6 +1354,7 @@ export async function updateAiModel({ fallbackModelId2, imageGenConfig, isAgentModel, + isMultimodal, }: { id: string; providerId?: string; @@ -1363,6 +1371,7 @@ export async function updateAiModel({ fallbackModelId2?: string; imageGenConfig?: ImageGenConfig; isAgentModel?: boolean; + isMultimodal?: boolean; }): Promise { try { // 如果设置为默认,先取消其他默认 @@ -1390,9 +1399,12 @@ export async function updateAiModel({ if (fallbackModelId2 !== undefined) updateData.fallbackModelId2 = fallbackModelId2 || null; // 🆕 图片生成配置 if (imageGenConfig !== undefined) updateData.imageGenConfig = imageGenConfig || null; - if (isAgentModel !== undefined) { + if (isAgentModel !== undefined || isMultimodal !== undefined) { const [existingModel] = await db - .select({ defaultParams: aiModel.defaultParams }) + .select({ + defaultParams: aiModel.defaultParams, + capabilities: aiModel.capabilities, + }) .from(aiModel) .where(eq(aiModel.id, id)) .limit(1); @@ -1401,10 +1413,19 @@ export async function updateAiModel({ return []; } - updateData.defaultParams = withAgentAutoModelFlag( - existingModel.defaultParams, - isAgentModel - ); + if (isAgentModel !== undefined) { + updateData.defaultParams = withAgentAutoModelFlag( + existingModel.defaultParams, + isAgentModel + ); + } + + if (isMultimodal !== undefined) { + updateData.capabilities = withMultimodalModelFlag( + existingModel.capabilities, + isMultimodal + ); + } } return await db