merge: sync settings updates into main
This commit is contained in:
commit
25a1a005a4
30
app/(admin)/_components/admin-layout-shell.tsx
Normal file
30
app/(admin)/_components/admin-layout-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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)}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
58
app/api/ai-wash/config/route.ts
Normal file
58
app/api/ai-wash/config/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
133
app/api/ai-wash/generate/route.ts
Normal file
133
app/api/ai-wash/generate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/ai-wash/model-options/route.ts
Normal file
38
app/api/ai-wash/model-options/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
1548
components/canvas/AiWashWorkspace.tsx
Normal file
1548
components/canvas/AiWashWorkspace.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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
310
lib/ai-wash/config.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user