feat: support cross-provider image failover and workflow model settings
This commit is contained in:
parent
b8a59fe2a4
commit
2108d06715
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
130
app/api/admin/fal-image-models/route.ts
Normal file
130
app/api/admin/fal-image-models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
|
||||
78
lib/ai/fal-image-models.ts
Normal file
78
lib/ai/fal-image-models.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
|
||||
55
lib/canvas/image-task-config.ts
Normal file
55
lib/canvas/image-task-config.ts
Normal 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 };
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
ALTER TABLE "SiteSettings"
|
||||
ADD COLUMN IF NOT EXISTS "agentWorkflowDefaultModelId" varchar(128);
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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: "立即升级",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -130,6 +130,7 @@ export interface AgentWorkflowBusinessModeConfig {
|
||||
iconKey: AgentWorkflowBusinessModeIconKey;
|
||||
customSvgUrl?: string | null;
|
||||
sortOrder: number;
|
||||
defaultModelId?: string | null;
|
||||
referenceReplaceEnabled?: boolean;
|
||||
aiDetailGenerateEnabled?: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user