From 2108d067150f9f5672d88b358b5103da664b809b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 20:10:48 +0800 Subject: [PATCH] feat: support cross-provider image failover and workflow model settings --- .../settings/admin-settings-client.tsx | 354 +++++++++++++++- .../agent-workflow-settings-client.tsx | 169 +++++++- app/(chat)/api/chat/route.ts | 392 +++++++++++++----- app/(chat)/api/models/route.ts | 24 ++ app/api/admin/fal-image-models/route.ts | 130 ++++++ app/api/admin/settings/route.ts | 16 + app/api/site-settings/route.ts | 4 + .../agents/AgentGenerationWorkspaceDarkV2.tsx | 34 +- lib/agents/workflow-ui-config.ts | 7 + lib/ai/fal-image-models.ts | 78 ++++ lib/ai/providers/fal-image.ts | 167 +++++++- lib/canvas/image-task-config.ts | 55 +++ lib/canvas/image-task-queue.ts | 52 ++- .../0075_add_agent_workflow_default_model.sql | 2 + lib/db/migrations/meta/_journal.json | 7 + lib/db/queries.ts | 4 + lib/db/schema.ts | 1 + types/agent.ts | 1 + 18 files changed, 1340 insertions(+), 157 deletions(-) create mode 100644 app/api/admin/fal-image-models/route.ts create mode 100644 lib/ai/fal-image-models.ts create mode 100644 lib/canvas/image-task-config.ts create mode 100644 lib/db/migrations/0075_add_agent_workflow_default_model.sql diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 928d6a5c6..375b1214e 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -76,6 +76,13 @@ import { THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, type ThreeDModelAliasEntry, } from "@/lib/model3d/model-alias-config"; +import { + CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, + DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY, + MAX_CANVAS_IMAGE_TASK_CONCURRENCY, + normalizeCanvasImageTaskQueueConfig, + type CanvasImageTaskQueueConfig, +} from "@/lib/canvas/image-task-config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; @@ -228,6 +235,7 @@ import { BLTCY_VIDEO_MODEL_PRESETS, BLTCY_VIDEO_PROVIDER_PRESET, } from "@/lib/ai/bltcy-video-models"; +import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models"; import { CANVAS_SKILL_ICON_OPTIONS, getCanvasSkillIconComponent, @@ -763,6 +771,8 @@ type AiModel = { isEnabled: boolean; isHidden: boolean; isDefault: boolean; + isSubModel?: boolean; + parentModelId?: string | null; sortOrder: number; fallbackModelId1?: string; fallbackModelId2?: string; @@ -979,6 +989,7 @@ type SiteSettings = { aiDetailGenerationEnabled: boolean; canvasDefaultModelId: string | null; canvasAutoModeDefaultModelId: string | null; + agentWorkflowDefaultModelId: string | null; canvasDetailDefaultModelId: string | null; canvasDetailAgentModelId: string | null; lovartComposerPrefix: string; @@ -1506,6 +1517,13 @@ export function AdminSettingsClient() { `/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`, fetcher ); + const { + data: canvasImageTaskQueueConfigData, + mutate: mutateCanvasImageTaskQueueConfig, + } = useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY}`, + fetcher + ); const { data: homeAnimationSettingData, mutate: mutateHomeAnimationSetting } = useSWR<{ setting?: { value?: unknown } }>( `/api/admin/settings?key=${HOME_CHAT_ANIMATION_CONFIG_KEY}`, @@ -1546,6 +1564,20 @@ export function AdminSettingsClient() { ), [modelsData?.models] ); + const imageGenerationModelOptions = useMemo( + () => + (modelsData?.models || []).filter( + (model) => model.supportsImageGen && model.isEnabled && !model.isHidden && !model.isSubModel + ), + [modelsData?.models] + ); + const modelProviderNameById = useMemo(() => { + const map = new Map(); + (providersData?.providers || []).forEach((provider) => { + map.set(provider.id, provider.name); + }); + return map; + }, [providersData?.providers]); // 网站设置状态 const [siteSettings, setSiteSettings] = useState({ @@ -1594,6 +1626,7 @@ export function AdminSettingsClient() { aiDetailGenerationEnabled: false, canvasDefaultModelId: null, canvasAutoModeDefaultModelId: null, + agentWorkflowDefaultModelId: null, canvasDetailDefaultModelId: null, canvasDetailAgentModelId: null, lovartComposerPrefix: "让Buyi-ai", @@ -1750,6 +1783,8 @@ export function AdminSettingsClient() { canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null, canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null, + agentWorkflowDefaultModelId: + (siteSettingsData as any).agentWorkflowDefaultModelId || null, canvasDetailDefaultModelId: siteSettingsData.canvasDetailDefaultModelId || null, canvasDetailAgentModelId: @@ -2043,6 +2078,12 @@ export function AdminSettingsClient() { maxScenes: 10, modelId: "", // 首选生图模型ID(多样性模式下优先尝试) }); + const [canvasImageTaskQueueConfig, setCanvasImageTaskQueueConfig] = + useState(() => + normalizeCanvasImageTaskQueueConfig({ + concurrency: DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY, + }) + ); const [tripo3dConfig, setTripo3dConfig] = useState({ providerId: "", @@ -2543,6 +2584,15 @@ export function AdminSettingsClient() { "/api/admin/settings?key=scene_fusion_config", fetcher ); + useEffect(() => { + if (!canvasImageTaskQueueConfigData) return; + setCanvasImageTaskQueueConfig( + normalizeCanvasImageTaskQueueConfig( + canvasImageTaskQueueConfigData.setting?.value + ) + ); + }, [canvasImageTaskQueueConfigData]); + useEffect(() => { if (sceneFusionConfigData?.setting?.value) { const config = sceneFusionConfigData.setting.value as { @@ -3275,6 +3325,30 @@ export function AdminSettingsClient() { const selectedModelProviderType = providersData?.providers?.find((p) => p.id === modelForm.providerId) ?.type || ""; + const failoverModelOptions = useMemo(() => { + const currentModelId = String(editingModel || modelForm.id || "").trim(); + const currentRuntimeModelId = String(modelForm.modelId || "").trim(); + const wantsImageFallback = modelForm.supportsImageGen; + + return (modelsData?.models || []) + .filter((model) => { + if (!model.isEnabled || model.isSubModel || model.excludeFromFailover) return false; + if (model.id === currentModelId || model.modelId === currentRuntimeModelId) return false; + if (wantsImageFallback && !model.supportsImageGen) return false; + return true; + }) + .map((model) => ({ + ...model, + providerName: modelProviderNameById.get(model.providerId) || "未知服务商", + })); + }, [ + editingModel, + modelForm.id, + modelForm.modelId, + modelForm.supportsImageGen, + modelProviderNameById, + modelsData?.models, + ]); const isVideoProviderForModelConfig = isVideoModelDescriptor({ providerType: selectedModelProviderType, modelId: modelForm.modelId, @@ -4997,6 +5071,35 @@ export function AdminSettingsClient() { } }; + const handleSaveCanvasImageTaskQueueConfig = async () => { + try { + const normalized = normalizeCanvasImageTaskQueueConfig( + canvasImageTaskQueueConfig + ); + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, + value: normalized, + }), + }); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(payload?.message || payload?.error || "保存失败"); + } + setCanvasImageTaskQueueConfig( + normalizeCanvasImageTaskQueueConfig(payload?.setting?.value || normalized) + ); + await mutateCanvasImageTaskQueueConfig(); + toast.success("生图任务队列并发配置保存成功"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "保存生图任务队列并发配置失败" + ); + } + }; + // --- 批量处理配置逻辑 --- const handleSaveSceneFusionConfig = async () => { try { @@ -5165,6 +5268,7 @@ export function AdminSettingsClient() { const isNewProvider = !editingProvider; const isVolcanoVideo = providerForm.type === "volcano-video"; const isKreaProvider = providerForm.type === "krea"; + const isFalImageProvider = providerForm.type === "fal-image"; const isBltcyVideoProvider = providerForm.type === "bltcy-video"; const isLemonProvider = providerForm.type === "openai" && @@ -5270,6 +5374,17 @@ export function AdminSettingsClient() { } } + if (isNewProvider && isFalImageProvider && providerId) { + try { + await handleQuickAddFalImageModels(providerId, { skipConfirm: true }); + } catch (modelError) { + console.error("[Auto Add FAL Image Models] Error:", modelError); + toast.warning( + 'FAL 图片预设模型添加失败,请手动点击"快速添加 FAL 图片预设"按钮' + ); + } + } + if (isNewProvider && isBltcyVideoProvider && providerId) { try { await handleQuickAddBltcyVideoModels(providerId, { skipConfirm: true }); @@ -6063,6 +6178,50 @@ export function AdminSettingsClient() { } }; + const handleQuickAddFalImageModels = async ( + providerId: string, + options?: { skipConfirm?: boolean } + ) => { + if ( + !options?.skipConfirm && + !confirm( + `将为此服务商添加 ${FAL_IMAGE_MODEL_PRESETS.length} 个 FAL 图片编辑模型预设(包含 GPT Image 2 Edit)。\n\n继续吗?` + ) + ) { + return; + } + + try { + toast.info("正在添加 FAL 图片预设模型..."); + + const response = await fetch("/api/admin/fal-image-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerId }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "添加失败"); + } + + const { addedCount, errors } = data; + + if (addedCount > 0) { + toast.success(`成功添加 ${addedCount} 个 FAL 图片预设模型`); + mutateModels(); + } + + if (errors && errors.length > 0) { + toast.warning(`${errors.length} 个模型添加失败(可能已存在)`); + } + } catch (error: any) { + console.error("[Quick Add FAL Image Models] Error:", error); + toast.error(error.message || "添加 FAL 图片预设模型失败"); + } + }; + const handleUseBltcyVideoProviderPreset = () => { setProviderForm({ id: "", @@ -11511,6 +11670,42 @@ export function AdminSettingsClient() { 这里的配置只影响 AI详情生成专用模型,以及 Agent模式自动出图时的默认生图模型。 +
+ + +
+ 仅影响风格化功能页默认出图模型;固定业务模式卡片里单独配置的默认模型优先级更高。 +
+
+
- {/* 🆕 备用模型 ID 配置 */} + {/* 🆕 备用模型配置 */}
- (主模型请求失败时自动切换) + (主模型请求失败时自动切换,支持跨服务商)
- +
- +
+

+ 生图模型会优先按这里选择的备用模型迁移;若仍失败,系统再走自动故障迁移候选池。 +

@@ -19599,6 +19854,73 @@ export function AdminSettingsClient() { + {/* 生图任务队列并发 */} + + +
+ + + 生图任务队列并发 + + + 控制风格化/画布异步生图队列允许同时运行的任务数。配置保存到数据库,运行队列会自动读取,无需重启服务。 + +
+ toggleLocalAiSection("canvas-image-task-queue")} + /> +
+ +
+
+
生效范围
+
+

• 影响通过异步队列提交的风格化/画布生图任务。

+

• 多用户共享这个全局并发池,不按用户单独拆分。

+

• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key 池继续细分。

+
+
+ +
+ + + setCanvasImageTaskQueueConfig( + normalizeCanvasImageTaskQueueConfig({ + concurrency: e.target.value, + }) + ) + } + /> +
+ 可配置范围 {DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}- + {MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "} + {canvasImageTaskQueueConfig.concurrency} 并发。 +
+
+
+ +
+ +
+
+
+ {/* 批量处理配置 */} diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 2e198b6ff..ac477d079 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -113,6 +113,13 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { @@ -173,6 +180,7 @@ const WORKFLOW_SETTINGS_DRAFT_STORAGE_KEY = "agent-workflow-settings-draft:v1"; const WORKFLOW_SETTINGS_STALE_DRAFT_STORAGE_KEY = "agent-workflow-settings-stale-draft:v1"; const WORKFLOW_SETTINGS_DRAFT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const DELETE_WORKFLOW_STEP_PASSWORD = "123456"; +const BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE = "__agent_workflow_mode_default_model_empty__"; type WorkflowStepDropPosition = "before" | "after" | "inside"; @@ -182,6 +190,7 @@ interface WorkflowStepDropTarget { } interface AgentWorkflowSettingsDraftState { + agentWorkflowDefaultModelId: string | null; presetLibraryConfig: AgentPresetLibraryConfig; config: AgentWorkflowUiConfig; materialApiConfig: AgentWorkflowMaterialApiConfig; @@ -489,6 +498,9 @@ function createAgentWorkflowSettingsStateSignature(state: AgentWorkflowSettingsD function createAgentWorkflowSettingsSectionSignatures(state: AgentWorkflowSettingsDraftState) { return { + agentWorkflowDefaultModelId: createAgentWorkflowValueSignature( + state.agentWorkflowDefaultModelId + ), agentPresetLibraryConfig: createAgentWorkflowValueSignature(state.presetLibraryConfig), agentWorkflowUiConfig: createAgentWorkflowValueSignature({ ...state.config, @@ -1757,6 +1769,7 @@ interface AgentWorkflowSettingsClientProps { } interface AgentWorkflowSettingsPayload { + agentWorkflowDefaultModelId?: string | null; agentPresetLibraryConfig?: AgentPresetLibraryConfig; agentWorkflowUiConfig?: AgentWorkflowUiConfig; agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig; @@ -1767,10 +1780,21 @@ interface AgentWorkflowSettingsPayload { agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig; } +interface AdminAiModelOption { + id: string; + modelId?: string | null; + displayName: string; + supportsImageGen?: boolean; + isEnabled?: boolean; + isHidden?: boolean; +} + function createAgentWorkflowSettingsStateFromPayload( payload?: AgentWorkflowSettingsPayload | null ): AgentWorkflowSettingsDraftState { return { + agentWorkflowDefaultModelId: + String(payload?.agentWorkflowDefaultModelId || "").trim() || null, presetLibraryConfig: normalizeAgentPresetLibraryConfig(payload?.agentPresetLibraryConfig), config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(payload?.agentWorkflowUiConfig)), materialApiConfig: normalizeAgentWorkflowMaterialApiConfig( @@ -1793,6 +1817,7 @@ function createAgentWorkflowSettingsStateFromPayload( type AgentWorkflowSettingsField = keyof AgentWorkflowSettingsPayload; const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record = { + agentWorkflowDefaultModelId: "风格化专用默认模型", agentPresetLibraryConfig: "预设库", agentWorkflowUiConfig: "工作流步骤", agentWorkflowMaterialApiConfig: "素材搜索接口", @@ -1805,6 +1830,10 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record( + "/api/admin/ai-models", + fetcher, + { revalidateOnFocus: false } + ); + const [agentWorkflowDefaultModelId, setAgentWorkflowDefaultModelId] = useState(null); const [presetLibraryConfig, setPresetLibraryConfig] = useState( defaultAgentPresetLibraryConfig ); @@ -1939,6 +1974,8 @@ export function AgentWorkflowSettingsClient({ if (draft.version === 2 && draft.baseSignature === serverSignature) { restoredDraft = draft; nextState = { + agentWorkflowDefaultModelId: + String(draft.state.agentWorkflowDefaultModelId || "").trim() || null, presetLibraryConfig: normalizeAgentPresetLibraryConfig(draft.state.presetLibraryConfig), config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(draft.state.config)), materialApiConfig: normalizeAgentWorkflowMaterialApiConfig( @@ -1966,6 +2003,7 @@ export function AgentWorkflowSettingsClient({ } } + setAgentWorkflowDefaultModelId(nextState.agentWorkflowDefaultModelId); setPresetLibraryConfig(nextState.presetLibraryConfig); setConfig(nextState.config); setMaterialApiConfig(nextState.materialApiConfig); @@ -2054,6 +2092,30 @@ export function AgentWorkflowSettingsClient({ () => getAgentWorkflowBusinessModeSettings(config), [config] ); + const workflowImageModelOptions = useMemo( + () => + (aiModelsData?.models || []) + .filter( + (model) => + model.supportsImageGen && + model.isEnabled !== false && + model.isHidden !== true + ) + .sort((left, right) => + String(left.displayName || left.modelId || left.id).localeCompare( + String(right.displayName || right.modelId || right.id), + "zh-CN" + ) + ), + [aiModelsData?.models] + ); + const workflowImageModelLabelById = useMemo(() => { + const entries: Array<[string, string]> = workflowImageModelOptions.map((model) => [ + model.id, + `${model.displayName}${model.modelId ? `(${model.modelId})` : ""}`, + ]); + return new Map(entries); + }, [workflowImageModelOptions]); const firstCategoryLabel = categoryLabels.firstCategory; const secondCategoryLabel = categoryLabels.secondCategory; const thirdCategoryLabel = categoryLabels.thirdCategory; @@ -2104,6 +2166,7 @@ export function AgentWorkflowSettingsClient({ const autoDraftState = useMemo( () => ({ + agentWorkflowDefaultModelId, presetLibraryConfig, config, materialApiConfig, @@ -2114,6 +2177,7 @@ export function AgentWorkflowSettingsClient({ psdTemplateSecondaryCategoryConfig, }), [ + agentWorkflowDefaultModelId, presetLibraryConfig, config, materialApiConfig, @@ -3676,8 +3740,10 @@ export function AgentWorkflowSettingsClient({ return; } - const nextState: AgentWorkflowSettingsDraftState = { - presetLibraryConfig: normalizeAgentPresetLibraryConfig(draft.state.presetLibraryConfig), + const nextState: AgentWorkflowSettingsDraftState = { + agentWorkflowDefaultModelId: + String(draft.state.agentWorkflowDefaultModelId || "").trim() || null, + presetLibraryConfig: normalizeAgentPresetLibraryConfig(draft.state.presetLibraryConfig), config: normalizeConfigOrdering(normalizeAgentWorkflowUiConfig(draft.state.config)), materialApiConfig: normalizeAgentWorkflowMaterialApiConfig( draft.state.materialApiConfig @@ -3693,6 +3759,7 @@ export function AgentWorkflowSettingsClient({ brandModelConfig: normalizeAgentWorkflowBrandModelConfig(draft.state.brandModelConfig), }; + setAgentWorkflowDefaultModelId(nextState.agentWorkflowDefaultModelId); setPresetLibraryConfig(nextState.presetLibraryConfig); setConfig(nextState.config); setMaterialApiConfig(nextState.materialApiConfig); @@ -6041,8 +6108,54 @@ export function AgentWorkflowSettingsClient({
- - {workflowBusinessModeSettings.modes.map((mode) => { + +
+
+
+
风格化专用默认生图模型
+

+ 当前台业务模式没有单独指定模型时,会优先使用这里的风格化专用模型;未设置时再沿用前台通用默认生图模型。 +

+
+ +
+
+ +
+ {workflowBusinessModeSettings.modes.map((mode) => { const refKey = `business-mode:${mode.key}`; const isUploading = uploadingKey === refKey; const Icon = @@ -6112,6 +6225,49 @@ export function AgentWorkflowSettingsClient({ />
+
+ + +

+ 用户选择该业务模式后,会优先使用这里配置的模型;未配置时沿用上方风格化专用默认模型。 +

+
+ {mode.key === "detail" ? (
@@ -6192,8 +6348,9 @@ export function AgentWorkflowSettingsClient({
); - })} -
+ })} + +
diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index ac7c4cb0d..60e751378 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -900,15 +900,19 @@ async function resolveStoryboardModelCandidates( const candidateIds = Array.from( new Set([selectedModel.id, ...configuredFallbackIds, ...sameProvider]) ); - const byId = new Map( - allModels - .map(({ model }: any) => model) - .filter(Boolean) - .map((model: any) => [String(model.id), model] as const) - ); + const byModelRef = new Map(); + allModels + .map(({ model }: any) => model) + .filter(Boolean) + .forEach((model: any) => { + byModelRef.set(String(model.id), model); + if (model.modelId) { + byModelRef.set(String(model.modelId), model); + } + }); return candidateIds - .map((id) => byId.get(id)) + .map((id) => byModelRef.get(id)) .filter(Boolean) .map((model: any) => ({ id: String(model.id), @@ -1266,7 +1270,33 @@ function isImageFailoverExcludedModel(model: { excludeFromFailover?: boolean } | return Boolean(model?.excludeFromFailover); } -function buildImageFallbackModelIds( +type ImageFallbackCandidate = { + model: any; + provider: any; + configured: boolean; + sameProvider: boolean; +}; + +function getImageFailoverConvertProvider(providerType?: string | null) { + switch (providerType) { + case "bfl-image": + case "ruxa-image": + case "fal-image": + case "openai-image": + case "midjourney": + case "runway": + case "krea": + case "gemini": + case "huanke-gemini": + return providerType; + case "volcano-vision": + return "volcano"; + default: + return "alibaba-image"; + } +} + +function buildImageFallbackCandidates( allModels: Array<{ model: any; provider: any }>, options: { currentProviderId?: string; @@ -1276,40 +1306,108 @@ function buildImageFallbackModelIds( } ) { const { currentProviderId, currentModelId, currentModelKey, configuredFallbackIds = [] } = options; + const configuredRefs = configuredFallbackIds + .map((item) => String(item || "").trim()) + .filter(Boolean); + const byModelRef = new Map(); - const eligibleFallbackModelIds = new Set( + const isEligible = (item: { model: any; provider: any }, sameProvider: boolean) => { + if ( + !item?.model?.supportsImageGen || + !item?.model?.isEnabled || + item?.model?.isSubModel || + isImageFailoverExcludedModel(item?.model) || + item?.model?.modelId === currentModelKey || + item?.model?.id === currentModelId + ) { + return false; + } + + const providerType = String(item?.provider?.type || ""); + if (!sameProvider && !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(providerType)) { + return false; + } + if (isImageFallbackModelCoolingDown(item.model.id)) { + const cooldown = imageFailoverModelCooldown.get(item.model.id); + console.log( + `[Chat API] ⏭ Skip configured image fallback on cooldown: ${item.model.displayName} (${providerType}) - ${cooldown?.reason || "cooldown"}` + ); + return false; + } + return true; + }; + + allModels.forEach((item) => { + if (!item?.model) return; + byModelRef.set(String(item.model.id), item); + if (item.model.modelId) { + byModelRef.set(String(item.model.modelId), item); + } + }); + + const candidates: ImageFallbackCandidate[] = []; + const seenModelIds = new Set(); + const pushCandidate = (item: { model: any; provider: any } | undefined, configured: boolean) => { + if (!item?.model) return; + const sameProvider = Boolean(currentProviderId && item.provider?.id === currentProviderId); + if (!isEligible(item, sameProvider)) return; + const modelId = String(item.model.id || ""); + if (!modelId || seenModelIds.has(modelId)) return; + seenModelIds.add(modelId); + candidates.push({ + model: item.model, + provider: item.provider, + configured, + sameProvider, + }); + }; + + configuredRefs.forEach((modelRef) => { + pushCandidate(byModelRef.get(modelRef), true); + }); + + if (currentProviderId) { allModels - .filter( - (item) => - item.model.supportsImageGen && - item.model.isEnabled && - !item.model.isSubModel && - !isImageFailoverExcludedModel(item.model) && - item.model.modelId !== currentModelKey && - item.model.id !== currentModelId - ) - .map((item) => String(item.model.modelId || "")) - .filter(Boolean) - ); + .filter((item) => item.provider?.id === currentProviderId) + .forEach((item) => pushCandidate(item, false)); + } - const sameProviderFallbacks = currentProviderId - ? allModels - .filter( - (item) => - item.provider.id === currentProviderId && - item.model.supportsImageGen && - item.model.isEnabled && - !item.model.isSubModel && - !isImageFailoverExcludedModel(item.model) && - item.model.modelId !== currentModelKey && - item.model.id !== currentModelId - ) - .map((item) => item.model.modelId) - : []; + return candidates; +} - return Array.from(new Set([...configuredFallbackIds, ...sameProviderFallbacks])).filter( - (modelId) => Boolean(modelId) && eligibleFallbackModelIds.has(String(modelId)) +async function generateImageWithFailoverCandidate(params: { + candidate: ImageFallbackCandidate; + messages: ChatMessage[]; + imageParams: any; + fallbackImageParams: any; +}) { + const { candidate, messages, imageParams, fallbackImageParams } = params; + const providerType = String(candidate.provider?.type || ""); + const candidateLanguageModel = await getLanguageModelById(candidate.model.id); + if (!candidateLanguageModel || typeof (candidateLanguageModel as any).doGenerate !== "function") { + throw new Error(`备用模型不支持当前生图故障迁移调用:${candidate.model.displayName || candidate.model.id}`); + } + + const candidateMessages = await convertMessagesWithImages( + messages, + getImageFailoverConvertProvider(providerType) ); + const candidateResult = await (candidateLanguageModel as any).doGenerate({ + messages: candidateMessages, + imageParams: imageParams || fallbackImageParams, + modelDisplayName: candidate.model.displayName, + overrideModelId: candidate.model.modelId, + }); + + if (candidateResult?.finishReason === "error") { + throw new Error(candidateResult.text || "图片生成失败"); + } + + return { + text: candidateResult.text || "图片生成失败", + usedModelId: String(candidate.model.modelId || candidate.model.id || ""), + displayName: String(candidate.model.displayName || candidate.model.modelId || candidate.model.id || ""), + }; } function getImageCrossProviderCandidates( @@ -9736,13 +9834,16 @@ export async function POST(request: Request) { currentModelConfig?.fallbackModelId2, ].filter(Boolean) as string[]; const allModels = await getEnabledAiModelsWithProvider(); - const fallbackModelIds = buildImageFallbackModelIds(allModels, { + const fallbackCandidates = buildImageFallbackCandidates(allModels, { currentProviderId: currentModelConfig?.providerId, currentModelId: currentModelConfig?.id, currentModelKey: actualModelId, configuredFallbackIds: configuredFallbacks, }); - console.log("[Chat API] Fallback model IDs:", fallbackModelIds); + console.log( + "[Chat API] Gemini fallback candidates:", + fallbackCandidates.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`) + ); const geminiStream = createUIMessageStream({ execute: async ({ writer }) => { @@ -9782,11 +9883,7 @@ export async function POST(request: Request) { }); } - // 🆕 尝试生成图片的函数(支持备用模型切换) - const tryGenerateImage = async (modelIdToUse: string, isRetry = false): Promise => { - console.log(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`); - - // 只传递最后一条用户消息,避免上下文历史干扰生图效果 + const buildMessagesForGemini = () => { const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop(); let messagesForGemini = lastUserMessage ? [lastUserMessage] : uiMessagesForModel; messagesForGemini = applyOptimizedPromptToLatestUserMessage( @@ -9794,7 +9891,15 @@ export async function POST(request: Request) { smartAutoOptimizedPrompt, hiddenPromptPrefix, ); - messagesForGemini = injectHistoryImagesToLastUserMessage(messagesForGemini, smartAutoHistoryImages); + return injectHistoryImagesToLastUserMessage(messagesForGemini, smartAutoHistoryImages); + }; + + // 🆕 尝试生成图片的函数(支持备用模型切换) + const tryGenerateImage = async (modelIdToUse: string, isRetry = false): Promise => { + console.log(`[Chat API] ${isRetry ? "🔄 Retry with fallback" : "🚀 Trying"} model: ${modelIdToUse}`); + + // 只传递最后一条用户消息,避免上下文历史干扰生图效果 + const messagesForGemini = buildMessagesForGemini(); console.log("[Chat API] Gemini: Using only last user message (no history context)"); // 转换消息:gemini-image 需要 base64 格式,huanke-gemini 需要 URL 格式 @@ -9847,18 +9952,38 @@ export async function POST(request: Request) { } catch (primaryError) { console.error("[Chat API] Primary model failed:", primaryError); - // 🆕 尝试备用模型(使用同一个 provider,但切换 modelId) + // 🆕 尝试备用模型(支持跨服务商切换) let fallbackSuccess = false; - for (const fallbackModelId of fallbackModelIds) { + for (const candidate of fallbackCandidates) { try { - console.log(`[Chat API] 🔄 Trying fallback model: ${fallbackModelId}`); - imageMarkdown = await tryGenerateImage(fallbackModelId, true); - usedModelId = fallbackModelId; + console.log( + `[Chat API] 🔄 Trying fallback model: ${candidate.model.displayName} (${candidate.provider.type})` + ); + const fallbackResult = await generateImageWithFailoverCandidate({ + candidate, + messages: buildMessagesForGemini(), + imageParams: effectiveImageParams, + fallbackImageParams: { + aspectRatio: defaultImageQualityParams.aspectRatio, + imageSize: defaultImageQualityParams.imageSize, + }, + }); + imageMarkdown = fallbackResult.text; + usedModelId = fallbackResult.usedModelId; fallbackSuccess = true; - console.log(`[Chat API] ✅ Fallback model ${fallbackModelId} succeeded!`); + console.log( + `[Chat API] ✅ Fallback model ${fallbackResult.displayName} succeeded!` + ); break; } catch (fallbackError) { - console.error(`[Chat API] ❌ Fallback model ${fallbackModelId} failed:`, fallbackError); + const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); + if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { + setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); + } + console.error( + `[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, + fallbackError + ); } } @@ -10197,13 +10322,16 @@ export async function POST(request: Request) { alibabaModelConfig?.fallbackModelId2, ].filter(Boolean) as string[]; const allModels = await getEnabledAiModelsWithProvider(); - const fallbackModelIds = buildImageFallbackModelIds(allModels, { + const fallbackCandidates = buildImageFallbackCandidates(allModels, { currentProviderId: alibabaModelConfig?.providerId, currentModelId: alibabaModelConfig?.id, currentModelKey: actualModelId, configuredFallbackIds: configuredFallbacks, }); - console.log("[Chat API] Alibaba fallback model IDs:", fallbackModelIds); + console.log( + "[Chat API] Alibaba fallback candidates:", + fallbackCandidates.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`) + ); const alibabaStream = createUIMessageStream({ execute: async ({ writer }) => { @@ -10341,20 +10469,41 @@ export async function POST(request: Request) { try { imageMarkdown = await tryGenerateImage(actualModelId, false); - } catch (primaryError) { - console.error("[Chat API] Alibaba primary model failed:", primaryError); - - let fallbackSuccess = false; - for (const fallbackModelId of fallbackModelIds) { + } catch (primaryError) { + console.error("[Chat API] Alibaba primary model failed:", primaryError); + + let fallbackSuccess = false; + for (const candidate of fallbackCandidates) { try { - console.log(`[Chat API] 🔄 Alibaba fallback model: ${fallbackModelId}`); - imageMarkdown = await tryGenerateImage(fallbackModelId, true); - usedModelId = fallbackModelId; + console.log( + `[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})` + ); + const fallbackResult = await generateImageWithFailoverCandidate({ + candidate, + messages: messagesForAlibaba, + imageParams: effectiveImageParams, + fallbackImageParams: { + aspectRatio: defaultImageQualityParams.aspectRatio, + imageSize: defaultImageQualityParams.imageSize, + imageCount: defaultImageQualityParams.imageCount, + }, + }); + imageMarkdown = fallbackResult.text; + usedModelId = fallbackResult.usedModelId; fallbackSuccess = true; - console.log(`[Chat API] ✅ Alibaba fallback model ${fallbackModelId} succeeded!`); + console.log( + `[Chat API] ✅ Alibaba fallback model ${fallbackResult.displayName} succeeded!` + ); break; } catch (fallbackError) { - console.error(`[Chat API] ❌ Alibaba fallback model ${fallbackModelId} failed:`, fallbackError); + const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); + if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { + setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); + } + console.error( + `[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, + fallbackError + ); } } @@ -10631,13 +10780,16 @@ export async function POST(request: Request) { volcanoModelConfig?.fallbackModelId2, ].filter(Boolean) as string[]; const allModels = await getEnabledAiModelsWithProvider(); - const fallbackModelIds = buildImageFallbackModelIds(allModels, { + const fallbackCandidates = buildImageFallbackCandidates(allModels, { currentProviderId: volcanoModelConfig?.providerId, currentModelId: volcanoModelConfig?.id, currentModelKey: actualModelId, configuredFallbackIds: configuredFallbacks, }); - console.log("[Chat API] Volcano fallback model IDs:", fallbackModelIds); + console.log( + "[Chat API] Volcano fallback candidates:", + fallbackCandidates.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`) + ); if (hasAnnotation) { console.log("[Chat API] 🎨 Volcano Vision: 检测到标注图片,将注入系统提示"); @@ -10745,19 +10897,40 @@ export async function POST(request: Request) { stopKeepalive = startStreamKeepalive(writer, "volcano-image"); try { imageMarkdown = await tryGenerateImage(actualModelId, false); - } catch (primaryError) { - console.error("[Chat API] Volcano primary model failed:", primaryError); - let fallbackSuccess = false; - for (const fallbackModelId of fallbackModelIds) { + } catch (primaryError) { + console.error("[Chat API] Volcano primary model failed:", primaryError); + let fallbackSuccess = false; + for (const candidate of fallbackCandidates) { try { - console.log(`[Chat API] 🔄 Volcano fallback model: ${fallbackModelId}`); - imageMarkdown = await tryGenerateImage(fallbackModelId, true); - usedModelId = fallbackModelId; + console.log( + `[Chat API] 🔄 Volcano fallback model: ${candidate.model.displayName} (${candidate.provider.type})` + ); + const fallbackResult = await generateImageWithFailoverCandidate({ + candidate, + messages: messagesForVolcano, + imageParams: effectiveImageParams, + fallbackImageParams: { + aspectRatio: defaultImageQualityParams.aspectRatio, + imageSize: defaultImageQualityParams.imageSize, + imageCount: defaultImageQualityParams.imageCount, + }, + }); + imageMarkdown = fallbackResult.text; + usedModelId = fallbackResult.usedModelId; fallbackSuccess = true; - console.log(`[Chat API] ✅ Volcano fallback model ${fallbackModelId} succeeded!`); + console.log( + `[Chat API] ✅ Volcano fallback model ${fallbackResult.displayName} succeeded!` + ); break; } catch (fallbackError) { - console.error(`[Chat API] ❌ Volcano fallback model ${fallbackModelId} failed:`, fallbackError); + const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); + if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { + setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); + } + console.error( + `[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, + fallbackError + ); } } if (!fallbackSuccess) { @@ -11523,13 +11696,16 @@ export async function POST(request: Request) { currentModelConfig?.fallbackModelId2, ].filter(Boolean) as string[]; const allModels = await getEnabledAiModelsWithProvider(); - const fallbackModelIds = buildImageFallbackModelIds(allModels, { + const fallbackCandidates = buildImageFallbackCandidates(allModels, { currentProviderId: currentModelConfig?.providerId, currentModelId: currentModelConfig?.id, currentModelKey: actualModelId, configuredFallbackIds: configuredFallbacks, }); - console.log("[Chat API] OpenAI-compatible fallback model IDs:", fallbackModelIds); + console.log( + "[Chat API] OpenAI-compatible fallback candidates:", + fallbackCandidates.map((candidate) => `${candidate.model.displayName}(${candidate.provider.type})`) + ); const lastUserMessage = uiMessagesForModel.filter((m) => m.role === "user").pop(); let messagesForCrossProvider = lastUserMessage ? [lastUserMessage] : uiMessagesForModel; @@ -11587,35 +11763,61 @@ export async function POST(request: Request) { try { normalizedText = await tryGenerateWithOpenAICompatModel(languageModel); - } catch (primaryError) { - console.error("[Chat API] OpenAI-compatible primary model failed:", primaryError); - - let fallbackSuccess = false; - for (const fallbackModelId of fallbackModelIds) { + } catch (primaryError) { + console.error("[Chat API] OpenAI-compatible primary model failed:", primaryError); + + let fallbackSuccess = false; + for (const candidate of fallbackCandidates) { try { console.log( - `[Chat API] 🔄 OpenAI-compatible fallback model: ${fallbackModelId}` + `[Chat API] 🔄 OpenAI-compatible fallback model: ${candidate.model.displayName} (${candidate.provider.type})` ); - const fallbackLanguageModel = await getLanguageModelById( - finalModelId, - fallbackModelId - ); - if (!fallbackLanguageModel) { - console.log( - `[Chat API] Skip unavailable OpenAI-compatible fallback model: ${fallbackModelId}` + const shouldUseOpenAICompatFallback = + candidate.sameProvider && + !IMAGE_FAILOVER_PROVIDER_ALLOWLIST.has(String(candidate.provider?.type || "")); + + if (shouldUseOpenAICompatFallback) { + const fallbackRuntimeModelId = String( + candidate.model?.modelId || candidate.model?.id || "" ); - continue; + const fallbackLanguageModel = await getLanguageModelById( + finalModelId, + fallbackRuntimeModelId + ); + if (!fallbackLanguageModel) { + console.log( + `[Chat API] Skip unavailable OpenAI-compatible fallback model: ${fallbackRuntimeModelId}` + ); + continue; + } + normalizedText = await tryGenerateWithOpenAICompatModel(fallbackLanguageModel); + usedModelId = fallbackRuntimeModelId; + } else { + const fallbackResult = await generateImageWithFailoverCandidate({ + candidate, + messages: messagesForCrossProvider, + imageParams: effectiveImageParams, + fallbackImageParams: { + aspectRatio: defaultImageQualityParams.aspectRatio, + imageSize: defaultImageQualityParams.imageSize, + imageCount: defaultImageQualityParams.imageCount, + }, + }); + normalizedText = fallbackResult.text; + usedModelId = fallbackResult.usedModelId; } - normalizedText = await tryGenerateWithOpenAICompatModel(fallbackLanguageModel); - usedModelId = fallbackModelId; fallbackSuccess = true; console.log( - `[Chat API] ✅ OpenAI-compatible fallback model ${fallbackModelId} succeeded!` + `[Chat API] ✅ OpenAI-compatible fallback model ${candidate.model.displayName} succeeded!` ); break; } catch (fallbackError) { + const fallbackErrorMessage = normalizeFailoverErrorMessage(fallbackError); + if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { + setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); + } console.error( - `[Chat API] ❌ OpenAI-compatible fallback model ${fallbackModelId} failed:`, + `[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError ); } diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts index e2333823d..8dcb998b7 100644 --- a/app/(chat)/api/models/route.ts +++ b/app/(chat)/api/models/route.ts @@ -3,6 +3,7 @@ import { getAgentAiModelWithProvider, getDefaultAiModel, getEnabledAiModelsWithProvider, + getSiteSettings, getUserById, } from "@/lib/db/queries"; import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability"; @@ -55,6 +56,7 @@ export async function GET() { const modelsWithProvider = await getEnabledAiModelsWithProvider(false, true); const agentModelWithProvider = await getAgentAiModelWithProvider(); const defaultModel = await getDefaultAiModel(); + const siteSettings = await getSiteSettings(); const visibleModelsWithProvider = sortModelsForDisplay( modelsWithProvider .filter( @@ -142,10 +144,32 @@ export async function GET() { : models.find((model) => model.isDefault)?.id)) ?? models[0]?.id ?? null; + const configuredAgentWorkflowModelId = String( + siteSettings?.agentWorkflowDefaultModelId || "" + ).trim(); + const agentWorkflowDefaultModel = configuredAgentWorkflowModelId + ? models.find( + (model) => + model.id === configuredAgentWorkflowModelId && + isImageGenerationModelDescriptor({ + providerType: model.providerType, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }) && + !isVideoModelDescriptor({ + providerType: model.providerType, + modelId: model.modelId, + capabilities: model.capabilities, + supportsImageGen: model.supportsImageGen, + }) + ) + : null; return Response.json({ models, defaultModelId: resolvedDefaultModelId, + agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.id || null, agentModel: agentModelWithProvider ? { id: agentModelWithProvider.model.id, diff --git a/app/api/admin/fal-image-models/route.ts b/app/api/admin/fal-image-models/route.ts new file mode 100644 index 000000000..41f7a44de --- /dev/null +++ b/app/api/admin/fal-image-models/route.ts @@ -0,0 +1,130 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models"; +import { db } from "@/lib/db"; +import { getAiProviderById } from "@/lib/db/queries"; +import { type AiModel, aiModel } from "@/lib/db/schema"; +import { requireAdmin } from "@/lib/middleware/admin"; + +export async function GET() { + try { + await requireAdmin(); + return NextResponse.json({ + presets: FAL_IMAGE_MODEL_PRESETS, + counts: { + imageEdit: FAL_IMAGE_MODEL_PRESETS.length, + total: FAL_IMAGE_MODEL_PRESETS.length, + }, + }); + } catch (error: any) { + console.error("[FAL Image Models API] GET error:", error); + return NextResponse.json( + { error: error.message || "Failed to fetch FAL image model presets" }, + { status: 500 } + ); + } +} + +export async function POST(request: Request) { + try { + await requireAdmin(); + + const body = await request.json(); + const providerId = String(body?.providerId || "").trim(); + const modelIds = Array.isArray(body?.modelIds) + ? body.modelIds + .map((item: unknown) => String(item || "").trim()) + .filter(Boolean) + : null; + + if (!providerId) { + return NextResponse.json( + { error: "Provider ID is required" }, + { status: 400 } + ); + } + + const provider = await getAiProviderById({ id: providerId }); + if (!provider || provider.type !== "fal-image") { + return NextResponse.json( + { error: "Provider must be a FAL image provider" }, + { status: 400 } + ); + } + + const presetsToAdd = modelIds + ? FAL_IMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId)) + : FAL_IMAGE_MODEL_PRESETS; + + const addedModels: AiModel[] = []; + const errors: { modelId: string; error: string }[] = []; + + for (const preset of presetsToAdd) { + try { + const existing = await db.query.aiModel.findFirst({ + where: and( + eq(aiModel.providerId, providerId), + eq(aiModel.modelId, preset.modelId) + ), + }); + + if (existing) { + errors.push({ + modelId: preset.modelId, + error: "Model already exists", + }); + continue; + } + + const [newModel] = await db + .insert(aiModel) + .values({ + providerId, + modelId: preset.modelId, + displayName: preset.displayName, + description: preset.description, + supportsVision: preset.supportsVision, + supportsImageGen: preset.supportsImageGen, + isEnabled: true, + isHidden: false, + isDefault: false, + sortOrder: preset.sortOrder, + capabilities: preset.capabilities || null, + defaultParams: preset.defaultParams || null, + imageGenConfig: preset.imageGenConfig || null, + isSubModel: false, + parentModelId: null, + }) + .returning(); + + addedModels.push(newModel); + } catch (error: any) { + console.error( + `[FAL Image Models API] Failed to add ${preset.modelId}:`, + error + ); + errors.push({ + modelId: preset.modelId, + error: error.message || "Insert failed", + }); + } + } + + return NextResponse.json({ + success: true, + addedCount: addedModels.length, + addedModels, + errors: errors.length > 0 ? errors : undefined, + counts: { + requested: presetsToAdd.length, + total: FAL_IMAGE_MODEL_PRESETS.length, + }, + }); + } catch (error: any) { + console.error("[FAL Image Models API] POST error:", error); + return NextResponse.json( + { error: error.message || "Failed to add FAL image models" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index fc3b98536..013854b19 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -47,6 +47,10 @@ import { normalizeVideoCanvasFunctionConfig, VIDEO_CANVAS_FUNCTION_CONFIG_KEY, } from "@/lib/video-canvas/config"; +import { + CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, + normalizeCanvasImageTaskQueueConfig, +} from "@/lib/canvas/image-task-config"; const settingSchema = z.object({ key: z.string(), @@ -86,6 +90,14 @@ export async function GET(request: Request) { if (key) { const setting = await getSystemSetting({ key }); + if (key === CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY) { + const value = normalizeCanvasImageTaskQueueConfig(setting?.value, { + envConcurrency: process.env.CANVAS_IMAGE_TASK_CONCURRENCY, + }); + return Response.json({ + setting: setting ? { ...setting, value } : { key, value }, + }); + } return Response.json({ setting }); } @@ -216,6 +228,10 @@ export async function POST(request: Request) { nextValue = normalizeVideoCanvasFunctionConfig(value); } + if (key === CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY) { + nextValue = normalizeCanvasImageTaskQueueConfig(value); + } + if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) { const existingSetting = await getSystemSetting({ key: THIRD_PARTY_SQLSERVER_CONFIG_KEY, diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 9df4faf55..5546e6ddd 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -844,6 +844,7 @@ export async function GET() { ossImageMigrationConfig, canvasDefaultModelId: null, canvasAutoModeDefaultModelId: null, + agentWorkflowDefaultModelId: null, canvasDetailDefaultModelId: null, canvasDetailAgentModelId: null, lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", @@ -1181,6 +1182,7 @@ const updateSchema = z.object({ enabled: z.boolean().optional(), iconKey: z.enum(["main-sku", "detail", "buyer-show"]).optional(), customSvgUrl: z.string().trim().max(2000).nullable().optional(), + defaultModelId: z.string().uuid().nullable().optional(), sortOrder: z.number().min(0).max(99).optional(), referenceReplaceEnabled: z.boolean().optional(), aiDetailGenerateEnabled: z.boolean().optional(), @@ -1308,6 +1310,7 @@ const updateSchema = z.object({ aiDetailGenerationEnabled: z.boolean().optional(), canvasDefaultModelId: z.string().nullable().optional(), canvasAutoModeDefaultModelId: z.string().nullable().optional(), + agentWorkflowDefaultModelId: z.string().nullable().optional(), canvasDetailDefaultModelId: z.string().nullable().optional(), canvasDetailAgentModelId: z.string().nullable().optional(), lovartPromoText: z.string().max(300).optional(), @@ -2273,6 +2276,7 @@ export async function PUT(request: NextRequest) { realtimeCanvasEnabled: siteSettingsPayload.realtimeCanvasEnabled ?? true, canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId, canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId, + agentWorkflowDefaultModelId: siteSettingsPayload.agentWorkflowDefaultModelId, canvasDetailDefaultModelId: siteSettingsPayload.canvasDetailDefaultModelId, canvasDetailAgentModelId: siteSettingsPayload.canvasDetailAgentModelId, lovartPromoText: diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index b9bd35c92..87fd48d14 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -1218,6 +1218,7 @@ export function AgentGenerationWorkspaceDarkV2({ imageGenConfig?: any; }>; defaultModelId?: string | null; + agentWorkflowDefaultModelId?: string | null; agentModel?: any; }>("/api/models", fetcher); @@ -1253,6 +1254,15 @@ export function AgentGenerationWorkspaceDarkV2({ const activeBusinessMode = businessModeCards.find((mode) => mode.key === activeBusinessModeKey) || businessModeCards[0] || defaultAgentWorkflowBusinessModeSettings.modes[0]; + const activeBusinessModeDefaultModelId = !requiresPresetSelection + ? String(activeBusinessMode?.defaultModelId || "").trim() || null + : null; + const effectiveDefaultModelId = + activeBusinessModeDefaultModelId || + selectedAgentPreset?.defaultModelId || + modelsData?.agentWorkflowDefaultModelId || + modelsData?.defaultModelId || + null; const presetDrivenResultModeType: AgentGenerationResultModeType = selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement" ? detailReplacementMethod === "ai_detail_generate" @@ -1849,9 +1859,7 @@ export function AgentGenerationWorkspaceDarkV2({ const resolvedSelectedModelId = transportState.selectedModelId !== "auto" ? transportState.selectedModelId - : selectedAgentPreset?.defaultModelId || - modelsData?.defaultModelId || - transportState.selectedModelId; + : effectiveDefaultModelId || transportState.selectedModelId; const replacementModePayload = transportState.isDetailReplacementPreset || transportState.isSkuReplacementPreset ? { @@ -1952,23 +1960,15 @@ export function AgentGenerationWorkspaceDarkV2({ ); return; } - if (selectedAgentPreset?.defaultModelId) { - const nextModelId = selectedAgentPreset.defaultModelId; - setSelectedModelId((current) => - current === nextModelId ? current : nextModelId - ); - return; - } - if (modelsData?.defaultModelId) { - const nextModelId = modelsData.defaultModelId; + if (effectiveDefaultModelId) { + const nextModelId = effectiveDefaultModelId; setSelectedModelId((current) => current === nextModelId ? current : nextModelId ); } }, [ - modelsData?.defaultModelId, + effectiveDefaultModelId, normalizedIncomingWorkspaceState.selectedModelId, - selectedAgentPreset?.defaultModelId, ]); // 初始化附件 @@ -2984,7 +2984,7 @@ export function AgentGenerationWorkspaceDarkV2({ const resolvedModelId = selectedModelId !== "auto" ? selectedModelId - : selectedAgentPreset?.defaultModelId || modelsData?.defaultModelId || selectedModelId; + : effectiveDefaultModelId || selectedModelId; if (!resolvedModelId) { toast.error("当前风格化未配置模型"); return; @@ -3107,7 +3107,7 @@ export function AgentGenerationWorkspaceDarkV2({ isDetailReplacementPreset, isSelectedBusinessModeUnavailable, isSkuReplacementPreset, - modelsData?.defaultModelId, + effectiveDefaultModelId, requiredProductUploadCount, requiresPresetSelection, selectedAgentPreset, @@ -3667,7 +3667,7 @@ export function AgentGenerationWorkspaceDarkV2({ const resolvedModelId = selectedModelId !== "auto" ? selectedModelId - : selectedAgentPreset?.defaultModelId || modelsData?.defaultModelId || undefined; + : effectiveDefaultModelId || undefined; detailGenerationCancelRef.current = false; setDetailGenerationRunResults([]); diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index e9bf8b88a..8a501371f 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -47,6 +47,7 @@ export const defaultAgentWorkflowBusinessModeSettings: AgentWorkflowBusinessMode enabled: true, iconKey: "main-sku", customSvgUrl: "", + defaultModelId: null, sortOrder: 0, }, { @@ -56,6 +57,7 @@ export const defaultAgentWorkflowBusinessModeSettings: AgentWorkflowBusinessMode enabled: true, iconKey: "detail", customSvgUrl: "", + defaultModelId: null, sortOrder: 1, referenceReplaceEnabled: true, aiDetailGenerateEnabled: true, @@ -67,6 +69,7 @@ export const defaultAgentWorkflowBusinessModeSettings: AgentWorkflowBusinessMode enabled: false, iconKey: "buyer-show", customSvgUrl: "", + defaultModelId: null, sortOrder: 2, }, ], @@ -351,6 +354,10 @@ function normalizeAgentWorkflowBusinessModeConfig( enabled: normalizeBoolean(record.enabled, fallback.enabled), iconKey: normalizeAgentWorkflowBusinessModeIconKey(record.iconKey, fallback.iconKey), customSvgUrl: normalizeText(record.customSvgUrl ?? record.iconSvgUrl, 2000), + defaultModelId: + normalizeText(record.defaultModelId ?? record.modelId, 128) || + fallback.defaultModelId || + null, sortOrder: normalizeNumber(record.sortOrder, fallback.sortOrder, 99), }; diff --git a/lib/ai/fal-image-models.ts b/lib/ai/fal-image-models.ts new file mode 100644 index 000000000..2a129c6e8 --- /dev/null +++ b/lib/ai/fal-image-models.ts @@ -0,0 +1,78 @@ +export const FAL_IMAGE_BASE_URL = "https://fal.run"; +export const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit"; + +export interface FalImageModelPreset { + modelId: string; + displayName: string; + description: string; + supportsVision: boolean; + supportsImageGen: boolean; + capabilities?: Record; + defaultParams?: Record; + imageGenConfig?: Record; + sortOrder: number; +} + +export const FAL_IMAGE_MODEL_PRESETS: FalImageModelPreset[] = [ + { + modelId: FAL_GPT_IMAGE_2_EDIT_MODEL_ID, + displayName: "FAL GPT Image 2 Edit", + description: + "FAL 托管的 OpenAI GPT Image 2 图片编辑模型,需至少一张参考图,支持多图参考、自然语言编辑与可选蒙版。", + supportsVision: true, + supportsImageGen: true, + capabilities: { + provider: "fal", + kind: "image-edit", + endpoint: FAL_GPT_IMAGE_2_EDIT_MODEL_ID, + apiFormat: "fal-gpt-image-2-edit", + officialUrl: "https://fal.ai/models/openai/gpt-image-2/edit", + requiredFields: ["prompt", "image_urls"], + optionalFields: [ + "image_size", + "quality", + "num_images", + "output_format", + "mask_image_url", + "openai_api_key", + ], + supportsMultiImageReference: true, + supportsMask: true, + supportsStreaming: true, + }, + defaultParams: { + provider: "fal", + endpoint: FAL_GPT_IMAGE_2_EDIT_MODEL_ID, + apiFormat: "fal-gpt-image-2-edit", + imageSize: "auto", + quality: "high", + outputFormat: "png", + }, + imageGenConfig: { + maxImages: 4, + supportsSize: true, + supports1K: true, + supports2K: true, + supports4K: true, + supportsPromptExtend: true, + supportsNegativePrompt: false, + supportsWatermark: false, + supportsSeed: false, + supportedImageSizes: [ + "auto", + "square_hd", + "square", + "landscape_4_3", + "landscape_16_9", + "portrait_4_3", + "portrait_16_9", + ], + supportedQualities: ["low", "medium", "high"], + enableLoadingPlaceholder: true, + loadingColor: "purple", + creditCostPerImage: 0, + creditCostPerVideo: 0, + }, + sortOrder: 20, + }, +]; diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 3e3200bfd..aec903d48 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -16,6 +16,10 @@ interface FalImageParams { outputFormat?: "png" | "jpg" | "jpeg" | "webp"; safetyTolerance?: string | number; enableWebSearch?: boolean; + quality?: string; + maskImageUrl?: string; + openaiApiKey?: string; + syncMode?: boolean; } interface UploadedImage { @@ -28,6 +32,47 @@ const DEFAULT_RESOLUTION = "1K"; const DEFAULT_OUTPUT_FORMAT = "png"; const DEFAULT_SAFETY_TOLERANCE = "4"; const DEFAULT_TIMEOUT_MS = 180000; +const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit"; +const GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS = new Set([ + "auto", + "square_hd", + "square", + "portrait_4_3", + "portrait_16_9", + "landscape_4_3", + "landscape_16_9", +]); +const GPT_IMAGE_2_EDIT_QUALITIES = new Set(["low", "medium", "high"]); + +type FalImageSize = string | { width: number; height: number }; + +const GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET: Record> = { + "1K": { + "1:1": "square_hd", + "4:3": "landscape_4_3", + "3:4": "portrait_4_3", + "16:9": "landscape_16_9", + "9:16": "portrait_16_9", + }, + "2K": { + "1:1": { width: 2048, height: 2048 }, + "3:2": { width: 2048, height: 1360 }, + "2:3": { width: 1360, height: 2048 }, + "4:3": { width: 2048, height: 1536 }, + "3:4": { width: 1536, height: 2048 }, + "16:9": { width: 1920, height: 1088 }, + "9:16": { width: 1088, height: 1920 }, + }, + "4K": { + "1:1": { width: 3072, height: 3072 }, + "3:2": { width: 3072, height: 2048 }, + "2:3": { width: 2048, height: 3072 }, + "4:3": { width: 3072, height: 2304 }, + "3:4": { width: 2304, height: 3072 }, + "16:9": { width: 3840, height: 2160 }, + "9:16": { width: 2160, height: 3840 }, + }, +}; function normalizeFalCredentials(apiKey: string) { const normalized = apiKey @@ -141,6 +186,60 @@ function normalizeSafetyTolerance(value?: string | number) { return DEFAULT_SAFETY_TOLERANCE; } +function isFalGptImage2EditModel(modelId: string) { + return modelId.trim().toLowerCase() === FAL_GPT_IMAGE_2_EDIT_MODEL_ID; +} + +function normalizeGptImage2EditQuality(value?: string) { + if (!value) return "high"; + const normalized = value.trim().toLowerCase(); + return GPT_IMAGE_2_EDIT_QUALITIES.has(normalized) ? normalized : "high"; +} + +function normalizeGptImage2EditImageSize( + imageSize?: string, + aspectRatio?: string +): FalImageSize { + if (!imageSize) return "auto"; + + const rawSize = imageSize.trim(); + const normalizedSize = rawSize.toLowerCase().replace(/\s+/g, ""); + if (GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS.has(normalizedSize)) { + return normalizedSize; + } + + const customSizeMatch = normalizedSize.match(/^(\d{2,5})[x×](\d{2,5})$/i); + if (customSizeMatch) { + const width = Number(customSizeMatch[1]); + const height = Number(customSizeMatch[2]); + if ( + Number.isFinite(width) && + Number.isFinite(height) && + width >= 256 && + height >= 256 && + width <= 4096 && + height <= 4096 && + width % 16 === 0 && + height % 16 === 0 + ) { + return { width, height }; + } + } + + const sizeBucket = rawSize.toUpperCase(); + const ratio = normalizeAspectRatio(aspectRatio).toLowerCase(); + return GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET[sizeBucket]?.[ratio] || "auto"; +} + +function getFalImageSizeLabel(size: FalImageSize) { + return typeof size === "string" ? size : `${size.width}x${size.height}`; +} + +function normalizeOptionalHttpUrl(value?: string) { + const normalized = String(value || "").trim(); + return /^https?:\/\//i.test(normalized) ? normalized : ""; +} + function detectImageExtension(imageUrl: string, contentType?: string | null) { const type = (contentType || "").toLowerCase(); if (type.includes("jpeg") || type.includes("jpg")) return "jpg"; @@ -313,13 +412,21 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { const safetyTolerance = normalizeSafetyTolerance(options.imageParams?.safetyTolerance); const imageCount = Math.max(1, Math.min(6, Number(options.imageParams?.imageCount || 1))); const activeModelId = options.overrideModelId || modelId; + const isGptImage2Edit = isFalGptImage2EditModel(activeModelId); + const gptImage2EditSize = normalizeGptImage2EditImageSize( + options.imageParams?.imageSize, + options.imageParams?.aspectRatio + ); + const outputSizeLabel = isGptImage2Edit + ? getFalImageSizeLabel(gptImage2EditSize) + : resolution; console.log("[Fal Image] Submitting request", { model: activeModelId, baseURL, imageCount, aspectRatio, - resolution, + resolution: outputSizeLabel, }); const fal = await import("@fal-ai/serverless-client"); @@ -327,21 +434,51 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { credentials: normalizeFalCredentials(apiKey), }); - const input: Record = { - prompt, - num_images: imageCount, - aspect_ratio: aspectRatio, - resolution, - output_format: outputFormat, - safety_tolerance: safetyTolerance, - }; + let input: Record; + if (isGptImage2Edit) { + if (imageUrls.length === 0) { + throw new Error("FAL GPT Image 2 Edit 需要至少一张可访问的参考图片 URL"); + } - if (imageUrls.length > 0) { - input.image_urls = imageUrls; - } + input = { + prompt, + image_urls: imageUrls, + image_size: gptImage2EditSize, + quality: normalizeGptImage2EditQuality(options.imageParams?.quality), + num_images: imageCount, + output_format: outputFormat, + }; - if (typeof options.imageParams?.enableWebSearch === "boolean") { - input.enable_web_search = options.imageParams.enableWebSearch; + const maskImageUrl = normalizeOptionalHttpUrl(options.imageParams?.maskImageUrl); + if (maskImageUrl) { + input.mask_image_url = maskImageUrl; + } + + if (typeof options.imageParams?.syncMode === "boolean") { + input.sync_mode = options.imageParams.syncMode; + } + + const openaiApiKey = String(options.imageParams?.openaiApiKey || "").trim(); + if (openaiApiKey) { + input.openai_api_key = openaiApiKey; + } + } else { + input = { + prompt, + num_images: imageCount, + aspect_ratio: aspectRatio, + resolution, + output_format: outputFormat, + safety_tolerance: safetyTolerance, + }; + + if (imageUrls.length > 0) { + input.image_urls = imageUrls; + } + + if (typeof options.imageParams?.enableWebSearch === "boolean") { + input.enable_web_search = options.imageParams.enableWebSearch; + } } const result: any = await fal.subscribe(activeModelId, { @@ -380,7 +517,7 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { throw new Error("FAL 未返回可用图片"); } - const text = formatMarkdown(uploadedImages, resolution); + const text = formatMarkdown(uploadedImages, outputSizeLabel); return { text, finishReason: "stop" as const, diff --git a/lib/canvas/image-task-config.ts b/lib/canvas/image-task-config.ts new file mode 100644 index 000000000..ce87e0320 --- /dev/null +++ b/lib/canvas/image-task-config.ts @@ -0,0 +1,55 @@ +export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config"; + +export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 1; +export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 100; + +export interface CanvasImageTaskQueueConfig { + concurrency: number; +} + +function clampConcurrency(value: number) { + return Math.min( + MAX_CANVAS_IMAGE_TASK_CONCURRENCY, + Math.max(DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY, Math.floor(value)) + ); +} + +function parseConcurrencyCandidate(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return clampConcurrency(value); + } + + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed)) { + return clampConcurrency(parsed); + } + } + + return null; +} + +export function normalizeCanvasImageTaskQueueConfig( + value: unknown, + options?: { envConcurrency?: unknown } +): CanvasImageTaskQueueConfig { + const raw = value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const candidates = [ + raw.concurrency, + raw.maxConcurrency, + raw.maxConcurrent, + typeof value === "number" || typeof value === "string" ? value : undefined, + options?.envConcurrency, + ]; + + for (const candidate of candidates) { + const parsed = parseConcurrencyCandidate(candidate); + if (parsed !== null) { + return { concurrency: parsed }; + } + } + + return { concurrency: DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY }; +} diff --git a/lib/canvas/image-task-queue.ts b/lib/canvas/image-task-queue.ts index 801979065..4fcedb73d 100644 --- a/lib/canvas/image-task-queue.ts +++ b/lib/canvas/image-task-queue.ts @@ -2,7 +2,13 @@ import "server-only"; import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm"; import { db } from "@/lib/db"; +import { getSystemSetting } from "@/lib/db/queries"; import { canvasImageTask } from "@/lib/db/schema"; +import { + CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, + DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY, + normalizeCanvasImageTaskQueueConfig, +} from "./image-task-config"; import { generateCanvasImage, type CanvasGenerateImageRequest, @@ -18,17 +24,47 @@ interface QueueState { draining: boolean; } -const DEFAULT_CONCURRENCY = 1; -const MAX_CONCURRENCY = 100; +const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000; const STALE_RUNNING_MS = 5 * 60 * 1000; const queueStateKey = "__canvas_image_task_queue_state__"; +let cachedConcurrency: + | { + expiresAt: number; + value: number; + } + | null = null; -function getQueueConcurrency(): number { - const raw = Number.parseInt(process.env.CANVAS_IMAGE_TASK_CONCURRENCY || "", 10); - if (Number.isFinite(raw) && raw > 0) { - return Math.min(raw, MAX_CONCURRENCY); +async function getQueueConcurrency(): Promise { + const now = Date.now(); + if (cachedConcurrency && cachedConcurrency.expiresAt > now) { + return cachedConcurrency.value; } - return DEFAULT_CONCURRENCY; + + const envConcurrency = process.env.CANVAS_IMAGE_TASK_CONCURRENCY; + let value = normalizeCanvasImageTaskQueueConfig(undefined, { + envConcurrency, + }).concurrency; + + try { + const setting = await getSystemSetting({ + key: CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY, + }); + if (setting?.value !== undefined && setting.value !== null) { + value = normalizeCanvasImageTaskQueueConfig(setting.value, { + envConcurrency, + }).concurrency; + } + } catch (error) { + console.warn("[Canvas Image Task Queue] Failed to load DB concurrency config, using fallback", { + error: error instanceof Error ? error.message : String(error), + }); + } + + cachedConcurrency = { + expiresAt: now + CONCURRENCY_CONFIG_CACHE_MS, + value: value || DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY, + }; + return cachedConcurrency.value; } function getQueueState(): QueueState { @@ -140,7 +176,7 @@ export async function drainCanvasImageTaskQueue() { state.draining = true; try { - const maxConcurrent = getQueueConcurrency(); + const maxConcurrent = await getQueueConcurrency(); while (state.running.size < maxConcurrent) { const taskId = await claimNextPendingTask(); if (!taskId) break; diff --git a/lib/db/migrations/0075_add_agent_workflow_default_model.sql b/lib/db/migrations/0075_add_agent_workflow_default_model.sql new file mode 100644 index 000000000..28597252f --- /dev/null +++ b/lib/db/migrations/0075_add_agent_workflow_default_model.sql @@ -0,0 +1,2 @@ +ALTER TABLE "SiteSettings" +ADD COLUMN IF NOT EXISTS "agentWorkflowDefaultModelId" varchar(128); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 06fcd4c57..0e291c8e9 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -428,6 +428,13 @@ "when": 1777252800000, "tag": "0074_add_enhance_pro_tasks", "breakpoints": true + }, + { + "idx": 61, + "version": "7", + "when": 1777276800000, + "tag": "0075_add_agent_workflow_default_model", + "breakpoints": true } ] } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 5810bc394..01e70f462 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -4458,6 +4458,10 @@ export async function getSiteSettings() { productPlanningEnabled: true, agentEnabled: true, videoCanvasEnabled: true, + canvasDefaultModelId: null, + canvasAutoModeDefaultModelId: null, + agentWorkflowDefaultModelId: null, + canvasDetailDefaultModelId: null, canvasDetailAgentModelId: null, lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: "立即升级", diff --git a/lib/db/schema.ts b/lib/db/schema.ts index bb0752469..a750be413 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -398,6 +398,7 @@ export const siteSettings = pgTable("SiteSettings", { realtimeCanvasEnabled: boolean("realtimeCanvasEnabled").notNull().default(true), // 是否显示实时画布入口 canvasDefaultModelId: varchar("canvasDefaultModelId", { length: 128 }), // 画布默认选择的模型ID(null表示智能自动) canvasAutoModeDefaultModelId: varchar("canvasAutoModeDefaultModelId", { length: 128 }), // 智能自动模式下实际使用的默认生图模型ID + agentWorkflowDefaultModelId: varchar("agentWorkflowDefaultModelId", { length: 128 }), // 风格化专用默认生图模型ID canvasDetailDefaultModelId: varchar("canvasDetailDefaultModelId", { length: 128 }), // AI详情生成专用默认生图模型ID canvasDetailAgentModelId: varchar("canvasDetailAgentModelId", { length: 128 }), // AI详情生成专用Agent模型ID(规划/复审) lovartPromoText: text("lovartPromoText").default( diff --git a/types/agent.ts b/types/agent.ts index 8f6a90d80..59750c1a9 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -130,6 +130,7 @@ export interface AgentWorkflowBusinessModeConfig { iconKey: AgentWorkflowBusinessModeIconKey; customSvgUrl?: string | null; sortOrder: number; + defaultModelId?: string | null; referenceReplaceEnabled?: boolean; aiDetailGenerateEnabled?: boolean; }