merge: sync settings updates into main

This commit is contained in:
root 2026-06-01 12:05:39 +08:00
commit 25a1a005a4
24 changed files with 4781 additions and 977 deletions

View File

@ -0,0 +1,30 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { usePathname } from "next/navigation";
import { TopNavbar } from "@/components/top-navbar";
type AdminLayoutShellProps = ComponentProps<typeof TopNavbar> & {
children: ReactNode;
};
function shouldHideSidebarChrome(pathname: string) {
return pathname === "/settings" || pathname.startsWith("/settings/");
}
export function AdminLayoutShell({
children,
...topNavbarProps
}: AdminLayoutShellProps) {
const pathname = usePathname();
const hideSidebarChrome = shouldHideSidebarChrome(pathname);
return (
<div className="flex min-h-screen flex-col bg-background">
{hideSidebarChrome ? null : <TopNavbar {...topNavbarProps} />}
<div className={hideSidebarChrome ? "flex-1" : "flex-1 pl-16"}>
{children}
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { auth } from "@/app/(auth)/auth";
import { TopNavbar } from "@/components/top-navbar";
import { getSiteSettings, getNavLinks, getTutorialVideos } from "@/lib/db/queries";
import { AdminLayoutShell } from "./_components/admin-layout-shell";
export default async function AdminLayout({
children,
@ -21,38 +21,37 @@ export default async function AdminLayout({
: null;
return (
<div className="flex min-h-screen flex-col bg-background">
<TopNavbar
siteName={siteSettings.siteName}
siteLogo={siteSettings.siteLogo || undefined}
siteLogoDark={siteSettings.siteLogoDark || undefined}
isAdmin={session?.user?.type === "admin" || session?.user?.type === "agent_admin"}
settingsHref={settingsHref}
customLinks={navLinks.map((link) => ({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
toolboxGroup: link.toolboxGroup || 1,
}))}
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideos={tutorialVideos.map(v => ({
id: v.id,
title: v.title,
videoUrl: v.videoUrl,
coverUrl: v.coverUrl,
description: v.description,
order: v.order,
}))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true}
/>
<div className="flex-1 pl-16">{children}</div>
</div>
<AdminLayoutShell
siteName={siteSettings.siteName}
siteLogo={siteSettings.siteLogo || undefined}
siteLogoDark={siteSettings.siteLogoDark || undefined}
isAdmin={session?.user?.type === "admin" || session?.user?.type === "agent_admin"}
settingsHref={settingsHref}
customLinks={navLinks.map((link) => ({
id: link.id,
name: link.name,
url: link.url,
order: link.order,
toolboxGroup: link.toolboxGroup || 1,
}))}
toolbox1Name={siteSettings.toolbox1Name || "工具箱"}
toolbox1Enabled={siteSettings.toolbox1Enabled ?? true}
toolbox2Name={siteSettings.toolbox2Name || "工具箱2"}
toolbox2Enabled={siteSettings.toolbox2Enabled ?? false}
tutorialVideos={tutorialVideos.map((v) => ({
id: v.id,
title: v.title,
videoUrl: v.videoUrl,
coverUrl: v.coverUrl,
description: v.description,
order: v.order,
}))}
tutorialEnabled={siteSettings.tutorialEnabled ?? false}
rankingEnabled={siteSettings.rankingEnabled ?? true}
productPlanningEnabled={(siteSettings as any).productPlanningEnabled ?? true}
videoCanvasEnabled={(siteSettings as any).videoCanvasEnabled ?? true}
>
{children}
</AdminLayoutShell>
);
}

View File

@ -1,90 +1,155 @@
"use client";
import Link from "next/link";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation";
import { SettingStatusBadge } from "./setting-status-badge";
import type { AdminSettingsNavItem } from "../_lib/types";
const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items);
function SettingsQuickAction({
activeTab,
item,
onTabChange,
stretched = false,
}: {
activeTab: string;
item: AdminSettingsNavItem;
onTabChange: (value: string) => void;
stretched?: boolean;
}) {
const Icon = item.icon;
const className =
item.value === activeTab
? "border-foreground/10 bg-foreground text-background shadow-sm hover:!bg-foreground/90 hover:!text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:!bg-white/90 dark:hover:!text-black"
: "border-transparent bg-transparent text-muted-foreground hover:!border-border/80 hover:!bg-zinc-100 hover:!text-foreground dark:hover:!bg-zinc-900/50";
const layoutClassName = stretched
? "h-10 w-full min-w-0 justify-center gap-2 rounded-2xl border px-4"
: "h-9 shrink-0 gap-2 rounded-full border px-3.5";
if (item.href) {
return (
<Button
asChild
className={`${layoutClassName} ${className}`}
size="sm"
variant="ghost"
>
<Link href={item.href}>
<Icon className="h-4 w-4" />
<span className={stretched ? "truncate" : ""}>{item.label}</span>
</Link>
</Button>
);
}
export function AdminSettingsNavigation({ activeTab }: { activeTab: string }) {
return (
<div className="rounded-xl border bg-background">
<div className="border-b bg-muted/20 px-5 py-4">
<div className="flex flex-col gap-1">
<div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
Admin Console
</div>
<h1 className="font-semibold text-2xl tracking-tight">
系统设置中心
</h1>
<p className="max-w-4xl text-muted-foreground text-sm leading-6">
按后台人员的操作路径重新组织入口:先处理运营数据,再调整站点体验,最后管理模型与基础设施能力。
</p>
<Button
className={`${layoutClassName} ${className}`}
onClick={() => onTabChange(item.value)}
size="sm"
variant="ghost"
>
<Icon className="h-4 w-4" />
<span className={stretched ? "truncate" : ""}>{item.label}</span>
</Button>
);
}
export function AdminSettingsNavigation({
activeTab,
onTabChange,
}: {
activeTab: string;
onTabChange: (value: string) => void;
}) {
const router = useRouter();
const activeItem = ALL_SETTINGS_ITEMS.find((item) => item.value === activeTab);
const handleSelectChange = (value: string) => {
const nextItem = ALL_SETTINGS_ITEMS.find((item) => item.value === value);
if (nextItem?.href) {
router.push(nextItem.href);
return;
}
onTabChange(value);
};
return (
<div className="rounded-2xl border bg-background px-4 py-3 shadow-sm">
<div className="flex flex-col gap-2 md:hidden">
<div className="flex items-center gap-2">
<Select onValueChange={handleSelectChange} value={activeTab}>
<SelectTrigger className="h-9 flex-1 border-border/70 bg-background shadow-none">
<SelectValue placeholder={activeItem?.label || "选择设置项"} />
</SelectTrigger>
<SelectContent>
{ALL_SETTINGS_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
asChild
className="h-9 rounded-full border-border/70 px-3 text-[13px]"
size="sm"
variant="outline"
>
<Link href="/">
<ArrowLeft className="h-3.5 w-3.5" />
返回
</Link>
</Button>
</div>
<div className="flex flex-wrap gap-1">
{ALL_SETTINGS_ITEMS.map((item) => (
<SettingsQuickAction
activeTab={activeTab}
item={item}
key={item.value}
onTabChange={onTabChange}
/>
))}
</div>
</div>
<div className="grid gap-px bg-border lg:grid-cols-3">
{ADMIN_SETTINGS_NAV_GROUPS.map((group) => {
const hasActiveTab = group.items.some(
(item) => item.value === activeTab
);
return (
<section className="bg-background p-4" key={group.title}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="font-semibold text-sm">{group.title}</h2>
<p className="text-muted-foreground text-xs leading-5">
{group.description}
</p>
</div>
{hasActiveTab ? (
<SettingStatusBadge tone="enabled">当前</SettingStatusBadge>
) : null}
</div>
<TabsList className="grid h-auto w-full grid-cols-1 gap-1 bg-transparent p-0">
{group.items.map((item) => {
const Icon = item.icon;
const triggerClassName =
"h-auto justify-start gap-2 rounded-md border border-transparent px-3 py-2 text-left data-[state=active]:border-border data-[state=active]:bg-muted/50";
const content = (
<>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block truncate font-medium text-sm">
{item.label}
</span>
<span className="block truncate font-normal text-[11px] text-muted-foreground">
{item.description}
</span>
</span>
</>
);
if (item.href) {
return (
<TabsTrigger
asChild
className={triggerClassName}
key={item.value}
value={item.value}
>
<Link href={item.href}>{content}</Link>
</TabsTrigger>
);
}
<div className="hidden min-w-0 items-center gap-3 md:flex">
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
{ALL_SETTINGS_ITEMS.map((item) => (
<div className="min-w-[118px] flex-1" key={item.value}>
<SettingsQuickAction
activeTab={activeTab}
item={item}
onTabChange={onTabChange}
stretched
/>
</div>
))}
</div>
return (
<TabsTrigger
className={triggerClassName}
key={item.value}
value={item.value}
>
{content}
</TabsTrigger>
);
})}
</TabsList>
</section>
);
})}
<Button
asChild
className="h-9 shrink-0 rounded-full border-border/70 px-3.5 text-[13px]"
size="sm"
variant="outline"
>
<Link href="/">
<ArrowLeft className="h-3.5 w-3.5" />
返回工作台
</Link>
</Button>
</div>
</div>
);

View File

@ -5,13 +5,17 @@ import {
ArrowUp,
Check,
Layers,
Loader2,
Menu,
Move3D,
Plus,
RefreshCw,
Save,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -26,6 +30,10 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
AI_WASH_CONTROL_ORDER,
type AiWashControlId,
} from "@/lib/ai-wash/config";
import {
ENHANCE_PRO_KREA_DEFAULT_BASE_URL,
ENHANCE_PRO_KREA_DEFAULT_MODEL,
@ -45,6 +53,7 @@ import {
} from "../local-ai-section-shell";
export function LocalAiImagePipelineSections({
modelsData,
rmbgConfig,
setRmbgConfig,
enhanceConfig,
@ -63,6 +72,9 @@ export function LocalAiImagePipelineSections({
selectedEnhanceProPreset,
selectedEnhanceProPresetIndex,
draggingEnhanceProPresetId,
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
@ -87,11 +99,26 @@ export function LocalAiImagePipelineSections({
handleEnhanceProPresetDragEnd,
handleEnhanceProPresetDrop,
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
handleSaveLayerSplitConfig,
}: LocalAiImagePipelineSectionsProps) {
const availableImageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen && model.isEnabled && !model.isHidden
);
const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
if (controlId === "creativity") {
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
}
if (controlId === "detail") {
return "每档可以补充默认提示词,决定细节精修的强弱。";
}
return "每档可以补充默认提示词,决定光影、氛围与空间塑造。";
};
return (
<>
{/* 抠图API配置 */}
@ -1385,8 +1412,775 @@ export function LocalAiImagePipelineSections({
</div>
</div>
<Button onClick={handleSaveEnhanceProConfig}>
<Save className="mr-2 h-4 w-4" /> 保存图像增强 Pro 配置
<Button onClick={handleSaveEnhanceProConfig}>
<Save className="mr-2 h-4 w-4" /> 保存图像增强 Pro 配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布“AI洗图”子页面使用的生图模型与提示词。前台会基于同一张原图并行生成多组洗图结果,并支持拖动对比。
</>
}
expanded={isLocalAiSectionExpanded("ai-wash")}
id="local-ai-section-ai-wash"
onToggle={() => toggleLocalAiSection("ai-wash")}
summary={
<LocalAiSectionSummary
items={[
{
label: aiWashConfig.enabled ? "功能启用" : "功能关闭",
tone: aiWashConfig.enabled ? "enabled" : "disabled",
},
{
label: `${aiWashConfig.presets.filter((preset) => preset.enabled).length} 个启用预设`,
tone:
aiWashConfig.presets.some(
(preset) =>
preset.enabled && preset.modelId.trim() && preset.prompt.trim()
)
? "enabled"
: "warning",
},
{
label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral",
},
]}
/>
}
title="AI洗图配置"
>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用 AI洗图</Label>
<div className="text-muted-foreground text-sm">
开启后,图像增强模式顶部会新增一个“AI洗图”入口,用户可上传底图并批量对比洗图结果。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={aiWashConfig.enabled}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{aiWashConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-3 rounded-xl border bg-muted/10 p-4 md:col-span-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="font-medium text-sm">前台入口名称与图标</div>
<div className="text-muted-foreground text-xs">
会同步到图像增强顶部的 AI洗图模式按钮,也会用于洗图工作区标题。
</div>
</div>
<div className="flex items-center gap-1">
<Button
className="h-8"
disabled={aiWashModeIconUploading}
onClick={() =>
document.getElementById("ai-wash-mode-icon-upload")?.click()
}
size="sm"
type="button"
variant="outline"
>
{aiWashModeIconUploading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
上传中
</>
) : (
<>
<Upload className="mr-1.5 h-3.5 w-3.5" />
上传 SVG
</>
)}
</Button>
{aiWashConfig.modeIconUrl ? (
<Button
className="h-8 px-2 text-muted-foreground"
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
modeIconUrl: "",
}))
}
size="sm"
type="button"
variant="ghost"
>
清除
</Button>
) : null}
</div>
<input
accept=".svg,image/svg+xml"
className="hidden"
id="ai-wash-mode-icon-upload"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await handleAiWashModeIconUpload(file);
}
event.target.value = "";
}}
type="file"
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-2">
<Label htmlFor="aiWashModeLabel">前台入口名称</Label>
<Input
id="aiWashModeLabel"
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
modeLabel: event.target.value,
}))
}
placeholder="AI洗图"
value={aiWashConfig.modeLabel}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">图标预览</Label>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] bg-[#111111] px-3 font-medium text-white text-xs">
<CanvasToolbarIcon
active
className="h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashConfig.modeIconUrl}
/>
{aiWashConfig.modeLabel || "AI洗图"}
</div>
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] border bg-white px-3 font-medium text-[#666666] text-xs">
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashConfig.modeIconUrl}
/>
{aiWashConfig.modeLabel || "AI洗图"}
</div>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="aiWashImageSize">输出档位</Label>
<Select
onValueChange={(value) =>
setAiWashConfig((prev) => ({
...prev,
imageSize: value,
}))
}
value={aiWashConfig.imageSize}
>
<SelectTrigger id="aiWashImageSize">
<SelectValue placeholder="选择出图档位" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1K">1K</SelectItem>
<SelectItem value="2K">2K</SelectItem>
<SelectItem value="3K">3K</SelectItem>
<SelectItem value="4K">4K</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="aiWashHistoryLimit">历史展示条数</Label>
<Input
id="aiWashHistoryLimit"
min={1}
max={24}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
historyLimit: Number(event.target.value || 8),
}))
}
type="number"
value={aiWashConfig.historyLimit}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium text-sm">启用提示词扩写</div>
<div className="text-muted-foreground text-xs">
会将预设提示词交给生图供应商做额外 prompt enhance。
</div>
</div>
<Switch
checked={aiWashConfig.promptExtend}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
promptExtend: checked,
}))
}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">洗图预设</div>
<div className="text-muted-foreground text-xs">
前台会横向展示启用中的预设结果。建议至少配置 2 到 3 个不同风格的结果卡位。
</div>
</div>
<div className="rounded-full border px-2.5 py-1 text-xs text-muted-foreground">
共 {aiWashConfig.presets.length} 个预设
</div>
</div>
<div className="space-y-4">
{aiWashConfig.presets.map((preset, index) => (
<div className="rounded-xl border p-4" key={preset.id || `ai-wash-${index}`}>
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="font-medium text-sm">{preset.title || `预设 ${index + 1}`}</div>
<div className="text-muted-foreground text-xs">
这个卡位会在前台结果区展示成一张独立的可拖动对比卡片。
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => ({
...item,
isDefault: itemIndex === index,
})),
}))
}
size="sm"
type="button"
variant={preset.isDefault ? "default" : "outline"}
>
{preset.isDefault ? "默认卡位" : "设为默认"}
</Button>
<div className="flex items-center gap-2 rounded-full border px-3 py-1.5">
<Switch
checked={preset.enabled}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, enabled: checked }
: item
),
}))
}
/>
<span className="text-sm text-muted-foreground">
{preset.enabled ? "启用" : "停用"}
</span>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`aiWashPresetTitle-${index}`}>预设标题</Label>
<Input
id={`aiWashPresetTitle-${index}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, title: event.target.value }
: item
),
}))
}
placeholder="例如:质感提升(细节更精准)"
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`aiWashPresetModel-${index}`}>
固定模型(留空则前台可选)
</Label>
<Select
onValueChange={(value) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? {
...item,
modelId: value === "__none__" ? "" : value,
}
: item
),
}))
}
value={preset.modelId || "__none__"}
>
<SelectTrigger id={`aiWashPresetModel-${index}`}>
<SelectValue placeholder="留空时前台允许用户选择模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">前台用户自行选择</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{model.displayName || model.modelId}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
如果这里指定了模型,前台默认不再展示生图模型下拉;创造力档位里若再指定模型,会以档位模型优先。
</div>
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
<div className="space-y-2">
<Label htmlFor={`aiWashPresetDescription-${index}`}>结果说明</Label>
<Textarea
id={`aiWashPresetDescription-${index}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, description: event.target.value }
: item
),
}))
}
placeholder="用于前台解释这一张结果图更偏什么风格。"
rows={3}
value={preset.description}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`aiWashPresetPrompt-${index}`}>洗图提示词</Label>
<Textarea
id={`aiWashPresetPrompt-${index}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, prompt: event.target.value }
: item
),
}))
}
placeholder="描述这一张卡位的洗图方向,例如细节强化、材质提升、AI 渲染化。"
rows={6}
value={preset.prompt}
/>
</div>
</div>
<div className="mt-4 space-y-4 rounded-xl border border-dashed p-4">
<div>
<div className="font-medium text-sm">三段调节条</div>
<div className="text-muted-foreground text-xs">
前台每个模式都会显示这 3 个滑条。每一档都能单独配置,数量也可以继续扩展,不限死 3 档。
</div>
</div>
<div className="space-y-4">
{AI_WASH_CONTROL_ORDER.map((controlId) => {
const control = preset.controls.find(
(item) => item.id === controlId
);
if (!control) return null;
return (
<div className="rounded-xl border p-4" key={`${preset.id}-${control.id}`}>
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="font-medium text-sm">
{control.label || control.id}
</div>
<div className="text-muted-foreground text-xs">
{control.helperText || getAiWashControlToneCopy(control.id)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-full border px-2.5 py-1 text-xs text-muted-foreground">
当前 {control.levels.length} 档
</div>
<Button
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: [
...currentControl.levels,
{
id: `${currentControl.id}-level-${currentControl.levels.length + 1}`,
label: String(currentControl.levels.length + 1),
modelId: "",
promptFragment: "",
},
],
};
}),
};
}),
}))
}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1 h-3.5 w-3.5" />
增加档位
</Button>
<Button
disabled={control.levels.length <= 1}
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
const nextLevels = currentControl.levels.slice(0, -1);
return {
...currentControl,
levels: nextLevels,
defaultLevel: Math.min(
currentControl.defaultLevel,
Math.max(0, nextLevels.length - 1)
),
};
}),
};
}),
}))
}
size="sm"
type="button"
variant="ghost"
>
删除最后一档
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div className="space-y-2">
<Label htmlFor={`aiWashControlLabel-${index}-${control.id}`}>
前台显示名
</Label>
<Input
id={`aiWashControlLabel-${index}-${control.id}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) =>
currentControl.id === control.id
? {
...currentControl,
label: event.target.value,
}
: currentControl
),
};
}),
}))
}
value={control.label}
/>
<Textarea
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) =>
currentControl.id === control.id
? {
...currentControl,
helperText: event.target.value,
}
: currentControl
),
};
}),
}))
}
placeholder={getAiWashControlToneCopy(control.id)}
rows={4}
value={control.helperText}
/>
</div>
<div className="space-y-3">
{control.levels.map((level, levelIndex) => (
<div className="rounded-lg border bg-muted/20 p-3" key={level.id}>
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<span className="rounded-full border bg-background px-2.5 py-1 text-xs font-medium">
档位 {levelIndex + 1}
</span>
<span className="text-muted-foreground text-xs">
前台滑块会把这个标签显示成当前档位值
</span>
</div>
<div className="flex items-center gap-2">
<Label
className="text-muted-foreground text-xs"
htmlFor={`aiWashControlDefault-${index}-${control.id}-${levelIndex}`}
>
默认
</Label>
<Switch
checked={control.defaultLevel === levelIndex}
onCheckedChange={(checked) => {
if (!checked) return;
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) =>
currentControl.id === control.id
? {
...currentControl,
defaultLevel: levelIndex,
}
: currentControl
),
};
}),
}));
}}
/>
</div>
</div>
<div
className={cn(
"grid gap-3",
control.kind === "model"
? "lg:grid-cols-[150px_280px_1fr]"
: "lg:grid-cols-[150px_1fr]"
)}
>
<div className="space-y-2">
<Label
htmlFor={`aiWashLevelLabel-${index}-${control.id}-${levelIndex}`}
>
档位标签
</Label>
<Input
id={`aiWashLevelLabel-${index}-${control.id}-${levelIndex}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: currentControl.levels.map(
(currentLevel, currentLevelIndex) =>
currentLevelIndex === levelIndex
? {
...currentLevel,
label: event.target.value,
}
: currentLevel
),
};
}),
};
}),
}))
}
value={level.label}
/>
</div>
{control.kind === "model" ? (
<div className="space-y-2">
<Label
htmlFor={`aiWashLevelModel-${index}-${control.id}-${levelIndex}`}
>
指定模型
</Label>
<Select
onValueChange={(value) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: currentControl.levels.map(
(currentLevel, currentLevelIndex) =>
currentLevelIndex === levelIndex
? {
...currentLevel,
modelId:
value === "__none__"
? ""
: value,
}
: currentLevel
),
};
}),
};
}),
}))
}
value={level.modelId || "__none__"}
>
<SelectTrigger
id={`aiWashLevelModel-${index}-${control.id}-${levelIndex}`}
>
<SelectValue placeholder="留空时走固定模型或前台用户选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
不指定,允许下游继承
</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem
key={model.id}
value={model.modelId}
>
{model.displayName || model.modelId}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型
</SelectItem>
)}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-2">
<Label
htmlFor={`aiWashLevelPrompt-${index}-${control.id}-${levelIndex}`}
>
追加提示词
</Label>
<Textarea
id={`aiWashLevelPrompt-${index}-${control.id}-${levelIndex}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: currentControl.levels.map(
(currentLevel, currentLevelIndex) =>
currentLevelIndex === levelIndex
? {
...currentLevel,
promptFragment:
event.target.value,
}
: currentLevel
),
};
}),
};
}),
}))
}
placeholder={
control.kind === "model"
? "可选:这一档除了切换模型,还可以额外加一段提示词。"
: "例如:强化微小纹理、让边缘更锐利、增强体积光层次。"
}
rows={4}
value={level.promptFragment}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
</div>
<Button onClick={handleSaveAiWashConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 AI洗图配置
</Button>
</LocalAiSectionShell>

View File

@ -12,11 +12,13 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
rmbg: false,
enhance: false,
"enhance-pro": false,
"ai-wash": false,
extend: false,
"watermark-remove": false,
"angle-edit": false,
"layer-split": false,
"third-party-sqlserver": false,
"external-image-api": true,
"realtime-canvas": false,
"canvas-image-task-queue": false,
"batch-process": false,

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
import type { AiWashConfig } from "@/lib/ai-wash/config";
import type {
EnhanceProKreaConfig,
EnhanceProKreaModel,
@ -297,6 +298,7 @@ export type LayerSplitConfig = {
};
export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
modelsData?: AdminModelsData;
rmbgConfig: RmbgConfig;
setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>;
enhanceConfig: EnhanceConfig;
@ -317,6 +319,9 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
selectedEnhanceProPreset: EnhanceProPreset | null | undefined;
selectedEnhanceProPresetIndex: number;
draggingEnhanceProPresetId: string;
aiWashConfig: AiWashConfig;
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
aiWashModeIconUploading: boolean;
extendConfig: ExtendConfig;
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
watermarkRemoveConfig: WatermarkRemoveConfig;
@ -352,6 +357,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
targetPresetId: string
) => void;
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
handleAiWashModeIconUpload: (file: File) => Promise<void>;
handleSaveAiWashConfig: AsyncAction;
handleSaveExtendConfig: AsyncAction;
handleSaveWatermarkRemoveConfig: AsyncAction;
handleSaveAngleEditConfig: AsyncAction;

File diff suppressed because it is too large Load Diff

View File

@ -538,13 +538,17 @@ export default function GalleryPage() {
/>
</div>
</div>
<div className="p-4 border-t bg-background">
<div className="border-t bg-background p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="font-medium">{selectedImage.alt}</p>
<p className="text-sm text-muted-foreground mt-1">
{formatBeijingTime(selectedImage.createdAt)}
</p>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-medium text-foreground">
模型 ID: {selectedImage.modelId || "未知模型"}
</span>
<span className="text-muted-foreground">
{formatBeijingTime(selectedImage.createdAt)}
</span>
</div>
</div>
<Button
onClick={() => handleDownload(selectedImage.url, selectedImage.alt)}

View File

@ -1,9 +1,229 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { generatedImage, user, aiModel, aiProvider } from "@/lib/db/schema";
import { eq, sql, desc, count, and, gte, lt } from "drizzle-orm";
import { eq, sql, desc, and, gte, lt } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
type RuntimeModelRegistryRow = {
id: string;
modelId: string;
displayName: string;
providerName: string | null;
providerType: string | null;
isEnabled: boolean;
isHidden: boolean;
isDefault: boolean;
isSubModel: boolean;
sortOrder: number;
updatedAt: Date;
createdAt: Date;
};
type RuntimeModelMeta = {
canonicalModelId: string | null;
modelName: string | null;
providerName: string | null;
providerType: string | null;
};
const ANALYTICS_SIZE_BUCKETS = ["4K", "3K", "2K", "1K"] as const;
function normalizeAnalyticsImageSizeBucket(value: string | null | undefined) {
const normalized = String(value || "")
.trim()
.toUpperCase();
if (!normalized) return "2K";
if (normalized.includes("4K")) return "4K";
if (normalized.includes("3K")) return "3K";
if (normalized.includes("2K")) return "2K";
if (normalized.includes("1K")) return "1K";
const sizeMatch = normalized.match(/(\d+(?:\.\d+)?)\s*K/);
if (sizeMatch) {
const sizeValue = Number(sizeMatch[1]);
if (sizeValue >= 3.5) return "4K";
if (sizeValue >= 2.5) return "3K";
if (sizeValue >= 1.5) return "2K";
return "1K";
}
const dimensionMatch = normalized.match(/(\d{3,5})\s*[X×*]\s*(\d{3,5})/);
if (dimensionMatch) {
const maxEdge = Math.max(
Number(dimensionMatch[1]),
Number(dimensionMatch[2])
);
if (maxEdge >= 3500) return "4K";
if (maxEdge >= 2500) return "3K";
if (maxEdge >= 1500) return "2K";
return "1K";
}
const numericMatch = normalized.match(/(\d{3,5})/);
if (numericMatch) {
const numericValue = Number(numericMatch[1]);
if (numericValue >= 3500) return "4K";
if (numericValue >= 2500) return "3K";
if (numericValue >= 1500) return "2K";
}
return "1K";
}
function scoreRuntimeModelCandidate(row: RuntimeModelRegistryRow) {
return (
(row.isEnabled ? 100 : 0) +
(!row.isHidden ? 20 : 0) +
(!row.isSubModel ? 10 : 0) +
(row.isDefault ? 8 : 0) +
Math.max(0, 1000 - row.sortOrder) +
(row.updatedAt?.getTime() || row.createdAt?.getTime() || 0) / 1_000_000_000_000
);
}
function buildRuntimeModelRegistry(rows: RuntimeModelRegistryRow[]) {
const registry = new Map<string, RuntimeModelMeta & { score: number }>();
for (const row of rows) {
const score = scoreRuntimeModelCandidate(row);
const meta = {
score,
canonicalModelId: row.modelId,
modelName: row.displayName || null,
providerName: row.providerName || null,
providerType: row.providerType || null,
};
for (const key of [row.id, row.modelId]) {
const current = registry.get(key);
if (current && current.score >= score) continue;
registry.set(key, meta);
}
}
return registry;
}
function resolveRuntimeModelMeta(
registry: Map<string, RuntimeModelMeta & { score: number }>,
modelId: string | null
): RuntimeModelMeta {
if (!modelId) {
return {
canonicalModelId: null,
modelName: null,
providerName: null,
providerType: null,
};
}
const resolved = registry.get(modelId);
return {
canonicalModelId: resolved?.canonicalModelId || null,
modelName: resolved?.modelName || null,
providerName: resolved?.providerName || null,
providerType: resolved?.providerType || null,
};
}
function buildCanonicalModelUsage(
rows: Array<{ modelId: string | null; userId: string; count: number }>,
registry: Map<string, RuntimeModelMeta & { score: number }>
) {
const usageMap = new Map<
string,
RuntimeModelMeta & {
modelId: string | null;
count: number;
userIds: Set<string>;
}
>();
for (const row of rows) {
const meta = resolveRuntimeModelMeta(registry, row.modelId);
const canonicalModelId = meta.canonicalModelId || row.modelId;
const key = canonicalModelId || row.modelId || "__unknown__";
const current = usageMap.get(key) || {
modelId: canonicalModelId,
canonicalModelId,
modelName: meta.modelName,
providerName: meta.providerName,
providerType: meta.providerType,
count: 0,
userIds: new Set<string>(),
};
current.count += row.count;
current.userIds.add(row.userId);
if (!current.modelName && meta.modelName) current.modelName = meta.modelName;
if (!current.providerName && meta.providerName) {
current.providerName = meta.providerName;
}
if (!current.providerType && meta.providerType) {
current.providerType = meta.providerType;
}
usageMap.set(key, current);
}
return Array.from(usageMap.values())
.map(({ userIds, canonicalModelId, ...item }) => ({
...item,
modelId: item.modelId || canonicalModelId,
userCount: userIds.size,
}))
.sort((left, right) => right.count - left.count);
}
function buildCanonicalModelTrend(
rows: Array<{ date: string; modelId: string | null; count: number }>,
registry: Map<string, RuntimeModelMeta & { score: number }>
) {
const trendMap = new Map<
string,
RuntimeModelMeta & {
date: string;
modelId: string | null;
count: number;
}
>();
for (const row of rows) {
const meta = resolveRuntimeModelMeta(registry, row.modelId);
const canonicalModelId = meta.canonicalModelId || row.modelId;
const key = `${row.date}::${canonicalModelId || row.modelId || "__unknown__"}`;
const current = trendMap.get(key) || {
date: row.date,
modelId: canonicalModelId,
canonicalModelId,
modelName: meta.modelName,
providerName: meta.providerName,
providerType: meta.providerType,
count: 0,
};
current.count += row.count;
if (!current.modelName && meta.modelName) current.modelName = meta.modelName;
if (!current.providerName && meta.providerName) {
current.providerName = meta.providerName;
}
if (!current.providerType && meta.providerType) {
current.providerType = meta.providerType;
}
trendMap.set(key, current);
}
return Array.from(trendMap.values())
.map(({ canonicalModelId, ...item }) => ({
...item,
modelId: item.modelId || canonicalModelId,
}))
.sort(
(left, right) =>
left.date.localeCompare(right.date) || right.count - left.count
);
}
export async function GET(request: NextRequest) {
try {
const session = await auth();
@ -60,7 +280,7 @@ export async function GET(request: NextRequest) {
.orderBy(sql`date(${generatedImage.createdAt})`);
// 4. 按图片尺寸统计
const sizeStats = await db
const rawSizeStats = await db
.select({
imageSize: generatedImage.imageSize,
count: sql<number>`count(*)::int`,
@ -68,50 +288,90 @@ export async function GET(request: NextRequest) {
.from(generatedImage)
.groupBy(generatedImage.imageSize)
.orderBy(desc(sql`count(*)`));
const sizeBucketMap = new Map<string, number>();
for (const stat of rawSizeStats) {
const bucket = normalizeAnalyticsImageSizeBucket(stat.imageSize);
sizeBucketMap.set(bucket, (sizeBucketMap.get(bucket) || 0) + stat.count);
}
const sizeStats = ANALYTICS_SIZE_BUCKETS.map((bucket) => ({
imageSize: bucket,
count: sizeBucketMap.get(bucket) || 0,
}));
// 5. 按模型统计(基础)
const modelStats = await db
const runtimeModelRows = await db
.select({
modelId: generatedImage.modelId,
count: sql<number>`count(*)::int`,
})
.from(generatedImage)
.groupBy(generatedImage.modelId)
.orderBy(desc(sql`count(*)`))
.limit(10);
// 5.1 按模型详细统计(包含模型名称和提供商信息)
const modelDetailStats = await db
.select({
modelId: generatedImage.modelId,
count: sql<number>`count(*)::int`,
userCount: sql<number>`count(distinct ${generatedImage.userId})::int`,
modelName: aiModel.displayName,
id: aiModel.id,
modelId: aiModel.modelId,
displayName: aiModel.displayName,
providerName: aiProvider.name,
providerType: aiProvider.type,
isEnabled: aiModel.isEnabled,
isHidden: aiModel.isHidden,
isDefault: aiModel.isDefault,
isSubModel: aiModel.isSubModel,
sortOrder: aiModel.sortOrder,
updatedAt: aiModel.updatedAt,
createdAt: aiModel.createdAt,
})
.from(aiModel)
.leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id));
const runtimeModelRegistry = buildRuntimeModelRegistry(runtimeModelRows);
// 5. 按模型统计
const rawModelUsageRows = await db
.select({
modelId: generatedImage.modelId,
userId: generatedImage.userId,
count: sql<number>`count(*)::int`,
})
.from(generatedImage)
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
.leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
.groupBy(generatedImage.modelId, aiModel.displayName, aiProvider.name, aiProvider.type)
.orderBy(desc(sql`count(*)`))
.limit(20);
.groupBy(generatedImage.modelId, generatedImage.userId);
const canonicalModelStats = buildCanonicalModelUsage(
rawModelUsageRows,
runtimeModelRegistry
);
const modelStats = canonicalModelStats.slice(0, 10).map((stat) => ({
modelId: stat.modelId,
count: stat.count,
}));
const modelDetailStats = canonicalModelStats.slice(0, 20).map((stat) => ({
modelId: stat.modelId,
count: stat.count,
userCount: stat.userCount,
modelName: stat.modelName,
providerName: stat.providerName,
providerType: stat.providerType,
}));
// 5.2 按提供商类型统计
const providerTypeStats = await db
.select({
providerType: aiProvider.type,
providerName: aiProvider.name,
count: sql<number>`count(*)::int`,
})
.from(generatedImage)
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
.leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id))
.groupBy(aiProvider.type, aiProvider.name)
.orderBy(desc(sql`count(*)`));
const providerTypeStatsMap = new Map<
string,
{
providerType: string | null;
providerName: string | null;
count: number;
}
>();
for (const stat of canonicalModelStats) {
const key = `${stat.providerType || "unknown"}::${stat.providerName || "unknown"}`;
const current = providerTypeStatsMap.get(key) || {
providerType: stat.providerType,
providerName: stat.providerName,
count: 0,
};
current.count += stat.count;
providerTypeStatsMap.set(key, current);
}
const providerTypeStats = Array.from(providerTypeStatsMap.values()).sort(
(left, right) => right.count - left.count
);
// 5.3 模型使用趋势(最近7天,按模型分组)
const modelTrendStats = await db
const rawModelTrendStats = await db
.select({
date: sql<string>`date(${generatedImage.createdAt})::text`,
modelId: generatedImage.modelId,
@ -121,6 +381,10 @@ export async function GET(request: NextRequest) {
.where(sql`${generatedImage.createdAt} >= now() - interval '7 days'`)
.groupBy(sql`date(${generatedImage.createdAt})`, generatedImage.modelId)
.orderBy(sql`date(${generatedImage.createdAt})`);
const modelTrendStats = buildCanonicalModelTrend(
rawModelTrendStats,
runtimeModelRegistry
);
// 6. 按月份统计
const monthlyStats = await db
@ -227,4 +491,3 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Failed to fetch analytics" }, { status: 500 });
}
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import {
AI_WASH_CONFIG_KEY,
normalizeAiWashConfig,
sanitizeAiWashConfigForClient,
} from "@/lib/ai-wash/config";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
toPublicStyleProModelOption,
} from "@/lib/style-pro/model-options";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("ai-wash-config-api");
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
}
const setting = await getSystemSetting({ key: AI_WASH_CONFIG_KEY });
const config = normalizeAiWashConfig(setting?.value);
const lockedModelRefs = Array.from(
new Set(
config.presets
.flatMap((preset) => [
preset.modelId,
...preset.controls.flatMap((control) =>
control.levels.map((level) => level.modelId)
),
])
.map((item) => String(item || "").trim())
.filter(Boolean)
)
);
const modelEntries =
lockedModelRefs.length > 0
? await getStyleProImageModelEntries({ includeHidden: true })
: [];
const lockedModels = lockedModelRefs
.map((modelRef) => findStyleProImageModelByRef(modelEntries, modelRef))
.filter(Boolean)
.map((entry) => toPublicStyleProModelOption(entry!));
return NextResponse.json({
success: true,
config: sanitizeAiWashConfigForClient(config),
lockedModels,
});
} catch (error) {
logger.error("config_fetch_failed", error);
return NextResponse.json({ error: "获取 AI洗图 配置失败" }, { status: 500 });
}
}

View File

@ -0,0 +1,133 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import {
createCanvasImageTask,
enqueueCanvasImageTask,
getCanvasImageTaskForUser,
} from "@/lib/canvas/image-task-queue";
import { type CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
} from "@/lib/style-pro/model-options";
const requestSchema = z.object({
prompt: z.string().min(1).max(5000),
referenceImage: z.string().min(1).max(200000),
selectedModelId: z.string().min(1).max(128),
imageSize: z.string().min(1).max(16).optional(),
promptExtend: z.boolean().optional(),
aspectRatio: z.string().min(1).max(16).optional(),
title: z.string().max(128).optional(),
});
export const dynamic = "force-dynamic";
export const maxDuration = 120;
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const body = requestSchema.parse(await request.json());
const imageModelEntries = await getStyleProImageModelEntries({
includeHidden: true,
});
const selectedModel = findStyleProImageModelByRef(
imageModelEntries,
body.selectedModelId
);
if (!selectedModel) {
return NextResponse.json(
{ success: false, error: "所选 AI洗图 生图模型不可用" },
{ status: 400 }
);
}
const imageSize = body.imageSize?.trim() || "2K";
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
imageSize,
});
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost,
reason: "ai_wash_generation",
modelId: selectedModel.model.id,
note: `AI洗图消耗积分(${body.title || selectedModel.model.displayName})`,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, {
status: pointsCharge.status,
});
}
const payload: CanvasGenerateImageRequest = {
prompt: body.prompt.trim(),
model: selectedModel.model.id,
referenceImage: body.referenceImage.trim(),
imageSize,
imageCount: 1,
promptExtend: body.promptExtend === true,
aspectRatio: body.aspectRatio?.trim() || "auto",
ownerKey: session.user.id,
debugLabel: "ai-wash",
debugMeta: {
selectedModelId: selectedModel.model.id,
selectedModelName: selectedModel.model.displayName,
title: body.title || "",
},
};
const task = await createCanvasImageTask({
userId: session.user.id,
request: payload as unknown as Record<string, unknown>,
});
await enqueueCanvasImageTask(task.id);
const currentTask = await getCanvasImageTaskForUser({
taskId: task.id,
userId: session.user.id,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume({
note: `AI洗图消耗积分(${body.title || selectedModel.model.displayName} / ${task.id})`,
});
} catch (pointsError) {
console.error("[AI Wash] Failed to consume points:", pointsError);
}
}
return NextResponse.json({
success: true,
taskId: task.id,
status: currentTask?.status || task.status,
queueStatus: currentTask?.queueStatus,
unitCost,
modelId: selectedModel.model.id,
modelName: selectedModel.model.displayName,
});
} catch (error) {
console.error("[AI Wash Generate] Failed:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: error.issues[0]?.message || "请求参数错误" },
{ status: 400 }
);
}
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "AI洗图失败" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
getStyleProImageModelDisplayEntries,
toPublicStyleProModelOption,
} from "@/lib/style-pro/model-options";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: "未登录" },
{ status: 401 }
);
}
const entries = await getStyleProImageModelDisplayEntries({
includeHidden: false,
});
const defaultEntry =
entries.find((entry) => entry.model.isDefault) || entries[0] || null;
return NextResponse.json({
success: true,
defaultModelId: defaultEntry?.model.id || null,
models: entries.map(toPublicStyleProModelOption),
});
} catch (error) {
console.error("[AI Wash Model Options] Failed:", error);
return NextResponse.json(
{ success: false, error: "获取 AI洗图 生图模型失败" },
{ status: 500 }
);
}
}

