feat: support cross-provider image failover and workflow model settings
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-04-27 20:10:48 +08:00
parent b8a59fe2a4
commit 2108d06715
18 changed files with 1340 additions and 157 deletions

View File

@ -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<string, string>();
(providersData?.providers || []).forEach((provider) => {
map.set(provider.id, provider.name);
});
return map;
}, [providersData?.providers]);
// 网站设置状态
const [siteSettings, setSiteSettings] = useState<SiteSettings>({
@ -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<CanvasImageTaskQueueConfig>(() =>
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模式自动出图时的默认生图模型。
</div>
<div className="space-y-2 mt-4">
<Label htmlFor="agentWorkflowDefaultModelId">
风格化生图 - 专用默认模型
</Label>
<Select
value={
siteSettings.agentWorkflowDefaultModelId ||
"system"
}
onValueChange={(value) =>
setSiteSettings({
...siteSettings,
agentWorkflowDefaultModelId:
value === "system" ? null : value,
})
}
>
<SelectTrigger id="agentWorkflowDefaultModelId">
<SelectValue placeholder="选择风格化默认生图模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">
沿用前台通用默认模型
</SelectItem>
{imageGenerationModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-xs text-muted-foreground">
仅影响风格化功能页默认出图模型;固定业务模式卡片里单独配置的默认模型优先级更高。
</div>
</div>
<div className="space-y-2 mt-4">
<Label htmlFor="canvasDetailAgentModelId">
AI详情生成 - Agent专用模型
@ -13497,6 +13692,17 @@ export function AdminSettingsClient() {
快速添加 Krea 预设模型
</Button>
)}
{provider.type === "fal-image" && (
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => handleQuickAddFalImageModels(provider.id)}
>
<ImageIcon className="h-4 w-4 mr-1" />
快速添加 FAL 图片预设
</Button>
)}
{provider.type === "bltcy-video" && (
<Button
variant="secondary"
@ -13820,46 +14026,95 @@ export function AdminSettingsClient() {
/>
</div>
{/* 🆕 备用模型 ID 配置 */}
{/* 🆕 备用模型配置 */}
<div className="space-y-2 border-t pt-4 mt-4">
<div className="flex items-center gap-2 mb-2">
<Label className="text-sm font-medium">
备用模型配置
</Label>
<span className="text-xs text-muted-foreground">
(主模型请求失败时自动切换)
(主模型请求失败时自动切换,支持跨服务商)
</span>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
备用模型 ID 1(优先使用)
备用模型 1(优先使用)
</Label>
<Input
value={modelForm.fallbackModelId1}
onChange={(e) =>
<Select
value={modelForm.fallbackModelId1 || "__none__"}
onValueChange={(value) =>
setModelForm({
...modelForm,
fallbackModelId1: e.target.value,
fallbackModelId1:
value === "__none__" ? "" : value,
})
}
placeholder="例如: nano-banana-pro-backup"
/>
>
<SelectTrigger>
<SelectValue placeholder="选择备用模型" />
</SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="__none__">
不设置备用模型
</SelectItem>
{modelForm.fallbackModelId1 &&
!failoverModelOptions.some(
(model) => model.id === modelForm.fallbackModelId1
) ? (
<SelectItem value={modelForm.fallbackModelId1}>
已配置旧模型引用({modelForm.fallbackModelId1})
</SelectItem>
) : null}
{failoverModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName} · {model.providerName} ·{" "}
{model.modelId}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
备用模型 ID 2(最后备用)
备用模型 2(最后备用)
</Label>
<Input
value={modelForm.fallbackModelId2}
onChange={(e) =>
<Select
value={modelForm.fallbackModelId2 || "__none__"}
onValueChange={(value) =>
setModelForm({
...modelForm,
fallbackModelId2: e.target.value,
fallbackModelId2:
value === "__none__" ? "" : value,
})
}
placeholder="例如: nano-banana-lite"
/>
>
<SelectTrigger>
<SelectValue placeholder="选择备用模型" />
</SelectTrigger>
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="__none__">
不设置备用模型
</SelectItem>
{modelForm.fallbackModelId2 &&
!failoverModelOptions.some(
(model) => model.id === modelForm.fallbackModelId2
) ? (
<SelectItem value={modelForm.fallbackModelId2}>
已配置旧模型引用({modelForm.fallbackModelId2})
</SelectItem>
) : null}
{failoverModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName} · {model.providerName} ·{" "}
{model.modelId}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
生图模型会优先按这里选择的备用模型迁移;若仍失败,系统再走自动故障迁移候选池。
</p>
</div>
<div className="flex gap-4">
@ -19599,6 +19854,73 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
{/* 生图任务队列并发 */}
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-xl">
<ImageIcon className="h-5 w-5" />
生图任务队列并发
</CardTitle>
<CardDescription>
控制风格化/画布异步生图队列允许同时运行的任务数。配置保存到数据库,运行队列会自动读取,无需重启服务。
</CardDescription>
</div>
<LocalAiSectionToggleButton
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
onClick={() => toggleLocalAiSection("canvas-image-task-queue")}
/>
</CardHeader>
<CardContent
className={cn(
"local-ai-config-card-content space-y-6 pt-6",
!isLocalAiSectionExpanded("canvas-image-task-queue") && "hidden"
)}
>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-xs leading-5 text-amber-800">
<p>• 影响通过异步队列提交的风格化/画布生图任务。</p>
<p>• 多用户共享这个全局并发池,不按用户单独拆分。</p>
<p>• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key 池继续细分。</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
type="number"
min={DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
value={canvasImageTaskQueueConfig.concurrency}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
concurrency: e.target.value,
})
)
}
/>
<div className="text-xs text-muted-foreground">
可配置范围 {DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发。
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存生图队列并发
</Button>
</div>
</CardContent>
</Card>
{/* 批量处理配置 */}
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">

View File

@ -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<AgentWorkflowSettingsField, string> = {
agentWorkflowDefaultModelId: "风格化专用默认模型",
agentPresetLibraryConfig: "预设库",
agentWorkflowUiConfig: "工作流步骤",
agentWorkflowMaterialApiConfig: "素材搜索接口",
@ -1805,6 +1830,10 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_LABELS: Record<AgentWorkflowSettingsField, s
function getAgentWorkflowSettingsPayloadEntries(state: AgentWorkflowSettingsDraftState) {
return [
{
field: "agentWorkflowDefaultModelId",
value: state.agentWorkflowDefaultModelId,
},
{
field: "agentPresetLibraryConfig",
value: state.presetLibraryConfig,
@ -1849,6 +1878,12 @@ export function AgentWorkflowSettingsClient({
fetcher,
{ refreshInterval: 60_000 }
);
const { data: aiModelsData } = useSWR<{ models: AdminAiModelOption[] }>(
"/api/admin/ai-models",
fetcher,
{ revalidateOnFocus: false }
);
const [agentWorkflowDefaultModelId, setAgentWorkflowDefaultModelId] = useState<string | null>(null);
const [presetLibraryConfig, setPresetLibraryConfig] = useState<AgentPresetLibraryConfig>(
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<string, string>(entries);
}, [workflowImageModelOptions]);
const firstCategoryLabel = categoryLabels.firstCategory;
const secondCategoryLabel = categoryLabels.secondCategory;
const thirdCategoryLabel = categoryLabels.thirdCategory;
@ -2104,6 +2166,7 @@ export function AgentWorkflowSettingsClient({
const autoDraftState = useMemo<AgentWorkflowSettingsDraftState>(
() => ({
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({
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-3">
{workflowBusinessModeSettings.modes.map((mode) => {
<CardContent className="space-y-4">
<div className="rounded-xl border bg-background/70 p-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(260px,360px)] lg:items-center">
<div>
<div className="text-sm font-semibold">风格化专用默认生图模型</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
当前台业务模式没有单独指定模型时,会优先使用这里的风格化专用模型;未设置时再沿用前台通用默认生图模型。
</p>
</div>
<Select
value={
agentWorkflowDefaultModelId ||
BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
}
onValueChange={(value) =>
setAgentWorkflowDefaultModelId(
value === BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
? null
: value
)
}
>
<SelectTrigger>
<SelectValue placeholder="选择风格化默认生图模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE}>
沿用前台通用默认模型
</SelectItem>
{agentWorkflowDefaultModelId &&
!workflowImageModelLabelById.has(agentWorkflowDefaultModelId) ? (
<SelectItem value={agentWorkflowDefaultModelId}>
已配置模型不存在或已停用({agentWorkflowDefaultModelId})
</SelectItem>
) : null}
{workflowImageModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{workflowImageModelLabelById.get(model.id) ||
model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
{workflowBusinessModeSettings.modes.map((mode) => {
const refKey = `business-mode:${mode.key}`;
const isUploading = uploadingKey === refKey;
const Icon =
@ -6112,6 +6225,49 @@ export function AgentWorkflowSettingsClient({
/>
</div>
<div className="space-y-2">
<Label>默认生图模型</Label>
<Select
value={
mode.defaultModelId ||
BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
}
onValueChange={(value) =>
updateBusinessMode(mode.key, (current) => ({
...current,
defaultModelId:
value === BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE
? null
: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="选择默认生图模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={BUSINESS_MODE_DEFAULT_MODEL_EMPTY_VALUE}>
沿用风格化专用默认模型
</SelectItem>
{mode.defaultModelId &&
!workflowImageModelLabelById.has(mode.defaultModelId) ? (
<SelectItem value={mode.defaultModelId}>
已配置模型不存在或已停用({mode.defaultModelId})
</SelectItem>
) : null}
{workflowImageModelOptions.map((model) => (
<SelectItem key={model.id} value={model.id}>
{workflowImageModelLabelById.get(model.id) ||
model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
用户选择该业务模式后,会优先使用这里配置的模型;未配置时沿用上方风格化专用默认模型。
</p>
</div>
{mode.key === "detail" ? (
<div className="grid gap-2 rounded-lg border bg-background/60 p-3">
<div className="flex items-center justify-between gap-3">
@ -6192,8 +6348,9 @@ export function AgentWorkflowSettingsClient({
</div>
</div>
);
})}
</CardContent>
})}
</div>
</CardContent>
</Card>
</motion.div>

View File

@ -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<string, any>();
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<string, { model: any; provider: any }>();
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<string>();
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<string> => {
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<string> => {
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
);
}

View File

@ -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,

View File

@ -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 }
);
}
}

View File

@ -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,

View File

@ -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:

View File

@ -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([]);

View File

@ -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),
};

View File

@ -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<string, any>;
defaultParams?: Record<string, any>;
imageGenConfig?: Record<string, any>;
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,
},
];

View File

@ -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<string, Record<string, FalImageSize>> = {
"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<string, any> = {
prompt,
num_images: imageCount,
aspect_ratio: aspectRatio,
resolution,
output_format: outputFormat,
safety_tolerance: safetyTolerance,
};
let input: Record<string, any>;
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,

View File

@ -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<string, unknown>)
: {};
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 };
}

View File

@ -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<number> {
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;

View File

@ -0,0 +1,2 @@
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "agentWorkflowDefaultModelId" varchar(128);

View File

@ -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
}
]
}

View File

@ -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: "立即升级",

View File

@ -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(

View File

@ -130,6 +130,7 @@ export interface AgentWorkflowBusinessModeConfig {
iconKey: AgentWorkflowBusinessModeIconKey;
customSvgUrl?: string | null;
sortOrder: number;
defaultModelId?: string | null;
referenceReplaceEnabled?: boolean;
aiDetailGenerateEnabled?: boolean;
}