View File

@ -328,7 +328,6 @@ async function createEnhanceProJob(params: {
width: params.width,
height: params.height,
image_url: params.sourceImageUrl,
batchSize: params.requestBody.batchSize,
seed: params.requestBody.seed,
prompt: String(params.requestBody.prompt || "").trim(),
output_format: params.requestBody.output_format || params.config.defaultOutputFormat,
@ -1065,9 +1064,8 @@ export async function POST(request: NextRequest) {
config.maxOutputEdge,
);
const batchSize = config.allowBatchSize
? clampInteger(body.batchSize, 1, config.maxBatchSize, 1)
: 1;
// Krea removed batch generation from the enhance endpoint.
const batchSize = 1;
const upscalingActivated = body.upscaling_activated === true;
const imageScalingFactor = clampNumber(
body.image_scaling_factor,

View File

@ -129,6 +129,10 @@ import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
} from "@/lib/enhance-pro/config";
import {
AI_WASH_CONFIG_KEY,
normalizeAiWashConfig,
} from "@/lib/ai-wash/config";
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
import { withTimeout } from "@/lib/public-api/performance";
import type { AgentCategoryTreeConfig } from "@/types/agent";
@ -205,12 +209,16 @@ async function readCanvasFeatureAvailability(valueByKey: Map<string, unknown>) {
]);
return {
aiWashIconUrl: normalizeAiWashConfig(valueByKey.get(AI_WASH_CONFIG_KEY))
.modeIconUrl,
angleEditEnabled: normalizeAngleEditEnabled(
valueByKey.get(ANGLE_EDIT_CONFIG_KEY),
),
enhanceProEnabled: normalizeEnhanceProKreaConfig(
valueByKey.get(ENHANCE_PRO_KREA_SETTING_KEY),
).enabled,
aiWashEnabled: normalizeAiWashConfig(valueByKey.get(AI_WASH_CONFIG_KEY)).enabled,
aiWashLabel: normalizeAiWashConfig(valueByKey.get(AI_WASH_CONFIG_KEY)).modeLabel,
tripo3dEnabled: enabledTripoProviders.length > 0,
worldlabs3dEnabled: enabledWorldlabsProviders.length > 0,
};
@ -691,6 +699,7 @@ async function getScopedPublicSiteSettings(
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
AI_WASH_CONFIG_KEY,
STYLE_PRO_CONFIG_KEY,
AGENT_WORKFLOW_UI_CONFIG_KEY,
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
@ -730,6 +739,9 @@ async function getScopedPublicSiteSettings(
),
angleEditEnabled: canvasFeatureAvailability.angleEditEnabled,
enhanceProEnabled: canvasFeatureAvailability.enhanceProEnabled,
aiWashEnabled: canvasFeatureAvailability.aiWashEnabled,
aiWashLabel: canvasFeatureAvailability.aiWashLabel,
aiWashIconUrl: canvasFeatureAvailability.aiWashIconUrl,
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
@ -774,6 +786,7 @@ async function getScopedPublicSiteSettings(
VIDEO_CANVAS_ENABLED_KEY,
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
ENHANCE_PRO_KREA_SETTING_KEY,
AI_WASH_CONFIG_KEY,
STYLE_PRO_CONFIG_KEY,
]);
const canvasFeatureAvailability = await readCanvasFeatureAvailability(valueByKey);
@ -810,6 +823,9 @@ async function getScopedPublicSiteSettings(
),
angleEditEnabled: canvasFeatureAvailability.angleEditEnabled,
enhanceProEnabled: canvasFeatureAvailability.enhanceProEnabled,
aiWashEnabled: canvasFeatureAvailability.aiWashEnabled,
aiWashLabel: canvasFeatureAvailability.aiWashLabel,
aiWashIconUrl: canvasFeatureAvailability.aiWashIconUrl,
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
});

View File

@ -12,8 +12,13 @@ export default function CanvasRealtimePage() {
const routeParams = useParams();
const searchParams = useSearchParams();
const id = String(routeParams?.id || "");
const initialMode =
searchParams?.get("mode") === "enhance-pro" ? "enhance-pro" : "realtime";
const initialMode: "realtime" | "enhance-pro" | "ai-wash" = (() => {
const mode = String(searchParams?.get("mode") || "").trim();
if (mode === "enhance-pro" || mode === "ai-wash") {
return mode;
}
return "realtime";
})();
const viewport = useCanvasStore(canvasSelectors.viewport);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);

View File

@ -3,28 +3,35 @@ import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css";
import { SessionProvider } from "next-auth/react";
const metadataBase = resolveMetadataBase();
export const metadata: Metadata = {
metadataBase,
title: {
template: "%s | yedir-ai",
default: "yedir-ai",
},
description: "AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
icons: {
icon: [
{ url: "/api/favicon", sizes: "any" },
{ url: "/api/favicon", type: "image/x-icon" },
],
apple: "/api/favicon",
shortcut: "/api/favicon",
},
};
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings().catch(() => null);
const siteName = String(settings?.siteName || "").trim() || "AI Chatbot";
return {
metadataBase,
title: {
template: `%s | ${siteName}`,
default: siteName,
},
description:
"AI design workspace for Canvas, Agent workflows, image editing, 3D/CAD, and local AI operations.",
icons: {
icon: [
{ url: "/api/favicon", sizes: "any" },
{ url: "/api/favicon", type: "image/x-icon" },
],
apple: "/api/favicon",
shortcut: "/api/favicon",
},
};
}
function resolveMetadataBase() {
const candidate =

View File

@ -4,6 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { KeyRound, Plus, Save, Trash2 } from "lucide-react";
import {
LocalAiSectionShell,
LocalAiSectionSummary,
} from "@/app/(admin)/settings/_components/local-ai-section-shell";
import { formatBeijingTime } from "@/lib/utils";
import {
defaultExternalImageApiConfig,
@ -13,13 +17,6 @@ import {
type ExternalImageApiConfig,
} from "@/lib/external-image-api/config";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@ -65,7 +62,15 @@ function buildKeyDraft() {
};
}
export function ExternalImageApiSettingsCard() {
export function ExternalImageApiSettingsCard({
expanded,
onToggle,
sectionId = "local-ai-section-external-image-api",
}: {
expanded: boolean;
onToggle: () => void;
sectionId?: string;
}) {
const { data: configData, mutate: mutateConfig } = useSWR<{
setting?: { value?: unknown };
}>(`/api/admin/settings?key=${EXTERNAL_IMAGE_API_CONFIG_KEY}`, fetcher);
@ -136,29 +141,49 @@ export function ExternalImageApiSettingsCard() {
};
return (
<Card>
<CardHeader className="space-y-2">
<div className="flex items-center gap-3">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<KeyRound className="h-5 w-5" />
</div>
<div>
<CardTitle className="text-xl">第三方生图 API</CardTitle>
<CardDescription>
对外开放当前系统里已启用的生图模型。第三方先调
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/models
</code>
获取模型名称,再调用
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/generate
</code>
生图。结果会继续走我们的储存桶并落到数据库。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<LocalAiSectionShell
description={
<>
对外开放当前系统里已启用的生图模型。第三方先调
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/models
</code>
获取模型名称,再调用
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/external/image/generate
</code>
生图。结果会继续走我们的储存桶并落到数据库。
</>
}
expanded={expanded}
id={sectionId}
onToggle={onToggle}
summary={
<LocalAiSectionSummary
items={[
{
label: config.enabled ? "开放接口已启用" : "开放接口已关闭",
tone: config.enabled ? "enabled" : "disabled",
},
{
label: `已配置 ${config.apiKeys.length} 个 Key`,
tone: config.apiKeys.length > 0 ? "enabled" : "warning",
},
{
label: `启用中 ${enabledKeyCount} 个`,
tone: enabledKeyCount > 0 ? "enabled" : "neutral",
},
]}
/>
}
title={
<span className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
第三方生图 API
</span>
}
>
<div className="space-y-6">
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-4 rounded-xl border p-4">
<div className="flex items-center justify-between rounded-lg border p-3">
@ -452,7 +477,7 @@ export function ExternalImageApiSettingsCard() {
)}
</div>
</div>
</CardContent>
</Card>
</div>
</LocalAiSectionShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ import {
} from "react";
import { toast } from "sonner";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { AiWashWorkspace } from "@/components/canvas/AiWashWorkspace";
import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace";
import { Button } from "@/components/ui/button";
import {
@ -58,6 +59,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { AI_WASH_DEFAULT_MODE_LABEL } from "@/lib/ai-wash/config";
import {
defaultCanvasToolbarConfig,
getCanvasToolbarItem,
@ -92,7 +94,7 @@ type RealtimeConfig = {
};
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
type RealtimeWorkspaceMode = "realtime" | "enhance-pro" | "ai-wash";
type RealtimeGenerationMode = "manual" | "auto";
type RealtimeAspectRatio = "1:1" | "4:3" | "2:3";
type RealtimeCanvasModeIconConfig = {
@ -905,6 +907,11 @@ export function RealtimeCanvasDialog({
const [refreshToken, setRefreshToken] = useState(0);
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
const [isEnhanceProEnabled, setIsEnhanceProEnabled] = useState(false);
const [isAiWashEnabled, setIsAiWashEnabled] = useState(false);
const [aiWashModeLabel, setAiWashModeLabel] = useState(
AI_WASH_DEFAULT_MODE_LABEL
);
const [aiWashModeIconUrl, setAiWashModeIconUrl] = useState("");
const [hasResolvedCanvasSettings, setHasResolvedCanvasSettings] =
useState(false);
const [modeIconConfig, setModeIconConfig] =
@ -1096,6 +1103,9 @@ export function RealtimeCanvasDialog({
let cancelled = false;
setHasResolvedCanvasSettings(false);
setIsEnhanceProEnabled(false);
setIsAiWashEnabled(false);
setAiWashModeLabel(AI_WASH_DEFAULT_MODE_LABEL);
setAiWashModeIconUrl("");
fetch(`/api/site-settings?scope=canvas&t=${Date.now()}`)
.then((res) => (res.ok ? res.json() : null))
@ -1103,6 +1113,17 @@ export function RealtimeCanvasDialog({
if (!data || cancelled) return;
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
setIsEnhanceProEnabled(data.enhanceProEnabled === true);
setIsAiWashEnabled(data.aiWashEnabled === true);
setAiWashModeLabel(
typeof data.aiWashLabel === "string" && data.aiWashLabel.trim()
? data.aiWashLabel.trim().slice(0, 32)
: AI_WASH_DEFAULT_MODE_LABEL
);
setAiWashModeIconUrl(
typeof data.aiWashIconUrl === "string"
? data.aiWashIconUrl.trim()
: ""
);
setCanvasToolbarConfig(
normalizeCanvasToolbarConfig(data.canvasToolbarConfig)
);
@ -1156,8 +1177,18 @@ export function RealtimeCanvasDialog({
}
if (workspaceMode === "enhance-pro" && !isEnhanceProEnabled) {
setWorkspaceMode("realtime");
return;
}
}, [hasResolvedCanvasSettings, isEnhanceProEnabled, open, workspaceMode]);
if (workspaceMode === "ai-wash" && !isAiWashEnabled) {
setWorkspaceMode("realtime");
}
}, [
hasResolvedCanvasSettings,
isAiWashEnabled,
isEnhanceProEnabled,
open,
workspaceMode,
]);
const pushMetric = useCallback((event: any) => {
metricsQueueRef.current.push(event);
@ -3127,14 +3158,18 @@ export function RealtimeCanvasDialog({
if (files.length === 0) return;
try {
if (workspaceMode === "enhance-pro") {
if (workspaceMode === "enhance-pro" || workspaceMode === "ai-wash") {
const file = files[0];
if (!file?.type.startsWith("image/")) {
throw new Error("请上传图片文件");
}
const dataUrl = await fileToDataUrl(file);
setEnhanceUploadedSourceImageUrl(dataUrl);
toast.success("已选择用于图像增强 Pro 的图片");
toast.success(
workspaceMode === "ai-wash"
? "已选择用于 AI洗图 的图片"
: "已选择用于图像增强 Pro 的图片"
);
return;
}
@ -3324,6 +3359,10 @@ export function RealtimeCanvasDialog({
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
return;
}
if (nextMode === "ai-wash" && !isAiWashEnabled) {
toast.error("AI洗图当前未开启,请先在系统设置中启用");
return;
}
if (workspaceMode === "realtime") {
captureDrawingLayerSnapshot();
}
@ -3336,6 +3375,7 @@ export function RealtimeCanvasDialog({
cancelPendingRealtimeFlush,
captureDrawingLayerSnapshot,
closeBrushSettingPopovers,
isAiWashEnabled,
isEnhanceProEnabled,
resetRealtimeInteractionState,
workspaceMode,
@ -3505,6 +3545,26 @@ export function RealtimeCanvasDialog({
</span>
</button>
) : null}
{isAiWashEnabled ? (
<button
className={cn(
"group inline-flex h-full min-w-0 items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
workspaceMode === "ai-wash"
? softActionButtonClass
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
)}
onClick={() => handleWorkspaceModeChange("ai-wash")}
type="button"
>
<CanvasToolbarIcon
active={workspaceMode === "ai-wash"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Sparkles}
iconUrl={aiWashModeIconUrl}
/>
<span className="max-w-[9rem] truncate">{aiWashModeLabel}</span>
</button>
) : null}
</div>
</div>
</div>
@ -3694,6 +3754,13 @@ export function RealtimeCanvasDialog({
onRequestAddImage={() => uploadInputRef.current?.click()}
sourceImageUrl={enhanceSourceImageUrl}
/>
) : workspaceMode === "ai-wash" ? (
<AiWashWorkspace
historyScopeKey={enhanceHistoryScopeKey}
onApplyResult={handleApplyEnhanceResult}
onRequestAddImage={() => uploadInputRef.current?.click()}
sourceImageUrl={enhanceSourceImageUrl}
/>
) : (
<>
{/* Main Canvas Area - Split View */}

View File

@ -1,6 +1,7 @@
"use client";
import type { User } from "next-auth";
import { usePathname } from "next/navigation";
import { TopNavbar } from "@/components/top-navbar";
type CustomLink = {
@ -61,27 +62,39 @@ export function ChatShellChrome({
agentEnabled,
videoCanvasEnabled,
}: ChatShellChromeProps) {
const pathname = usePathname();
const hideSidebarChrome =
pathname === "/gallery" || pathname.startsWith("/gallery/");
return (
<>
<TopNavbar
agentEnabled={agentEnabled}
customLinks={customLinks}
isAdmin={isAdmin}
productPlanningEnabled={productPlanningEnabled}
rankingEnabled={rankingEnabled}
settingsHref={settingsHref}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
siteName={siteName}
toolbox1Enabled={toolbox1Enabled}
toolbox1Name={toolbox1Name}
toolbox2Enabled={toolbox2Enabled}
toolbox2Name={toolbox2Name}
tutorialEnabled={tutorialEnabled}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={videoCanvasEnabled}
/>
<div className="flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]">
{hideSidebarChrome ? null : (
<TopNavbar
agentEnabled={agentEnabled}
customLinks={customLinks}
isAdmin={isAdmin}
productPlanningEnabled={productPlanningEnabled}
rankingEnabled={rankingEnabled}
settingsHref={settingsHref}
siteLogo={siteLogo}
siteLogoDark={siteLogoDark}
siteName={siteName}
toolbox1Enabled={toolbox1Enabled}
toolbox1Name={toolbox1Name}
toolbox2Enabled={toolbox2Enabled}
toolbox2Name={toolbox2Name}
tutorialEnabled={tutorialEnabled}
tutorialVideos={tutorialVideos}
videoCanvasEnabled={videoCanvasEnabled}
/>
)}
<div
className={
hideSidebarChrome
? "flex h-svh w-full overflow-hidden"
: "flex h-svh w-full overflow-hidden pl-16 [--sidebar-offset:4rem]"
}
>
<main className="h-full min-w-0 flex-1">{children}</main>
</div>
</>

View File

@ -1529,9 +1529,8 @@ export function LovartHome({
<Button
variant="ghost"
onClick={() => setUpgradeDialogOpen(true)}
className="h-8 rounded-[14px] bg-[#1c1c1e] px-2.5 text-[14px] text-white hover:bg-black hover:text-white"
className="h-8 rounded-[14px] bg-[#1c1c1e] px-2 text-[14px] text-white hover:bg-black hover:text-white"
>
<span className="mr-1.5">升级</span>
<div className="inline-flex items-center gap-1 rounded-[9px] bg-white/10 px-1.5 py-0.5 text-xs text-white">
<Zap className="h-3 w-3 text-amber-300" />
{membershipData?.summary?.currentCredits ?? 0}

310
lib/ai-wash/config.ts Normal file
View File

@ -0,0 +1,310 @@
export const AI_WASH_CONFIG_KEY = "ai_wash_config";
export const AI_WASH_DEFAULT_MODE_LABEL = "AI洗图";
export const AI_WASH_DEFAULT_IMAGE_SIZE = "2K";
export const AI_WASH_DEFAULT_HISTORY_LIMIT = 8;
export const AI_WASH_CONTROL_ORDER = [
"creativity",
"detail",
"lighting",
] as const;
export type AiWashControlId = (typeof AI_WASH_CONTROL_ORDER)[number];
export type AiWashControlKind = "model" | "prompt";
export type AiWashControlLevel = {
id: string;
label: string;
modelId: string;
promptFragment: string;
};
export type AiWashControl = {
id: AiWashControlId;
label: string;
helperText: string;
kind: AiWashControlKind;
levels: AiWashControlLevel[];
defaultLevel: number;
};
export type AiWashPreset = {
id: string;
title: string;
description: string;
prompt: string;
modelId: string;
enabled: boolean;
isDefault: boolean;
controls: AiWashControl[];
};
export type AiWashConfig = {
enabled: boolean;
modeLabel: string;
modeIconUrl: string;
imageSize: string;
promptExtend: boolean;
historyLimit: number;
presets: AiWashPreset[];
};
function normalizeText(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
function createPresetId(title: string, index: number) {
const normalized = String(title || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || `ai-wash-preset-${index + 1}`;
}
function createLevelId(controlId: AiWashControlId, index: number) {
return `${controlId}-level-${index + 1}`;
}
function createDefaultControl(
controlId: AiWashControlId,
overrides?: Partial<AiWashControl>
): AiWashControl {
const defaults: Record<
AiWashControlId,
Pick<AiWashControl, "label" | "helperText" | "kind">
> = {
creativity: {
label: "创造力",
helperText: "每一档都可以单独指定生图模型,也可以补充少量提示词片段。",
kind: "model",
},
detail: {
label: "细节",
helperText: "每一档都可以补充默认提示词,控制画面的细节强弱与精修程度。",
kind: "prompt",
},
lighting: {
label: "光塑",
helperText: "每一档都可以补充默认提示词,控制光影层次、氛围和空间塑造。",
kind: "prompt",
},
};
return {
id: controlId,
label: overrides?.label || defaults[controlId].label,
helperText: overrides?.helperText || defaults[controlId].helperText,
kind: overrides?.kind || defaults[controlId].kind,
defaultLevel:
typeof overrides?.defaultLevel === "number"
? Math.max(0, Math.min(23, Math.floor(overrides.defaultLevel)))
: 1,
levels:
overrides?.levels && overrides.levels.length > 0
? overrides.levels
: Array.from({ length: 3 }, (_, index) => ({
id: createLevelId(controlId, index),
label: String(index + 1),
modelId: "",
promptFragment: "",
})),
};
}
function normalizeControlLevel(
controlId: AiWashControlId,
value: unknown,
index: number
): AiWashControlLevel {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
id: normalizeText(raw.id, 64) || createLevelId(controlId, index),
label: normalizeText(raw.label, 24) || String(index + 1),
modelId: normalizeText(raw.modelId, 128),
promptFragment: normalizeText(raw.promptFragment, 1000),
};
}
function normalizeControl(
controlId: AiWashControlId,
value: unknown,
fallback?: AiWashControl
): AiWashControl {
const base = fallback || createDefaultControl(controlId);
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const levelsInput = Array.isArray(raw.levels) ? raw.levels : base.levels;
const levels = levelsInput
.map((level, index) => normalizeControlLevel(controlId, level, index))
.slice(0, 24);
const defaultLevel = Number.isFinite(Number(raw.defaultLevel))
? Number(raw.defaultLevel)
: base.defaultLevel;
return {
id: controlId,
label: normalizeText(raw.label, 24) || base.label,
helperText: normalizeText(raw.helperText, 200) || base.helperText,
kind:
raw.kind === "model" || raw.kind === "prompt" ? raw.kind : base.kind,
levels: levels.length > 0 ? levels : base.levels,
defaultLevel: Math.max(0, Math.min(levels.length - 1, Math.floor(defaultLevel))),
};
}
function normalizeControls(value: unknown): AiWashControl[] {
const rawList = Array.isArray(value) ? value : [];
return AI_WASH_CONTROL_ORDER.map((controlId) => {
const matched =
rawList.find((item) => {
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
return raw.id === controlId;
}) || null;
return normalizeControl(controlId, matched, createDefaultControl(controlId));
});
}
function normalizePreset(
value: unknown,
index: number,
fallback: AiWashPreset
): AiWashPreset {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const title = normalizeText(raw.title, 48) || fallback.title;
const description =
normalizeText(raw.description, 200) || fallback.description;
const prompt = normalizeText(raw.prompt, 4000) || fallback.prompt;
return {
id: normalizeText(raw.id, 64) || createPresetId(title, index),
title,
description,
prompt,
modelId: normalizeText(raw.modelId, 128),
enabled: raw.enabled !== false,
isDefault: raw.isDefault === true,
controls: normalizeControls(raw.controls),
};
}
function ensureDefaultPreset(presets: AiWashPreset[]) {
if (presets.length === 0) return presets;
if (presets.some((preset) => preset.isDefault && preset.enabled)) {
return presets;
}
return presets.map((preset, index) => ({
...preset,
isDefault: index === 0,
}));
}
function defaultControlsForPreset(): AiWashControl[] {
return AI_WASH_CONTROL_ORDER.map((controlId) => createDefaultControl(controlId));
}
export function defaultAiWashConfig(): AiWashConfig {
return {
enabled: false,
modeLabel: AI_WASH_DEFAULT_MODE_LABEL,
modeIconUrl: "",
imageSize: AI_WASH_DEFAULT_IMAGE_SIZE,
promptExtend: false,
historyLimit: AI_WASH_DEFAULT_HISTORY_LIMIT,
presets: [
{
id: "texture-precise",
title: "质感提升(细节更精准)",
description: "保留原图结构,强化细节精度、材质质感和光影层级。",
prompt:
"保持原图主体、构图、色彩关系和空间透视基本不变,重点提升材质纹理、细节锐度、光影层次和整体清晰度,让画面更精致、更干净、更高级。",
modelId: "",
enabled: true,
isDefault: true,
controls: defaultControlsForPreset(),
},
{
id: "texture-expressive",
title: "质感提升(细节更发散)",
description: "允许模型更积极地重建纹理和氛围,适合需要更强视觉表现的场景。",
prompt:
"在保留主体识别和原始构图的基础上,允许更积极地重建纹理、材质和环境氛围,提升视觉张力与高级感,让结果更具设计感和表现力。",
modelId: "",
enabled: true,
isDefault: false,
controls: defaultControlsForPreset(),
},
{
id: "ai-render",
title: "AI模型渲染(类似渲染器)",
description: "转成更接近商业渲染图的质感,适合家居、空间、产品展示。",
prompt:
"将原图重塑为更接近专业商业渲染图的视觉效果,提升光影控制、材质真实度、环境反射和空间氛围,保持主体结构与镜头视角稳定。",
modelId: "",
enabled: true,
isDefault: false,
controls: defaultControlsForPreset(),
},
],
};
}
export function normalizeAiWashConfig(value: unknown): AiWashConfig {
const fallback = defaultAiWashConfig();
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const presetsInput = Array.isArray(raw.presets) ? raw.presets : fallback.presets;
const presets = ensureDefaultPreset(
presetsInput
.map((preset, index) =>
normalizePreset(
preset,
index,
fallback.presets[index] || fallback.presets[0]
)
)
.slice(0, 6)
);
return {
enabled: raw.enabled === true,
modeLabel: normalizeText(raw.modeLabel, 32) || AI_WASH_DEFAULT_MODE_LABEL,
modeIconUrl: normalizeText(raw.modeIconUrl, 2000),
imageSize: normalizeText(raw.imageSize, 16) || AI_WASH_DEFAULT_IMAGE_SIZE,
promptExtend: raw.promptExtend === true,
historyLimit: Math.max(
1,
Math.min(24, Number(raw.historyLimit) || AI_WASH_DEFAULT_HISTORY_LIMIT)
),
presets,
};
}
export function sanitizeAiWashConfigForClient(
config: AiWashConfig
): AiWashConfig {
return normalizeAiWashConfig(config);
}
export function getEnabledAiWashPresets(config: AiWashConfig) {
return config.presets.filter(
(preset) => preset.enabled && preset.prompt.trim().length > 0
);
}

View File

@ -27,23 +27,33 @@ const REFERENCE_INPUT_FIELDS = new Set([
"imageUrl",
"imageUrls",
"image_url",
"image_urls",
"styleImages",
"style_images",
"imageStyleRefs",
"image_style_references",
"characterReferenceImages",
"character_reference_images",
"backgroundImage",
"referenceImages",
"reference_images",
"startImage",
"endImage",
"init_image_url",
"references",
"subjects",
"styleReferences",
"style_references",
"elementReferences",
"element_references",
"videoReference",
"video_reference",
"startVideo",
"init_video",
"referenceVideos",
"reference_videos",
"referenceAudios",
"reference_audios",
]);
function hasReferenceInput(fields: string[]) {
@ -68,12 +78,15 @@ function createDefaultParams(
function createImageConfig(fields: string[]) {
return {
maxImages: fields.includes("batchSize") ? 4 : 1,
// Krea removed batch generation; one request now produces one output.
maxImages: 1,
supportsSize: fields.includes("width") && fields.includes("height"),
supports1K: true,
supports2K: true,
supports4K: fields.includes("resolution") || fields.includes("width"),
supportsPromptExtend: fields.includes("skipPromptExpansion"),
supportsPromptExtend:
fields.includes("skipPromptExpansion") ||
fields.includes("skip_prompt_expansion"),
supportsNegativePrompt: fields.includes("negative_prompt"),
supportsWatermark: false,
supportsSeed: fields.includes("seed"),
@ -233,62 +246,65 @@ function videoPreset(
}
export const KREA_MODEL_PRESETS: KreaModelPreset[] = [
imagePreset(10, "/generate/image/bfl/flux-1-dev", "Flux", ["strength", "seed", "steps", "styleImages", "imageStyleRefs", "prompt", "width", "height", "guidance_scale_flux", "imageUrl", "relaxedModeAccess", "styles"], ["prompt"]),
imagePreset(20, "/generate/image/bfl/flux-1-kontext-dev", "Flux Kontext", ["strength", "seed", "steps", "styleImages", "imageStyleRefs", "prompt", "width", "height", "guidance_scale_flux", "imageUrl", "relaxedModeAccess", "styles"], ["prompt"]),
imagePreset(30, "/generate/image/google/nano-banana-pro", "Nano Banana Pro", ["batchSize", "prompt", "width", "height", "imageUrls", "aspectRatio", "styleImages", "resolution"], ["prompt"]),
imagePreset(40, "/generate/image/google/nano-banana-2", "Nano Banana 2", ["batchSize", "prompt", "width", "height", "imageUrls", "aspectRatio", "styleImages", "resolution"], ["prompt"]),
imagePreset(50, "/generate/image/google/nano-banana", "Nano Banana", ["batchSize", "prompt", "width", "height", "imageUrls", "styleImages", "aspectRatio"], ["prompt"]),
imagePreset(60, "/generate/image/bfl/flux-1.1-pro", "Flux 1.1 Pro", ["batchSize", "prompt", "width", "height", "seed"], ["prompt", "width", "height"]),
imagePreset(70, "/generate/image/bfl/flux-1.1-pro-ultra", "Flux 1.1 Pro Ultra", ["batchSize", "prompt", "width", "height", "seed", "raw"], ["prompt"]),
imagePreset(80, "/generate/image/ideogram/ideogram-2-turbo", "Ideogram 2.0A Turbo", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(90, "/generate/image/ideogram/ideogram-3", "Ideogram 3.0", ["batchSize", "prompt", "width", "height", "seed", "styleImages", "characterReferenceImages"], ["prompt"]),
imagePreset(100, "/generate/image/google/imagen-3", "Imagen 3", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(110, "/generate/image/google/imagen-4", "Imagen 4", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(120, "/generate/image/google/imagen-4-fast", "Imagen 4 Fast", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(130, "/generate/image/google/imagen-4-ultra", "Imagen 4 Ultra", ["batchSize", "prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(140, "/generate/image/runway/gen-4-image", "Runway Gen-4", ["batchSize", "prompt", "width", "height", "seed", "referenceImages"], ["prompt", "referenceImages"]),
imagePreset(150, "/generate/image/openai/gpt-image", "ChatGPT Image", ["batchSize", "prompt", "width", "height", "quality", "backgroundImage", "imageUrls", "styleImages", "styles"], ["prompt"]),
imagePreset(160, "/generate/image/bytedance/seedream-3", "Seedream 3", ["batchSize", "prompt", "width", "height", "seed", "model"], ["prompt", "model"]),
imagePreset(170, "/generate/image/bytedance/seedream-4", "Seedream 4", ["batchSize", "prompt", "seed", "width", "height", "styleImages"], ["prompt", "width", "height"]),
imagePreset(180, "/generate/image/bytedance/seedream-5-lite", "Seedream 5 Lite", ["batchSize", "prompt", "seed", "width", "height", "styleImages"], ["prompt", "width", "height"]),
imagePreset(190, "/generate/image/qwen/2512", "Qwen 2512", ["prompt", "negative_prompt", "width", "height", "num_inference_steps", "cfg_scale", "seed", "batchSize", "skipPromptExpansion", "styles"], ["prompt"]),
imagePreset(200, "/generate/image/z-image/z-image", "Z Image", ["prompt", "width", "height", "batchSize", "seed", "imageUrl", "denoising_strength", "styleImages", "skipPromptExpansion", "styles"], ["prompt", "width", "height"]),
imagePreset(210, "/generate/image/bytedance/seededit", "SeedEdit", ["batchSize", "prompt", "seed", "imageUrl"], ["prompt", "imageUrl"]),
enhancePreset(220, "/generate/enhance/topaz/generative-enhance", "Topaz Generative", ["batchSize", "width", "height", "seed", "prompt", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "image_url", "model", "upscaling_activated", "image_scaling_factor", "creativity", "texture", "sharpen", "denoise", "detail"], ["width", "height", "image_url"]),
enhancePreset(230, "/generate/enhance/topaz/standard-enhance", "Topaz", ["batchSize", "width", "height", "seed", "prompt", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "image_url", "model", "upscaling_activated", "image_scaling_factor", "sharpen", "denoise", "fix_compression", "strength"], ["width", "height", "image_url", "model"]),
enhancePreset(240, "/generate/enhance/topaz/bloom-enhance", "Topaz Bloom", ["batchSize", "width", "height", "seed", "prompt", "output_format", "crop_to_fill", "image_url", "model", "creativity", "face_preservation", "color_preservation", "upscaling_activated", "image_scaling_factor"], ["width", "height", "image_url", "model"]),
videoPreset(250, "/generate/video/kling/kling-1", "Kling 1.0", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(260, "/generate/video/kling/kling-1.5", "Kling 1.5", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage"], ["prompt"]),
videoPreset(270, "/generate/video/kling/kling-1.6", "Kling 1.6", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(280, "/generate/video/kling/kling-2", "Kling 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "mode"], ["prompt"]),
videoPreset(290, "/generate/video/kling/kling-2.1", "Kling 2.1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "mode"], ["prompt"]),
videoPreset(300, "/generate/video/kling/kling-2.5", "Kling 2.5", ["startImage", "prompt", "aspectRatio", "duration", "endImage"], ["prompt"]),
videoPreset(310, "/generate/video/kling/kling-2.6", "Kling 2.6", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "generateAudio"], ["prompt"]),
videoPreset(320, "/generate/video/kling/kling-o1", "Kling o1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "styleReferences", "elementReferences", "videoReference"], ["prompt"]),
videoPreset(330, "/generate/video/minimax/hailuo", "Hailuo", ["startImage", "prompt", "expandPrompt", "cameraControl", "model", "subjects"], ["prompt"]),
videoPreset(340, "/generate/video/minimax/hailuo-02", "Hailuo 02", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(350, "/generate/video/minimax/hailuo-2.3", "Hailuo 2.3", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(360, "/generate/video/minimax/hailuo-2.3-fast", "Hailuo 2.3 Fast", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(370, "/generate/video/alibaba/wan-2.1", "Wan 2.1", ["startImage", "prompt", "width", "height", "endImage", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(380, "/generate/video/alibaba/wan-2.2", "Wan 2.2", ["startImage", "guide_scale", "prompt", "width", "height", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(390, "/generate/video/alibaba/wan-2.5", "Wan 2.5", ["startImage", "prompt", "aspectRatio", "resolution", "enablePromptExpansion", "duration", "seed", "audio_url", "generateAudio"], ["prompt"]),
videoPreset(400, "/generate/video/google/veo-2", "Veo 2", ["startImage", "prompt", "aspectRatio", "duration", "image_url"], ["prompt"]),
videoPreset(410, "/generate/video/google/veo-3", "Veo 3", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(420, "/generate/video/google/veo-3-fast", "Veo 3 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(430, "/generate/video/google/veo-3.1", "Veo 3.1", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(440, "/generate/video/google/veo-3.1-fast", "Veo 3.1 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(450, "/generate/video/google/veo-3.1-lite", "Veo 3.1 Lite", ["startImage", "prompt", "aspectRatio", "duration", "resolution", "endImage"], ["prompt"]),
videoPreset(460, "/generate/video/bytedance/seedance-1.0-lite", "Seedance Lite", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(470, "/generate/video/bytedance/seedance-1.0-pro", "Seedance Pro", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(480, "/generate/video/bytedance/seedance-1.0-pro-fast", "Seedance Pro Fast", ["startImage", "prompt", "aspectRatio", "duration", "resolution"], ["prompt"]),
videoPreset(490, "/generate/video/bytedance/seedance-2", "Seedance 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(500, "/generate/video/bytedance/seedance-2-fast", "Seedance 2.0 Fast", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(510, "/generate/video/openai/sora-2", "Sora 2", ["startImage", "prompt", "duration", "aspectRatio"], ["prompt"]),
videoPreset(520, "/generate/video/luma/ray-2", "Ray 2", ["startImage", "prompt", "aspectRatio", "loop", "width", "height"], ["prompt", "width", "height"]),
videoPreset(530, "/generate/video/runway/gen-3", "Runway Gen-3", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(540, "/generate/video/runway/gen-4-video", "Runway Gen-4", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(550, "/generate/video/runway/gen-4.5", "Runway Gen-4.5", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(560, "/generate/video/runway/aleph", "Runway Aleph", ["startImage", "startVideo", "startVideoTrim", "endVideoTrim", "prompt", "init_video", "init_image_url", "references", "ratio", "seed", "contentModeration"], ["prompt", "init_video"]),
imagePreset(10, "/generate/image/krea/krea-2/medium", "Krea 2 Medium", ["prompt", "seed", "styles", "image_style_references", "aspect_ratio", "resolution", "creativity", "moodboards"], ["prompt", "aspect_ratio", "resolution"]),
imagePreset(20, "/generate/image/krea/krea-2/large", "Krea 2 Large", ["prompt", "seed", "styles", "image_style_references", "aspect_ratio", "resolution", "creativity", "moodboards"], ["prompt", "aspect_ratio", "resolution"]),
imagePreset(30, "/generate/image/bfl/flux-1-dev", "Flux", ["prompt", "seed", "steps", "width", "height", "image_url", "styles", "strength", "guidance_scale", "style_images", "image_style_references"], ["prompt"]),
imagePreset(40, "/generate/image/bfl/flux-1-kontext-dev", "Flux Kontext", ["prompt", "seed", "steps", "width", "height", "image_url", "strength", "guidance_scale", "style_images"], ["prompt"]),
imagePreset(50, "/generate/image/google/nano-banana-pro", "Nano Banana Pro", ["prompt", "width", "height", "image_urls", "aspect_ratio", "resolution", "style_images"], ["prompt"]),
imagePreset(60, "/generate/image/google/nano-banana-2", "Nano Banana 2", ["prompt", "width", "height", "image_urls", "aspect_ratio", "resolution", "style_images"], ["prompt"]),
imagePreset(70, "/generate/image/google/nano-banana", "Nano Banana", ["prompt", "width", "height", "image_urls", "aspect_ratio", "style_images"], ["prompt"]),
imagePreset(80, "/generate/image/bfl/flux-1.1-pro", "Flux 1.1 Pro", ["prompt", "width", "height", "seed"], ["prompt", "width", "height"]),
imagePreset(90, "/generate/image/bfl/flux-1.1-pro-ultra", "Flux 1.1 Pro Ultra", ["prompt", "width", "height", "seed", "raw"], ["prompt"]),
imagePreset(100, "/generate/image/ideogram/ideogram-2-turbo", "Ideogram 2.0A Turbo", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(110, "/generate/image/ideogram/ideogram-3", "Ideogram 3.0", ["prompt", "width", "height", "seed", "character_reference_images", "style_images"], ["prompt"]),
imagePreset(120, "/generate/image/google/imagen-3", "Imagen 3", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(130, "/generate/image/google/imagen-4", "Imagen 4", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(140, "/generate/image/google/imagen-4-fast", "Imagen 4 Fast", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(150, "/generate/image/google/imagen-4-ultra", "Imagen 4 Ultra", ["prompt", "width", "height", "seed"], ["prompt"]),
imagePreset(160, "/generate/image/runway/gen-4-image", "Runway Gen-4", ["prompt", "width", "height", "seed", "reference_images"], ["prompt", "reference_images"]),
imagePreset(170, "/generate/image/openai/gpt-image", "ChatGPT Image", ["prompt", "width", "height", "quality", "image_urls", "styles", "style_images"], ["prompt"]),
imagePreset(180, "/generate/image/openai/gpt-image-2", "ChatGPT Image 2", ["prompt", "quality", "image_urls", "width", "height", "aspect_ratio", "resolution", "style_images"], ["prompt"]),
imagePreset(190, "/generate/image/luma/uni-1", "Luma Uni 1", ["prompt", "width", "height", "mode", "style", "output_format", "web_search", "style_images"], ["prompt"]),
imagePreset(200, "/generate/image/bytedance/seedream-4", "Seedream 4", ["prompt", "width", "height", "seed", "style_images"], ["prompt", "width", "height"]),
imagePreset(210, "/generate/image/bytedance/seedream-5-lite", "Seedream 5 Lite", ["prompt", "width", "height", "seed", "style_images"], ["prompt", "width", "height"]),
imagePreset(220, "/generate/image/qwen/2512", "Qwen 2512", ["prompt", "negative_prompt", "width", "height", "num_inference_steps", "cfg_scale", "seed", "skip_prompt_expansion", "styles"], ["prompt"]),
imagePreset(230, "/generate/image/z-image/z-image", "Z Image", ["prompt", "seed", "denoising_strength", "styles", "skip_prompt_expansion", "aspect_ratio", "resolution", "image_url", "style_images"], ["prompt", "aspect_ratio", "resolution"]),
imagePreset(240, "/generate/image/bytedance/seededit", "SeedEdit", ["prompt", "seed", "image_url"], ["prompt", "image_url"]),
enhancePreset(250, "/generate/enhance/topaz/generative-enhance", "Topaz Generative", ["prompt", "width", "height", "seed", "image_url", "model", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "upscaling_activated", "image_scaling_factor", "creativity", "texture", "sharpen", "denoise", "detail"], ["width", "height", "image_url"]),
enhancePreset(260, "/generate/enhance/topaz/standard-enhance", "Topaz", ["prompt", "width", "height", "seed", "image_url", "model", "output_format", "subject_detection", "face_enhancement", "face_enhancement_creativity", "face_enhancement_strength", "crop_to_fill", "upscaling_activated", "image_scaling_factor", "sharpen", "denoise", "fix_compression", "strength"], ["width", "height", "image_url", "model"]),
enhancePreset(270, "/generate/enhance/topaz/bloom-enhance", "Topaz Bloom", ["prompt", "width", "height", "seed", "image_url", "output_format", "crop_to_fill", "creativity", "face_preservation", "color_preservation", "upscaling_activated", "image_scaling_factor"], ["width", "height", "image_url"]),
videoPreset(280, "/generate/video/kling/kling-1", "Kling 1.0", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(290, "/generate/video/kling/kling-1.5", "Kling 1.5", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage"], ["prompt"]),
videoPreset(300, "/generate/video/kling/kling-1.6", "Kling 1.6", ["startImage", "prompt", "aspectRatio", "duration", "cameraControl", "endImage", "mode"], ["prompt"]),
videoPreset(310, "/generate/video/kling/kling-2", "Kling 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "mode"], ["prompt"]),
videoPreset(320, "/generate/video/kling/kling-2.1", "Kling 2.1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "mode"], ["prompt"]),
videoPreset(330, "/generate/video/kling/kling-2.5", "Kling 2.5", ["startImage", "prompt", "aspectRatio", "duration", "endImage"], ["prompt"]),
videoPreset(340, "/generate/video/kling/kling-2.6", "Kling 2.6", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "generateAudio"], ["prompt"]),
videoPreset(350, "/generate/video/kling/kling-o1", "Kling o1", ["startImage", "prompt", "aspectRatio", "duration", "endImage", "styleReferences", "elementReferences", "videoReference"], ["prompt"]),
videoPreset(360, "/generate/video/minimax/hailuo", "Hailuo", ["startImage", "prompt", "expandPrompt", "cameraControl", "model", "subjects"], ["prompt"]),
videoPreset(370, "/generate/video/minimax/hailuo-02", "Hailuo 02", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(380, "/generate/video/minimax/hailuo-2.3", "Hailuo 2.3", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(390, "/generate/video/minimax/hailuo-2.3-fast", "Hailuo 2.3 Fast", ["startImage", "prompt", "expandPrompt", "endImage", "resolution", "duration"], ["prompt"]),
videoPreset(400, "/generate/video/alibaba/wan-2.1", "Wan 2.1", ["startImage", "prompt", "width", "height", "endImage", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(410, "/generate/video/alibaba/wan-2.2", "Wan 2.2", ["startImage", "guide_scale", "prompt", "width", "height", "aspectRatio", "frame_num", "styles"], ["prompt", "width", "height"]),
videoPreset(420, "/generate/video/alibaba/wan-2.5", "Wan 2.5", ["startImage", "prompt", "aspectRatio", "resolution", "enablePromptExpansion", "duration", "seed", "audio_url", "generateAudio"], ["prompt"]),
videoPreset(430, "/generate/video/google/veo-2", "Veo 2", ["startImage", "prompt", "aspectRatio", "duration", "image_url"], ["prompt"]),
videoPreset(440, "/generate/video/google/veo-3", "Veo 3", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(450, "/generate/video/google/veo-3-fast", "Veo 3 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution"], ["prompt"]),
videoPreset(460, "/generate/video/google/veo-3.1", "Veo 3.1", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(470, "/generate/video/google/veo-3.1-fast", "Veo 3.1 Fast", ["startImage", "prompt", "aspectRatio", "duration", "generateAudio", "resolution", "endImage", "referenceImages"], ["prompt"]),
videoPreset(480, "/generate/video/google/veo-3.1-lite", "Veo 3.1 Lite", ["startImage", "prompt", "aspectRatio", "duration", "resolution", "endImage"], ["prompt"]),
videoPreset(490, "/generate/video/bytedance/seedance-1.0-lite", "Seedance Lite", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(500, "/generate/video/bytedance/seedance-1.0-pro", "Seedance Pro", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "duration", "resolution"], ["prompt"]),
videoPreset(510, "/generate/video/bytedance/seedance-1.0-pro-fast", "Seedance Pro Fast", ["startImage", "prompt", "aspectRatio", "duration", "resolution"], ["prompt"]),
videoPreset(520, "/generate/video/bytedance/seedance-2", "Seedance 2.0", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(530, "/generate/video/bytedance/seedance-2-fast", "Seedance 2.0 Fast", ["startImage", "prompt", "aspectRatio", "endImage", "referenceImages", "referenceVideos", "referenceAudios", "duration", "generateAudio", "resolution", "seed"], ["prompt"]),
videoPreset(540, "/generate/video/openai/sora-2", "Sora 2", ["startImage", "prompt", "duration", "aspectRatio"], ["prompt"]),
videoPreset(550, "/generate/video/luma/ray-2", "Ray 2", ["startImage", "prompt", "aspectRatio", "loop", "width", "height"], ["prompt", "width", "height"]),
videoPreset(560, "/generate/video/runway/gen-3", "Runway Gen-3", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(570, "/generate/video/runway/gen-4-video", "Runway Gen-4", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(580, "/generate/video/runway/gen-4.5", "Runway Gen-4.5", ["startImage", "prompt", "aspectRatio", "duration", "seed"], ["prompt"]),
videoPreset(590, "/generate/video/runway/aleph", "Runway Aleph", ["startImage", "startVideo", "startVideoTrim", "endVideoTrim", "prompt", "init_video", "init_image_url", "references", "ratio", "seed", "contentModeration"], ["prompt", "init_video"]),
];
export function getKreaModelPresetById(modelId: string) {

View File

@ -318,11 +318,6 @@ function normalizeSeed(seed?: number) {
return clamp(Math.round(Number(seed)), 0, 4294967295);
}
function normalizeBatchSize(value?: number) {
if (!Number.isFinite(Number(value))) return 1;
return clamp(Math.round(Number(value)), 1, 4);
}
function normalizeVideoDuration(duration: unknown, endpoint: string) {
const value = Number.isFinite(Number(duration)) ? Math.round(Number(duration)) : 5;
if (endpoint.includes("/bytedance/seedance-2")) {
@ -387,9 +382,11 @@ function buildTaggedReferences(imageUrls: string[], tagPrefix = "ref", max = 3)
const KREA_SOURCE_IMAGE_FIELDS = new Set([
"imageUrls",
"image_urls",
"imageUrl",
"image_url",
"referenceImages",
"reference_images",
"init_image_url",
"references",
]);
@ -401,6 +398,19 @@ function hasKreaSourceImageField(fields: Iterable<string>) {
return false;
}
function hasAnyKreaField(fields: Set<string>, aliases: string[]) {
return aliases.some((field) => fields.has(field));
}
function getPreferredKreaField(fields: Set<string>, aliases: string[]) {
for (const field of aliases) {
if (fields.has(field)) {
return field;
}
}
return null;
}
function buildReferencePreservingPrompt(params: {
endpoint: string;
kind: KreaModelKind;
@ -449,19 +459,26 @@ function summarizeKreaReferenceFields(body: Record<string, any>) {
for (const field of [
"imageUrls",
"image_urls",
"imageUrl",
"image_url",
"styleImages",
"style_images",
"imageStyleRefs",
"image_style_references",
"characterReferenceImages",
"character_reference_images",
"referenceImages",
"reference_images",
"startImage",
"endImage",
"init_image_url",
"references",
"subjects",
"styleReferences",
"style_references",
"elementReferences",
"element_references",
]) {
const value = body[field];
if (Array.isArray(value)) {
@ -841,6 +858,7 @@ function buildKreaBody(params: {
const required = new Set(requiredFields);
const body: Record<string, any> = {};
const isVideo = kind === "video";
const isImageLike = !isVideo;
const videoMode =
isVideo
? videoParams?.mode ||
@ -864,7 +882,6 @@ function buildKreaBody(params: {
const videoDimensions = resolveVideoDimensions(ratio, videoResolution);
if (fields.has("prompt")) body.prompt = prompt;
if (fields.has("batchSize")) body.batchSize = normalizeBatchSize(imageParams?.imageCount);
if (
fields.has("width") &&
@ -878,13 +895,20 @@ function buildKreaBody(params: {
) {
body.height = isVideo ? videoDimensions.height : imageDimensions.height;
}
if (fields.has("aspectRatio") && shouldInferImageGeometryFromReference && inferredReferenceAspectRatio) {
body.aspectRatio = inferredReferenceAspectRatio;
const aspectRatioField = getPreferredKreaField(
fields,
isImageLike ? ["aspect_ratio", "aspectRatio"] : ["aspectRatio", "aspect_ratio"]
);
if (aspectRatioField && shouldInferImageGeometryFromReference && inferredReferenceAspectRatio) {
body[aspectRatioField] = inferredReferenceAspectRatio;
} else if (
fields.has("aspectRatio") &&
(isVideo || !shouldInferImageGeometryFromReference || required.has("aspectRatio"))
aspectRatioField &&
(isVideo ||
!shouldInferImageGeometryFromReference ||
required.has("aspectRatio") ||
required.has("aspect_ratio"))
) {
body.aspectRatio = ratio === "auto" ? "1:1" : ratio;
body[aspectRatioField] = ratio === "auto" ? "1:1" : ratio;
}
if (fields.has("ratio")) body.ratio = ratio === "auto" ? "16:9" : ratio;
if (fields.has("resolution")) {
@ -896,8 +920,12 @@ function buildKreaBody(params: {
if (fields.has("negative_prompt") && imageParams?.negativePrompt) {
body.negative_prompt = imageParams.negativePrompt;
}
if (fields.has("skipPromptExpansion") && typeof imageParams?.promptExtend === "boolean") {
body.skipPromptExpansion = !imageParams.promptExtend;
const skipPromptExpansionField = getPreferredKreaField(fields, [
"skip_prompt_expansion",
"skipPromptExpansion",
]);
if (skipPromptExpansionField && typeof imageParams?.promptExtend === "boolean") {
body[skipPromptExpansionField] = !imageParams.promptExtend;
}
if (fields.has("enablePromptExpansion")) {
body.enablePromptExpansion = true;
@ -908,8 +936,12 @@ function buildKreaBody(params: {
if (fields.has("duration")) {
body.duration = normalizeVideoDuration(videoParams?.duration, endpoint);
}
if (fields.has("generateAudio") && typeof videoParams?.generateAudio === "boolean") {
body.generateAudio = videoParams.generateAudio;
const generateAudioField = getPreferredKreaField(fields, [
"generate_audio",
"generateAudio",
]);
if (generateAudioField && typeof videoParams?.generateAudio === "boolean") {
body[generateAudioField] = videoParams.generateAudio;
}
if (fields.has("loop")) {
body.loop = false;
@ -924,31 +956,65 @@ function buildKreaBody(params: {
if (fields.has("output_format")) body.output_format = "jpg";
if (fields.has("backgroundImage")) body.backgroundImage = "auto";
if (fields.has("imageUrls") && imageUrls.length > 0) {
body.imageUrls = imageUrls.slice(0, 8);
}
if (fields.has("imageUrl") && imageUrls[0]) {
body.imageUrl = imageUrls[0];
}
if (fields.has("image_url") && imageUrls[0]) {
body.image_url = imageUrls[0];
}
if (fields.has("styleImages") && imageUrls.length > 0 && !body.imageUrls && !body.imageUrl) {
body.styleImages = buildStyleImages(imageUrls.slice(0, 4));
}
if (fields.has("imageStyleRefs") && imageUrls.length > 1) {
body.imageStyleRefs = buildStyleImages(imageUrls.slice(1, 5));
}
if (fields.has("characterReferenceImages") && imageUrls.length > 0) {
body.characterReferenceImages = imageUrls.slice(0, 4);
const imageUrlsField = getPreferredKreaField(
fields,
isImageLike ? ["image_urls", "imageUrls"] : ["imageUrls", "image_urls"]
);
if (imageUrlsField && imageUrls.length > 0) {
body[imageUrlsField] = imageUrls.slice(0, 8);
}
const imageUrlField = getPreferredKreaField(
fields,
isImageLike ? ["image_url", "imageUrl"] : ["imageUrl", "image_url"]
);
if (imageUrlField && imageUrls[0]) {
body[imageUrlField] = imageUrls[0];
}
const styleImagesField = getPreferredKreaField(
fields,
isImageLike ? ["style_images", "styleImages"] : ["styleImages", "style_images"]
);
if (
fields.has("referenceImages") &&
styleImagesField &&
imageUrls.length > 0 &&
!hasAnyKreaField(new Set(Object.keys(body)), [
"image_urls",
"imageUrls",
"image_url",
"imageUrl",
])
) {
body[styleImagesField] = buildStyleImages(imageUrls.slice(0, 4));
}
const imageStyleReferencesField = getPreferredKreaField(fields, [
"image_style_references",
"imageStyleRefs",
]);
if (imageStyleReferencesField && imageUrls.length > 1) {
body[imageStyleReferencesField] = buildStyleImages(imageUrls.slice(1, 5));
}
const characterReferenceImagesField = getPreferredKreaField(fields, [
"character_reference_images",
"characterReferenceImages",
]);
if (characterReferenceImagesField && imageUrls.length > 0) {
body[characterReferenceImagesField] = imageUrls.slice(0, 4);
}
const referenceImagesField = getPreferredKreaField(fields, [
"reference_images",
"referenceImages",
]);
if (
referenceImagesField &&
imageUrls.length > 0 &&
(!isVideo || videoMode === "reference-images")
) {
body.referenceImages = endpoint.includes("/image/runway/gen-4-image")
body[referenceImagesField] = endpoint.includes("/image/runway/gen-4-image")
? buildTaggedReferences(imageUrls, "ref", 3)
: imageUrls.slice(0, endpoint.includes("/seedance-2") ? 9 : 4);
}
@ -970,27 +1036,50 @@ function buildKreaBody(params: {
if (fields.has("subjects") && imageUrls[0] && videoParams?.mode === "reference-images") {
body.subjects = [{ type: "character", image: [imageUrls[0]] }];
}
if (fields.has("styleReferences") && imageUrls.length > 0) {
body.styleReferences = buildTaggedReferences(imageUrls, "style", 3);
const styleReferencesField = getPreferredKreaField(fields, [
"style_references",
"styleReferences",
]);
if (styleReferencesField && imageUrls.length > 0) {
body[styleReferencesField] = buildTaggedReferences(imageUrls, "style", 3);
}
if (fields.has("elementReferences") && imageUrls.length > 0) {
body.elementReferences = [
const elementReferencesField = getPreferredKreaField(fields, [
"element_references",
"elementReferences",
]);
if (elementReferencesField && imageUrls.length > 0) {
body[elementReferencesField] = [
{
tag: "element_1",
images: imageUrls.slice(0, 3),
},
];
}
if (fields.has("videoReference") && videoUrls[0]) {
body.videoReference = { url: videoUrls[0], tag: "video_1" };
const videoReferenceField = getPreferredKreaField(fields, [
"video_reference",
"videoReference",
]);
if (videoReferenceField && videoUrls[0]) {
body[videoReferenceField] = { url: videoUrls[0], tag: "video_1" };
}
if (fields.has("startVideo") && videoUrls[0]) body.startVideo = videoUrls[0];
if (fields.has("init_video") && videoUrls[0]) body.init_video = videoUrls[0];
if (fields.has("referenceVideos") && videoUrls.length > 0) {
body.referenceVideos = videoUrls.slice(0, 3);
const referenceVideosField = getPreferredKreaField(fields, [
"reference_videos",
"referenceVideos",
]);
if (referenceVideosField && videoUrls.length > 0) {
body[referenceVideosField] = videoUrls.slice(0, 3);
}
if (required.has("referenceImages") && !body.referenceImages) {
if (
(required.has("referenceImages") || required.has("reference_images")) &&
!body.referenceImages &&
!body.reference_images
) {
throw new Error("该 Krea 模型需要至少 1 张参考图");
}
if ((required.has("imageUrl") || required.has("image_url")) && !body.imageUrl && !body.image_url) {
@ -1038,7 +1127,7 @@ export function createKrea({ apiKey, baseURL }: KreaConfig) {
preset?.requestFields ||
(kind === "video"
? ["prompt", "aspectRatio", "duration", "startImage", "endImage", "referenceImages"]
: ["prompt", "width", "height", "imageUrls", "imageUrl", "batchSize"]);
: ["prompt", "width", "height", "image_urls", "image_url"]);
const requiredFields = preset?.requiredFields || ["prompt"];
const requestedImageAspectRatio = normalizeText(options.imageParams?.aspectRatio).toLowerCase();
const referenceAspectRatio =