From ffccdecc90f3c9d88235eea04bfc9946ae4e73f6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 14:11:59 +0800 Subject: [PATCH 01/19] feat: streamline admin settings layout --- .../_components/admin-layout-shell.tsx | 30 ++ app/(admin)/layout.tsx | 67 ++-- .../_components/admin-settings-navigation.tsx | 323 ++++++++++++++---- .../settings/admin-settings-client.tsx | 9 +- 4 files changed, 321 insertions(+), 108 deletions(-) create mode 100644 app/(admin)/_components/admin-layout-shell.tsx diff --git a/app/(admin)/_components/admin-layout-shell.tsx b/app/(admin)/_components/admin-layout-shell.tsx new file mode 100644 index 000000000..8dc2a86b3 --- /dev/null +++ b/app/(admin)/_components/admin-layout-shell.tsx @@ -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 & { + children: ReactNode; +}; + +function shouldHideSidebarChrome(pathname: string) { + return pathname === "/settings"; +} + +export function AdminLayoutShell({ + children, + ...topNavbarProps +}: AdminLayoutShellProps) { + const pathname = usePathname(); + const hideSidebarChrome = shouldHideSidebarChrome(pathname); + + return ( +
+ {hideSidebarChrome ? null : } +
+ {children} +
+
+ ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index fd960eca7..703e03359 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -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 ( -
- ({ - 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}
-
+ ({ + 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} + ); } diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index 5fc9010c0..acb571cbe 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -1,91 +1,272 @@ "use client"; import Link from "next/link"; -import { TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ArrowLeft, ChevronDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation"; +import type { AdminSettingsNavGroup, AdminSettingsNavItem } from "../_lib/types"; import { SettingStatusBadge } from "./setting-status-badge"; -export function AdminSettingsNavigation({ activeTab }: { activeTab: string }) { +function getActiveGroup(activeTab: string) { + return ADMIN_SETTINGS_NAV_GROUPS.find((group) => + group.items.some((item) => item.value === activeTab) + ); +} + +function getActiveItem(activeTab: string) { + return ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items).find( + (item) => item.value === activeTab + ); +} + +function SettingsGroupDropdown({ + activeTab, + group, + onTabChange, +}: { + activeTab: string; + group: AdminSettingsNavGroup; + onTabChange: (value: string) => void; +}) { + const hasActiveTab = group.items.some((item) => item.value === activeTab); + return ( -
-
-
+ + + + + + {group.title} + + {group.items.map((item) => { + const Icon = item.icon; + const itemContent = ( + <> + +
+
{item.label}
+
+ {item.description} +
+
+ {item.value === activeTab ? ( + 当前 + ) : null} + + ); + + if (item.href) { + return ( + + {itemContent} + + ); + } + + return ( + onTabChange(item.value)} + > + {itemContent} + + ); + })} +
+
+ ); +} + +function SettingsQuickAction({ + activeTab, + item, + onTabChange, +}: { + activeTab: string; + item: AdminSettingsNavItem; + onTabChange: (value: string) => void; +}) { + const Icon = item.icon; + const className = + item.value === activeTab + ? "border-primary bg-primary text-primary-foreground hover:bg-primary/90" + : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"; + + if (item.href) { + return ( + + ); + } + + return ( + + ); +} + +export function AdminSettingsNavigation({ + activeTab, + onTabChange, +}: { + activeTab: string; + onTabChange: (value: string) => void; +}) { + const activeGroup = getActiveGroup(activeTab); + const activeItem = getActiveItem(activeTab); + const ActiveIcon = activeItem?.icon; + + return ( +
+
+
Admin Console
-

- 系统设置中心 -

+
+

+ 系统设置中心 +

+ {activeGroup ? ( + {activeGroup.title} + ) : null} +

- 按后台人员的操作路径重新组织入口:先处理运营数据,再调整站点体验,最后管理模型与基础设施能力。 + 页面已改成横向工作区:顶部收拢分类入口,正文区域尽量留给配置表单和数据内容。

+
-
- {ADMIN_SETTINGS_NAV_GROUPS.map((group) => { - const hasActiveTab = group.items.some( - (item) => item.value === activeTab - ); - return ( -
-
-
-

{group.title}

-

- {group.description} -

-
- {hasActiveTab ? ( - 当前 - ) : null} -
- - {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 = ( - <> - - - - {item.label} - - - {item.description} - - - - ); - if (item.href) { - return ( - - {content} - - ); - } +
+
+ - return ( - - {content} - - ); - })} - -
- ); - })} +
+ {(activeGroup?.items ?? []).map((item) => ( + + ))} +
+
+ +
+ {ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( + + ))} + + + +
+ {(activeGroup?.items ?? []).map((item) => ( + + ))} +
+
+ + {activeItem && ActiveIcon ? ( +
+
+ +
+
+
+

{activeItem.label}

+ {activeGroup ? ( + + {activeGroup.title} + + ) : null} +
+

+ {activeItem.description} +

+
+
+ ) : null}
); } diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 38a2e25ff..ec7e1762e 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -126,7 +126,7 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { defaultAgentWorkflowUiConfig, @@ -7996,9 +7996,12 @@ export function AdminSettingsClient() { }, [expandedLocalAiSections]); return ( -
+
- + {/* 用户管理 */} From de70a23ff6eafb15eec0663ff3147e4e6836f484 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 14:24:27 +0800 Subject: [PATCH 02/19] refactor: tighten admin settings toolbar --- .../_components/admin-settings-navigation.tsx | 253 ++++++++++-------- 1 file changed, 142 insertions(+), 111 deletions(-) diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index acb571cbe..601ff24bb 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -24,6 +24,7 @@ import { import { Separator } from "@/components/ui/separator"; import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation"; import type { AdminSettingsNavGroup, AdminSettingsNavItem } from "../_lib/types"; +import { cn } from "@/lib/utils"; import { SettingStatusBadge } from "./setting-status-badge"; function getActiveGroup(activeTab: string) { @@ -53,16 +54,26 @@ function SettingsGroupDropdown({ - {group.title} + +
{group.title}
+
+ {group.description} +
+
{group.items.map((item) => { const Icon = item.icon; @@ -115,14 +126,14 @@ function SettingsQuickAction({ const Icon = item.icon; const className = item.value === activeTab - ? "border-primary bg-primary text-primary-foreground hover:bg-primary/90" - : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"; + ? "border-foreground/10 bg-foreground text-background shadow-sm hover:bg-foreground/90 dark:border-white/20 dark:bg-white dark:text-black dark:hover:bg-white/90" + : "border-transparent bg-transparent text-muted-foreground hover:border-border/80 hover:bg-background hover:text-foreground"; if (item.href) { return ( -
- -
-
- - -
- {(activeGroup?.items ?? []).map((item) => ( - - ))} -
-
- -
- {ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( - - ))} - - - -
- {(activeGroup?.items ?? []).map((item) => ( - - ))} -
-
-
- - {activeItem && ActiveIcon ? ( -
-
- -
-
-
-

{activeItem.label}

+
+
+
+
+
+ Admin Console {activeGroup ? ( - - {activeGroup.title} - + <> + + + {activeGroup.title} + + + ) : null} +
+ +
+

+ 系统设置中心 +

+ {activeItem && ActiveIcon ? ( +
+
+ +
+
+
+ + {activeItem.label} + + 当前 +
+
+
+ ) : null} +
+
+ + +
+ +
+
+ + + {activeItem ? ( +

+ {activeItem.description} +

+ ) : null} + +
+ {(activeGroup?.items ?? []).map((item) => ( + + ))} +
+
+ +
+
+ {ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( + + ))} +
+ + + +
+
+ {(activeGroup?.items ?? []).map((item) => ( + + ))} +
+ {activeItem ? ( +

+ {activeItem.description} +

) : null}
-

- {activeItem.description} -

- ) : null} +
); } From fbfe3256c8eb16a7b5d7cc26909e88064cc0ec11 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 14:32:11 +0800 Subject: [PATCH 03/19] refactor: unify admin settings toolbar --- .../_components/admin-settings-navigation.tsx | 37 ++++++------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index 601ff24bb..ce6465a57 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -246,12 +246,6 @@ export function AdminSettingsNavigation({ - {activeItem ? ( -

- {activeItem.description} -

- ) : null} -
{(activeGroup?.items ?? []).map((item) => (
-
-
+
+
{ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( ))} -
- + -
-
- {(activeGroup?.items ?? []).map((item) => ( - - ))} -
- {activeItem ? ( -

- {activeItem.description} -

- ) : null} + {(activeGroup?.items ?? []).map((item) => ( + + ))}
From 155539693cd27993966a09738181dd11ec8390b1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 14:51:20 +0800 Subject: [PATCH 04/19] refactor: flatten admin settings header --- .../_components/admin-settings-navigation.tsx | 192 ++++++++---------- 1 file changed, 86 insertions(+), 106 deletions(-) diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index ce6465a57..4a08e25a6 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -33,12 +33,6 @@ function getActiveGroup(activeTab: string) { ); } -function getActiveItem(activeTab: string) { - return ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items).find( - (item) => item.value === activeTab - ); -} - function SettingsGroupDropdown({ activeTab, group, @@ -166,123 +160,109 @@ export function AdminSettingsNavigation({ onTabChange: (value: string) => void; }) { const activeGroup = getActiveGroup(activeTab); - const activeItem = getActiveItem(activeTab); - const ActiveIcon = activeItem?.icon; return (
-
-
-
-
- Admin Console - {activeGroup ? ( - <> - - - {activeGroup.title} - - - ) : null} -
- -
-

- 系统设置中心 -

- {activeItem && ActiveIcon ? ( -
-
- -
-
-
- - {activeItem.label} - - 当前 -
-
-
- ) : null} -
-
+
+
+
-
-
- - -
- {(activeGroup?.items ?? []).map((item) => ( - - ))} -
-
- -
-
- {ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( - - ))} - - - - {(activeGroup?.items ?? []).map((item) => ( - - ))} -
-
+
+ {(activeGroup?.items ?? []).map((item) => ( + + ))}
+ +
+
+ Admin Console +
+ + {activeGroup ? ( + <> + + + {activeGroup.title} + + + ) : null} + + + +
+ {ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( + + ))} + + + + {(activeGroup?.items ?? []).map((item) => ( + + ))} +
+ + +
); } From aea1d84c88e579394ccc7c5061c9612cdd27aa38 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 15:11:52 +0800 Subject: [PATCH 05/19] refactor: improve analytics dashboard --- .../settings/admin-settings-client.tsx | 1351 ++++++++++------- app/api/admin/images/analytics/route.ts | 165 +- 2 files changed, 895 insertions(+), 621 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index ec7e1762e..ccc572573 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1420,12 +1420,97 @@ type ImageAnalytics = { modelTrendStats: Array<{ date: string; modelId: string | null; + modelName: string | null; + providerName: string | null; + providerType: string | null; count: number; }>; monthlyStats: Array<{ month: string; count: number }>; hourlyStats: Array<{ hour: number; count: number }>; }; +type RuntimeModelMeta = { + modelName: string | null; + providerName: string | null; + providerType: string | null; +}; + +type RuntimeModelCandidate = RuntimeModelMeta & { + modelId: string | null; + isEnabled?: boolean; + isHidden?: boolean; + isDefault?: boolean; + isSubModel?: boolean; + sortOrder?: number | null; +}; + +const analyticsNumberFormatter = new Intl.NumberFormat("zh-CN"); + +function scoreRuntimeModelCandidate(candidate: RuntimeModelCandidate) { + return ( + (candidate.isEnabled ? 100 : 0) + + (!candidate.isHidden ? 20 : 0) + + (!candidate.isSubModel ? 10 : 0) + + (candidate.isDefault ? 8 : 0) + + Math.max(0, 1000 - Number(candidate.sortOrder ?? 999)) + ); +} + +function buildRuntimeModelMetaMap(candidates: RuntimeModelCandidate[]) { + const map = new Map(); + + candidates.forEach((candidate) => { + const modelId = String(candidate.modelId || "").trim(); + if (!modelId) return; + const score = scoreRuntimeModelCandidate(candidate); + const current = map.get(modelId); + if (current && current.score >= score) return; + map.set(modelId, { + score, + modelName: candidate.modelName || null, + providerName: candidate.providerName || null, + providerType: candidate.providerType || null, + }); + }); + + return map; +} + +function resolveRuntimeModelMeta( + runtimeModelMetaById: Map, + modelId: string | null +): RuntimeModelMeta { + if (!modelId) { + return { + modelName: null, + providerName: null, + providerType: null, + }; + } + + const resolved = runtimeModelMetaById.get(modelId); + return { + modelName: resolved?.modelName || null, + providerName: resolved?.providerName || null, + providerType: resolved?.providerType || null, + }; +} + +function formatAnalyticsNumber(value: number | null | undefined) { + return analyticsNumberFormatter.format(value || 0); +} + +function formatAnalyticsRatio(count: number, total: number) { + if (!total) return "0%"; + return `${((count / total) * 100).toFixed(1)}%`; +} + +function formatAnalyticsMonthLabel(value: string) { + const [year, month] = String(value || "").split("-"); + if (!year || !month) return value || "本月"; + return `${year} 年 ${month} 月`; +} + const AVAILABLE_TOOLS = [ { name: "getWeather", description: "获取天气信息" }, { name: "createDocument", description: "创建文档" }, @@ -1750,6 +1835,157 @@ export function AdminSettingsClient() { : null; const { data: analyticsData } = useSWR(analyticsUrl, fetcher); + const runtimeModelMetaById = useMemo(() => { + const providerMetaById = new Map< + string, + { providerName: string | null; providerType: string | null } + >(); + (providersData?.providers || []).forEach((provider) => { + providerMetaById.set(provider.id, { + providerName: provider.name, + providerType: provider.type, + }); + }); + + const candidates: RuntimeModelCandidate[] = []; + + (modelsData?.models || []).forEach((model) => { + const providerMeta = providerMetaById.get(model.providerId); + candidates.push({ + modelId: model.modelId, + modelName: model.displayName, + providerName: providerMeta?.providerName || null, + providerType: providerMeta?.providerType || null, + isEnabled: model.isEnabled, + isHidden: model.isHidden, + isDefault: model.isDefault, + isSubModel: model.isSubModel, + sortOrder: model.sortOrder, + }); + }); + + (analyticsData?.modelDetailStats || []).forEach((stat) => { + candidates.push({ + modelId: stat.modelId, + modelName: stat.modelName, + providerName: stat.providerName, + providerType: stat.providerType, + }); + }); + + (analyticsData?.modelTrendStats || []).forEach((stat) => { + candidates.push({ + modelId: stat.modelId, + modelName: stat.modelName, + providerName: stat.providerName, + providerType: stat.providerType, + }); + }); + + return buildRuntimeModelMetaMap(candidates); + }, [ + analyticsData?.modelDetailStats, + analyticsData?.modelTrendStats, + modelsData?.models, + providersData?.providers, + ]); + + const analyticsOverviewCards = useMemo(() => { + const currentMonth = `${new Date().getFullYear()}-${String( + new Date().getMonth() + 1 + ).padStart(2, "0")}`; + return [ + { + key: "total-images", + label: "总生成量", + value: formatAnalyticsNumber(analyticsData?.overview.totalImages), + note: "累计图片资产", + icon: ImageIcon, + tone: "from-zinc-950 via-zinc-900 to-zinc-800 text-white", + }, + { + key: "total-users", + label: "活跃用户", + value: formatAnalyticsNumber(analyticsData?.overview.totalUsers), + note: "参与生成的用户数", + icon: Users, + tone: "from-emerald-500/15 via-emerald-500/5 to-transparent text-emerald-700", + }, + { + key: "today-images", + label: "今日生成", + value: formatAnalyticsNumber(analyticsData?.overview.todayImages), + note: `${formatAnalyticsNumber(analyticsData?.overview.todayUsers)} 位用户`, + icon: TrendingUp, + tone: "from-amber-500/20 via-amber-500/5 to-transparent text-amber-700", + }, + { + key: "month-images", + label: selectedMonth === currentMonth ? "本月生成" : formatAnalyticsMonthLabel(selectedMonth), + value: formatAnalyticsNumber(analyticsData?.overview.monthImages), + note: `${formatAnalyticsNumber(analyticsData?.overview.monthUsers)} 位用户`, + icon: Calendar, + tone: "from-lime-400/25 via-lime-300/10 to-transparent text-lime-700", + }, + ]; + }, [analyticsData?.overview, selectedMonth]); + + const analyticsPeakHour = useMemo(() => { + return (analyticsData?.hourlyStats || []).reduce<{ + hour: number; + count: number; + } | null>((best, stat) => { + if (!best || stat.count > best.count) { + return { hour: stat.hour, count: stat.count }; + } + return best; + }, null); + }, [analyticsData?.hourlyStats]); + + const analyticsRecentMonths = useMemo( + () => analyticsData?.monthlyStats?.slice(-6) || [], + [analyticsData?.monthlyStats] + ); + + const analyticsTrendDays = useMemo(() => { + const grouped = new Map< + string, + { + date: string; + total: number; + models: ImageAnalytics["modelTrendStats"]; + } + >(); + + (analyticsData?.modelTrendStats || []).forEach((stat) => { + const current = grouped.get(stat.date) || { + date: stat.date, + total: 0, + models: [], + }; + current.total += stat.count; + current.models.push(stat); + grouped.set(stat.date, current); + }); + + return Array.from(grouped.values()) + .sort((left, right) => left.date.localeCompare(right.date)) + .slice(-7) + .map((item) => ({ + ...item, + models: [...item.models].sort((left, right) => right.count - left.count), + })); + }, [analyticsData?.modelTrendStats]); + + const analyticsModelTotal = useMemo( + () => + (analyticsData?.modelDetailStats || []).reduce( + (sum, stat) => sum + stat.count, + 0 + ), + [analyticsData?.modelDetailStats] + ); + const detailAgentModelOptions = useMemo( () => (modelsData?.models || []).filter( @@ -8585,9 +8821,10 @@ export function AdminSettingsClient() {

- {analyticsData?.modelDetailStats?.find( - (s) => s.modelId === selectedImage.modelId - )?.modelName || + {resolveRuntimeModelMeta( + runtimeModelMetaById, + selectedImage.modelId + ).modelName || selectedImage.modelId || "未知"}

@@ -8729,651 +8966,589 @@ export function AdminSettingsClient() { {/* 🆕 数据分析 */} - - {/* 概览卡片 */} -
- - -
-
- + +
+ + +
+
+ analytics overview
-
-

- {analyticsData?.overview.totalImages || 0} -

-

总生图数

-
-
- -
- - -
-
- -
-
-

- {analyticsData?.overview.totalUsers || 0} -

-

总用户数

-
-
-
-
- - -
-
- -
-
-

- {analyticsData?.overview.todayImages || 0} -

-

今日生图

-
-
-
-
- - -
-
-
- +
+
+ + 生成与运营总览 + + + 当前面板聚合最近 30 天趋势、当日峰值时段和模型使用分布。 + {selectedDate + ? ` 当前已锁定 ${selectedDate} 的小时分布。` + : " 点击趋势柱可联动查看某一天的小时明细。"} +
-
-

- {analyticsData?.overview.monthImages || 0} -

-

- {selectedMonth === - `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}` - ? "本月生图" - : `${selectedMonth} 生图`} -

+
+ + {formatAnalyticsMonthLabel(selectedMonth)} + {selectedDate ? ( + + {selectedDate} + + ) : null}
+
+ + +
+ {analyticsOverviewCards.map((item) => { + const Icon = item.icon; + return ( +
+
+
+
+ {item.label} +
+
+ {item.value} +
+
+
+ +
+
+
+ {item.note} +
+
+ ); + })} +
+
+ + + + +
+ filters & pulse +
+ 时间筛选与月度脉冲 + + 用月份控制本月指标卡,并通过日期联动小时分布。 + +
+ +
+ setSelectedMonth(e.target.value)} type="month" value={selectedMonth} />
+ +
+
+
+ + 高峰时段 +
+
+ {analyticsPeakHour + ? `${String(analyticsPeakHour.hour).padStart(2, "0")}:00` + : "--:--"} +
+
+ {analyticsPeakHour + ? `${formatAnalyticsNumber(analyticsPeakHour.count)} 次生成` + : "暂无小时数据"} +
+
+ +
+
+ + 领先模型 +
+
+ {resolveRuntimeModelMeta( + runtimeModelMetaById, + analyticsData?.modelDetailStats?.[0]?.modelId || null + ).modelName || + analyticsData?.modelDetailStats?.[0]?.modelId || + "暂无"} +
+
+ {analyticsData?.modelDetailStats?.[0] + ? `${formatAnalyticsNumber( + analyticsData.modelDetailStats[0].count + )} 次调用` + : "还没有模型统计结果"} +
+
+
+ +
+
+ recent months +
+
+ {analyticsRecentMonths.map((stat) => ( +
+
+ {stat.month} +
+
+ {formatAnalyticsNumber(stat.count)} +
+
+ ))} +
+
-
- {/* 用户排行 */} - - - - 用户生图排行 (Top 20) - +
+ + +
+
+ throughput +
+ 生成节奏 + + 左侧看最近 30 天总量,右侧看 + {selectedDate ? ` ${selectedDate} ` : "当前选择日期 "} + 的小时分布。 + +
+ {selectedDate ? ( + + ) : null}
- -
- {analyticsData?.userStats?.map((user, index) => ( -
-
- +
+
+ 近 30 天生成量 + 点击柱体联动小时分布 +
+
+ {analyticsData?.dailyStats?.map((stat, index) => { + const maxCount = Math.max( + ...(analyticsData.dailyStats?.map((s) => s.count) || [1]) + ); + const height = (stat.count / maxCount) * 100; + const isHovered = + hoveredBar?.type === "daily" && + hoveredBar?.index === index; + const isSelected = selectedDate === stat.date; + return ( +
+ setSelectedDate( + selectedDate === stat.date ? null : stat.date + ) + } + onMouseEnter={() => + setHoveredBar({ + type: "daily", + index, + value: stat.count, + label: stat.date, + }) + } + onMouseLeave={() => setHoveredBar(null)} + style={{ height: `${Math.max(height, 5)}%` }} > - {index + 1} - - - {user.userName || user.userEmail?.split("@")[0]} - -
- {user.imageCount} -
- ))} + {isHovered ? ( +
+ {stat.date} · {formatAnalyticsNumber(stat.count)} 次 +
+ ) : null} +
+ ); + })} +
+
+ {analyticsData?.dailyStats?.[0]?.date || "--"} + {analyticsData?.dailyStats?.at(-1)?.date || "--"} +
+
+ +
+
+ 24 小时分布 + {selectedDate || "默认最近样本"} +
+
+ {Array.from({ length: 24 }, (_, hour) => { + const stat = analyticsData?.hourlyStats?.find( + (item) => item.hour === hour + ); + const count = stat?.count || 0; + const maxCount = Math.max( + ...(analyticsData?.hourlyStats?.map((s) => s.count) || [ + 1, + ]), + 1 + ); + const height = (count / maxCount) * 100; + const isHovered = + hoveredBar?.type === "hourly" && + hoveredBar?.index === hour; + return ( +
+ setHoveredBar({ + type: "hourly", + index: hour, + value: count, + label: `${hour}:00`, + }) + } + onMouseLeave={() => setHoveredBar(null)} + style={{ height: `${Math.max(height, 4)}%` }} + > + {isHovered ? ( +
+ {String(hour).padStart(2, "0")}:00 ·{" "} + {formatAnalyticsNumber(count)} 次 +
+ ) : null} +
+ ); + })} +
+
+ 00:00 + 12:00 + 23:00 +
- {/* 图片尺寸分布 */} - - - 图片尺寸分布 - - -
+
+ + +
+ top users +
+ 高活跃用户 + 按生成次数排序,便于识别主要使用群体。 +
+ +
+ {analyticsData?.userStats?.slice(0, 8).map((user, index) => ( +
+
+
+ {index + 1} +
+
+
+ {user.userName || user.userEmail?.split("@")[0]} +
+
+ {user.userEmail} +
+
+
+
+
+ {formatAnalyticsNumber(user.imageCount)} +
+
+ 次生成 +
+
+
+ ))} + {(!analyticsData?.userStats || + analyticsData.userStats.length === 0) && ( +
+ 暂无用户排行数据 +
+ )} +
+
+
+ + + +
+ output sizes +
+ 尺寸分布 + 观察用户输出尺寸的偏好与占比。 +
+ {analyticsData?.sizeStats?.map((stat) => { - const total = analyticsData.overview.totalImages || 1; - const percentage = ((stat.count / total) * 100).toFixed(1); + const percentage = formatAnalyticsRatio( + stat.count, + analyticsData?.overview.totalImages || 0 + ); return (
-
- {stat.imageSize || "未知"} +
+ + {stat.imageSize || "未知"} + - {stat.count} ({percentage}%) + {formatAnalyticsNumber(stat.count)} · {percentage}
-
+
); })} -
- - - - {/* 每日趋势 */} - - - 最近30天趋势 - {selectedDate && ( - - )} - - -
- {analyticsData?.dailyStats?.map((stat, index) => { - const maxCount = Math.max( - ...(analyticsData.dailyStats?.map((s) => s.count) || [1]) - ); - const height = (stat.count / maxCount) * 100; - const isHovered = - hoveredBar?.type === "daily" && - hoveredBar?.index === index; - const isSelected = selectedDate === stat.date; - return ( -
- setSelectedDate( - selectedDate === stat.date ? null : stat.date - ) - } - onMouseEnter={() => - setHoveredBar({ - type: "daily", - index, - value: stat.count, - label: stat.date, - }) - } - onMouseLeave={() => setHoveredBar(null)} - style={{ height: `${Math.max(height, 2)}%` }} - > - {isHovered && ( -
- {stat.date}: {stat.count} 张 -
- 点击查看当天高峰时段 -
-
- )} -
- ); - })} -
-
- {analyticsData?.dailyStats?.[0]?.date} - {analyticsData?.dailyStats?.at(-1)?.date} -
-
-
- - {/* 高峰时段 */} - - - - 生图高峰时段 - {selectedDate && ( - - {selectedDate} - - )} - - {!selectedDate && ( - - 点击左侧日期柱形查看当天数据 - - )} - - -
- {Array.from({ length: 24 }, (_, hour) => { - const stat = analyticsData?.hourlyStats?.find( - (s) => s.hour === hour - ); - const count = stat?.count || 0; - const maxCount = Math.max( - ...(analyticsData?.hourlyStats?.map((s) => s.count) || [ - 1, - ]), - 1 - ); - const height = (count / maxCount) * 100; - const isHovered = - hoveredBar?.type === "hourly" && - hoveredBar?.index === hour; - return ( -
- setHoveredBar({ - type: "hourly", - index: hour, - value: count, - label: `${hour}:00`, - }) - } - onMouseLeave={() => setHoveredBar(null)} - style={{ height: `${Math.max(height, 2)}%` }} - > - {isHovered && ( -
- {hour}:00-{hour + 1}:00: {count} 张 -
- )} -
- ); - })} -
-
- 0:00 - 12:00 - 23:00 -
-
-
- - {/* 月度统计 */} - - - 月度统计 - - -
- {analyticsData?.monthlyStats?.slice(-6).map((stat) => ( -
-

- {stat.count} -

-

- {stat.month} -

-
- ))} -
-
-
+ + +
- {/* 🆕 模型使用分析 */} -
-

- - 模型使用分析 -

-
- {/* 模型使用排行 */} - - - 模型使用排行 - - 按生图数量排序 - - - -
- {analyticsData?.modelDetailStats - ?.slice(0, 8) - .map((stat, index) => { - const maxCount = - analyticsData?.modelDetailStats?.[0]?.count || 1; - const percentage = (stat.count / maxCount) * 100; - return ( -
-
-
- - {index + 1} - - - {stat.modelName || stat.modelId || "未知模型"} - -
-
- - {stat.count} - - - 张 - +
+ + +
+ model usage +
+ 模型使用概览 + + 修正为按 runtime modelId 聚合后再解析名称,避免跨 provider 串名。 + +
+ +
+ {(analyticsData?.modelDetailStats || []).slice(0, 6).map((stat, index) => { + const resolved = resolveRuntimeModelMeta( + runtimeModelMetaById, + stat.modelId + ); + return ( +
+
+
+
+ + {index + 1} + +
+ {resolved.modelName || stat.modelId || "未知模型"}
-
-
-
-
- {stat.providerName || "未知提供商"} - {stat.userCount} 用户 +
+ + {resolved.providerName || "未知提供商"} + + {formatAnalyticsNumber(stat.userCount)} 位用户
- ); - })} - {(!analyticsData?.modelDetailStats || - analyticsData.modelDetailStats.length === 0) && ( -
- 暂无数据 +
+
+ {formatAnalyticsNumber(stat.count)} +
+
+ {formatAnalyticsRatio(stat.count, analyticsModelTotal)} +
+
+
+
+
+
- )} -
- - + ); + })} + {(!analyticsData?.modelDetailStats || + analyticsData.modelDetailStats.length === 0) && ( +
+ 暂无模型使用数据 +
+ )} +
- {/* 提供商类型分布 */} - - - 提供商分布 - - 按 AI 提供商统计 - - - -
- {(() => { - const providerColors: Record = { - gemini: "bg-purple-500", - "bltcy-video": "bg-cyan-500", - "volcano-vision": "bg-orange-500", - "volcano-video": "bg-red-500", - "doubao-video": "bg-red-400", - "fal-video": "bg-indigo-500", - "ruxa-video": "bg-violet-500", - alibaba: "bg-blue-500", - "alibaba-image": "bg-amber-500", - "bfl-image": "bg-cyan-600", - "ruxa-image": "bg-emerald-500", - "fal-image": "bg-sky-500", - "openai-image": "bg-green-600", - "stepfun-image": "bg-indigo-600", - "grsai-gpt-image": "bg-emerald-600", - "grsai-video": "bg-orange-600", - midjourney: "bg-fuchsia-600", - "tripo-3d": "bg-teal-500", - "worldlabs-3d": "bg-sky-600", - "kittycad-cad": "bg-orange-500", - openai: "bg-green-500", - zhipu: "bg-lime-500", - "volcano-multimodal": "bg-fuchsia-500", - runway: "bg-rose-500", - krea: "bg-blue-500", - "qianwen-video": "bg-cyan-500", - custom: "bg-gray-500", - local: "bg-yellow-500", - }; +
+
+
+ model traffic +
+
+ {formatAnalyticsNumber(analyticsModelTotal)} +
+
+ 当前榜单内模型累计调用次数 +
+
+ +
+ {(analyticsData?.providerTypeStats || []).map((stat, index) => { const total = analyticsData?.providerTypeStats?.reduce( - (acc, s) => acc + s.count, + (sum, item) => sum + item.count, 0 ) || 0; - - return analyticsData?.providerTypeStats?.map( - (stat, index) => { - const percentage = - total > 0 - ? ((stat.count / total) * 100).toFixed(1) - : "0"; - const color = - providerColors[stat.providerType || ""] || - "bg-gray-400"; - return ( -
-
-
-
- - {stat.providerName || - stat.providerType || - "未知"} - - - - {stat.count} - - - ({percentage}%) - - -
-
-
-
-
-
- ); - } - ); - })()} - {(!analyticsData?.providerTypeStats || - analyticsData.providerTypeStats.length === 0) && ( -
- 暂无数据 -
- )} -
- - - - {/* 图片尺寸分布 */} - - - 图片尺寸分布 - - 按生成图片尺寸统计 - - - -
- {analyticsData?.sizeStats?.map((stat) => { - const total = - analyticsData?.sizeStats?.reduce( - (acc, s) => acc + s.count, - 0 - ) || 0; - const percentage = - total > 0 - ? ((stat.count / total) * 100).toFixed(1) - : "0"; + const width = formatAnalyticsRatio(stat.count, total); return (
-

- {stat.imageSize || "未知"} -

-

- {stat.count} 张 -

-

- {percentage}% -

+
+ + {stat.providerName || stat.providerType || "未知提供商"} + + + {formatAnalyticsNumber(stat.count)} · {width} + +
+
+
+
); })}
- - +
+
+
- {/* 模型使用趋势(最近7天) */} - - - 模型使用趋势 - - 最近7天各模型使用量 - - - -
- {(() => { - // 按日期分组 - const groupedByDate = - analyticsData?.modelTrendStats?.reduce( - (acc, stat) => { - if (!acc[stat.date]) acc[stat.date] = []; - acc[stat.date].push(stat); - return acc; - }, - {} as Record< - string, - typeof analyticsData.modelTrendStats + + +
+ recent 7 days +
+ 模型日趋势 + 每天显示总量,并直接列出当日最活跃的模型。 +
+ + {analyticsTrendDays.map((day) => { + const topModels = day.models.slice(0, 3); + return ( +
+
+
+
+ {day.date} +
+
+ 当日共 {formatAnalyticsNumber(day.total)} 次生成 +
+
+
+ {formatAnalyticsNumber(day.models.length)} 个模型活跃 +
+
+
+ {topModels.map((stat) => ( +
- ) || {}; - - const dates = Object.keys(groupedByDate).sort().slice(-7); - const maxDailyTotal = Math.max( - ...dates.map( - (date) => - groupedByDate[date]?.reduce( - (sum, s) => sum + s.count, - 0 - ) || 0 - ), - 1 - ); - - return dates.map((date) => { - const dayStats = groupedByDate[date] || []; - const dayTotal = dayStats.reduce( - (sum, s) => sum + s.count, - 0 - ); - const heightPercent = (dayTotal / maxDailyTotal) * 100; - - return ( -
- - {date.slice(5)} + + {stat.modelName || stat.modelId || "未知模型"} -
- {dayStats.map((stat, idx) => { - const statPercent = - dayTotal > 0 - ? (stat.count / dayTotal) * 100 - : 0; - const colors = [ - "bg-blue-500", - "bg-green-500", - "bg-purple-500", - "bg-orange-500", - "bg-pink-500", - ]; - return ( -
- ); - })} -
- - {dayTotal} + + {formatAnalyticsNumber(stat.count)}
- ); - }); - })()} - {(!analyticsData?.modelTrendStats || - analyticsData.modelTrendStats.length === 0) && ( -
- 暂无数据 + ))}
- )} +
+ ); + })} + {analyticsTrendDays.length === 0 ? ( +
+ 暂无趋势数据
- - -
+ ) : null} + +
diff --git a/app/api/admin/images/analytics/route.ts b/app/api/admin/images/analytics/route.ts index 48e493554..b5731d7d5 100644 --- a/app/api/admin/images/analytics/route.ts +++ b/app/api/admin/images/analytics/route.ts @@ -1,9 +1,78 @@ 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 = { + 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 = { + modelName: string | null; + providerName: string | null; + providerType: string | null; +}; + +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(); + + for (const row of rows) { + const score = scoreRuntimeModelCandidate(row); + const current = registry.get(row.modelId); + if (current && current.score >= score) continue; + registry.set(row.modelId, { + score, + modelName: row.displayName || null, + providerName: row.providerName || null, + providerType: row.providerType || null, + }); + } + + return registry; +} + +function resolveRuntimeModelMeta( + registry: Map, + modelId: string | null +): RuntimeModelMeta { + if (!modelId) { + return { + modelName: null, + providerName: null, + providerType: null, + }; + } + + const resolved = registry.get(modelId); + return { + modelName: resolved?.modelName || null, + providerName: resolved?.providerName || null, + providerType: resolved?.providerType || null, + }; +} + export async function GET(request: NextRequest) { try { const session = await auth(); @@ -69,49 +138,74 @@ export async function GET(request: NextRequest) { .groupBy(generatedImage.imageSize) .orderBy(desc(sql`count(*)`)); - // 5. 按模型统计(基础) - const modelStats = await db + const runtimeModelRows = await db .select({ - modelId: generatedImage.modelId, - count: sql`count(*)::int`, + 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(generatedImage) - .groupBy(generatedImage.modelId) - .orderBy(desc(sql`count(*)`)) - .limit(10); + .from(aiModel) + .leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id)); - // 5.1 按模型详细统计(包含模型名称和提供商信息) - const modelDetailStats = await db + const runtimeModelRegistry = buildRuntimeModelRegistry(runtimeModelRows); + + // 5. 按模型统计 + const aggregatedModelStats = await db .select({ modelId: generatedImage.modelId, count: sql`count(*)::int`, userCount: sql`count(distinct ${generatedImage.userId})::int`, - modelName: aiModel.displayName, - providerName: aiProvider.name, - providerType: aiProvider.type, }) .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); - - // 5.2 按提供商类型统计 - const providerTypeStats = await db - .select({ - providerType: aiProvider.type, - providerName: aiProvider.name, - count: sql`count(*)::int`, - }) - .from(generatedImage) - .leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId)) - .leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id)) - .groupBy(aiProvider.type, aiProvider.name) + .groupBy(generatedImage.modelId) .orderBy(desc(sql`count(*)`)); + const modelStats = aggregatedModelStats.slice(0, 10).map((stat) => ({ + modelId: stat.modelId, + count: stat.count, + })); + + const modelDetailStats = aggregatedModelStats.slice(0, 20).map((stat) => ({ + modelId: stat.modelId, + count: stat.count, + userCount: stat.userCount, + ...resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId), + })); + + // 5.2 按提供商类型统计 + const providerTypeStatsMap = new Map< + string, + { + providerType: string | null; + providerName: string | null; + count: number; + } + >(); + for (const stat of aggregatedModelStats) { + const meta = resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId); + const key = `${meta.providerType || "unknown"}::${meta.providerName || "unknown"}`; + const current = providerTypeStatsMap.get(key) || { + providerType: meta.providerType, + providerName: meta.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`date(${generatedImage.createdAt})::text`, modelId: generatedImage.modelId, @@ -121,6 +215,12 @@ 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 = rawModelTrendStats.map((stat) => ({ + date: stat.date, + modelId: stat.modelId, + count: stat.count, + ...resolveRuntimeModelMeta(runtimeModelRegistry, stat.modelId), + })); // 6. 按月份统计 const monthlyStats = await db @@ -227,4 +327,3 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Failed to fetch analytics" }, { status: 500 }); } } - From 13070959607216f895883e04b019cac1812a4415 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 15:19:06 +0800 Subject: [PATCH 06/19] refactor: streamline settings navigation chrome --- .../_components/admin-layout-shell.tsx | 2 +- .../_components/admin-settings-navigation.tsx | 175 ++++++++---------- 2 files changed, 80 insertions(+), 97 deletions(-) diff --git a/app/(admin)/_components/admin-layout-shell.tsx b/app/(admin)/_components/admin-layout-shell.tsx index 8dc2a86b3..d1a2fccb9 100644 --- a/app/(admin)/_components/admin-layout-shell.tsx +++ b/app/(admin)/_components/admin-layout-shell.tsx @@ -9,7 +9,7 @@ type AdminLayoutShellProps = ComponentProps & { }; function shouldHideSidebarChrome(pathname: string) { - return pathname === "/settings"; + return pathname === "/settings" || pathname.startsWith("/settings/"); } export function AdminLayoutShell({ diff --git a/app/(admin)/settings/_components/admin-settings-navigation.tsx b/app/(admin)/settings/_components/admin-settings-navigation.tsx index 4a08e25a6..bcb82ae70 100644 --- a/app/(admin)/settings/_components/admin-settings-navigation.tsx +++ b/app/(admin)/settings/_components/admin-settings-navigation.tsx @@ -1,16 +1,8 @@ "use client"; import Link from "next/link"; -import { ArrowLeft, ChevronDown } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Select, SelectContent, @@ -25,7 +17,6 @@ import { Separator } from "@/components/ui/separator"; import { ADMIN_SETTINGS_NAV_GROUPS } from "../_lib/settings-navigation"; import type { AdminSettingsNavGroup, AdminSettingsNavItem } from "../_lib/types"; import { cn } from "@/lib/utils"; -import { SettingStatusBadge } from "./setting-status-badge"; function getActiveGroup(activeTab: string) { return ADMIN_SETTINGS_NAV_GROUPS.find((group) => @@ -33,78 +24,48 @@ function getActiveGroup(activeTab: string) { ); } -function SettingsGroupDropdown({ - activeTab, +function SettingsGroupTab({ group, + isActive, onTabChange, }: { - activeTab: string; group: AdminSettingsNavGroup; + isActive: boolean; onTabChange: (value: string) => void; }) { - const hasActiveTab = group.items.some((item) => item.value === activeTab); + const defaultItem = group.items[0]; + const hasHref = Boolean(defaultItem?.href); + const className = cn( + "h-10 w-full justify-center rounded-2xl border px-4 text-sm font-medium shadow-none transition-colors", + isActive + ? "border-foreground/10 bg-foreground text-background hover:bg-foreground/90 hover:text-background dark:border-white/20 dark:bg-white dark:text-black dark:hover:bg-white/90" + : "border-border/70 bg-zinc-50/80 text-muted-foreground hover:bg-background hover:text-foreground" + ); + + if (!defaultItem) return null; + + if (hasHref) { + return ( + + ); + } return ( - - - - - - -
{group.title}
-
- {group.description} -
-
- - {group.items.map((item) => { - const Icon = item.icon; - const itemContent = ( - <> - -
-
{item.label}
-
- {item.description} -
-
- {item.value === activeTab ? ( - 当前 - ) : null} - - ); - - if (item.href) { - return ( - - {itemContent} - - ); - } - - return ( - onTabChange(item.value)} - > - {itemContent} - - ); - })} -
-
+ ); } @@ -112,28 +73,33 @@ 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 dark:border-white/20 dark:bg-white dark:text-black dark:hover:bg-white/90" : "border-transparent bg-transparent text-muted-foreground hover:border-border/80 hover:bg-background hover:text-foreground"; + 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 ( ); @@ -141,13 +107,13 @@ function SettingsQuickAction({ return ( ); } @@ -160,6 +126,10 @@ export function AdminSettingsNavigation({ onTabChange: (value: string) => void; }) { const activeGroup = getActiveGroup(activeTab); + const groupButtons = ADMIN_SETTINGS_NAV_GROUPS.map((group) => ({ + group, + isActive: group.items.some((item) => item.value === activeTab), + })); return (
@@ -229,26 +199,39 @@ export function AdminSettingsNavigation({ -
- {ADMIN_SETTINGS_NAV_GROUPS.map((group) => ( - - ))} +
+
+ {groupButtons.map(({ group, isActive }) => ( + + ))} +
- + - {(activeGroup?.items ?? []).map((item) => ( - - ))} +
+ {(activeGroup?.items ?? []).map((item) => ( + + ))} +
- ); - } - - return ( - - ); -} +const ALL_SETTINGS_ITEMS = ADMIN_SETTINGS_NAV_GROUPS.flatMap((group) => group.items); function SettingsQuickAction({ activeTab, @@ -125,35 +73,30 @@ export function AdminSettingsNavigation({ activeTab: string; onTabChange: (value: string) => void; }) { - const activeGroup = getActiveGroup(activeTab); - const groupButtons = ADMIN_SETTINGS_NAV_GROUPS.map((group) => ({ - group, - isActive: group.items.some((item) => item.value === activeTab), - })); + 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 (
- - + - {ADMIN_SETTINGS_NAV_GROUPS.map((group, groupIndex) => ( - - {group.title} - {group.items - .filter((item) => !item.href) - .map((item) => ( - - {item.label} - - ))} - {groupIndex < ADMIN_SETTINGS_NAV_GROUPS.length - 1 ? ( - - ) : null} - + {ALL_SETTINGS_ITEMS.map((item) => ( + + {item.label} + ))} @@ -172,7 +115,7 @@ export function AdminSettingsNavigation({
- {(activeGroup?.items ?? []).map((item) => ( + {ALL_SETTINGS_ITEMS.map((item) => ( - {activeGroup ? ( + {activeItem ? ( <> - {activeGroup.title} + {activeItem.label} ) : null} -
-
- {groupButtons.map(({ group, isActive }) => ( - - ))} -
- - - -
- {(activeGroup?.items ?? []).map((item) => ( +
+ {ALL_SETTINGS_ITEMS.map((item) => ( +
- ))} -
+
+ ))}
-
-
- Admin Console -
- - {activeItem ? ( - <> - - - {activeItem.label} - - - ) : null} - - - +
{ALL_SETTINGS_ITEMS.map((item) => (
From 180ee864d9c5249ff3cdcb4432b9d98ebf3c613d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 16:09:06 +0800 Subject: [PATCH 09/19] refactor: polish analytics and gallery chrome --- .../settings/admin-settings-client.tsx | 1068 +++++++++-------- app/(chat)/gallery/page.tsx | 14 +- app/api/admin/images/analytics/route.ts | 210 +++- components/chat-shell-chrome.tsx | 51 +- components/home/lovart-home.tsx | 3 +- 5 files changed, 775 insertions(+), 571 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index ccc572573..cf482f75b 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1445,6 +1445,8 @@ type RuntimeModelCandidate = RuntimeModelMeta & { }; const analyticsNumberFormatter = new Intl.NumberFormat("zh-CN"); +const UUID_LIKE_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function scoreRuntimeModelCandidate(candidate: RuntimeModelCandidate) { return ( @@ -1511,6 +1513,17 @@ function formatAnalyticsMonthLabel(value: string) { return `${year} 年 ${month} 月`; } +function formatAnalyticsModelLabel( + meta: RuntimeModelMeta | null | undefined, + modelId: string | null | undefined +) { + if (meta?.modelName) return meta.modelName; + const normalizedModelId = String(modelId || "").trim(); + if (!normalizedModelId) return "未知模型"; + if (UUID_LIKE_REGEX.test(normalizedModelId)) return "未映射模型"; + return normalizedModelId; +} + const AVAILABLE_TOOLS = [ { name: "getWeather", description: "获取天气信息" }, { name: "createDocument", description: "创建文档" }, @@ -1851,7 +1864,7 @@ export function AdminSettingsClient() { (modelsData?.models || []).forEach((model) => { const providerMeta = providerMetaById.get(model.providerId); - candidates.push({ + const sharedMeta = { modelId: model.modelId, modelName: model.displayName, providerName: providerMeta?.providerName || null, @@ -1861,6 +1874,11 @@ export function AdminSettingsClient() { isDefault: model.isDefault, isSubModel: model.isSubModel, sortOrder: model.sortOrder, + }; + candidates.push(sharedMeta); + candidates.push({ + ...sharedMeta, + modelId: model.id, }); }); @@ -8821,12 +8839,13 @@ export function AdminSettingsClient() {

- {resolveRuntimeModelMeta( - runtimeModelMetaById, + {formatAnalyticsModelLabel( + resolveRuntimeModelMeta( + runtimeModelMetaById, + selectedImage.modelId + ), selectedImage.modelId - ).modelName || - selectedImage.modelId || - "未知"} + )}

{selectedImage.recordType !== "video" ? ( @@ -8967,83 +8986,80 @@ export function AdminSettingsClient() { {/* 🆕 数据分析 */} -
- - -
-
- analytics overview + + +
+
+ analytics overview +
+
+
+ + 生成与运营总览 + + + 统一展示生成规模、活跃用户、趋势波动、模型分布和分辨率结构。 +
-
-
- - 生成与运营总览 - - - 当前面板聚合最近 30 天趋势、当日峰值时段和模型使用分布。 - {selectedDate - ? ` 当前已锁定 ${selectedDate} 的小时分布。` - : " 点击趋势柱可联动查看某一天的小时明细。"} - -
-
- - {formatAnalyticsMonthLabel(selectedMonth)} - {selectedDate ? ( - - {selectedDate} - - ) : null} -
+
+ + {formatAnalyticsMonthLabel(selectedMonth)} + {selectedDate ? ( + + {selectedDate} + + ) : null}
- - -
- {analyticsOverviewCards.map((item) => { - const Icon = item.icon; - return ( -
-
-
-
- {item.label} -
-
- {item.value} -
+
+ + +
+ {analyticsOverviewCards.map((item) => { + const Icon = item.icon; + return ( +
+
+
+
+ {item.label}
-
- +
+ {item.value}
-
- {item.note} +
+
- ); - })} -
- - +
+ {item.note} +
+
+ ); + })} +
+ + - - -
- filters & pulse -
- 时间筛选与月度脉冲 - - 用月份控制本月指标卡,并通过日期联动小时分布。 - -
- + + +
+ filters & pulse +
+ 统计筛选与核心信号 + + 保留月份筛选,同时把高峰时段、领先模型和最近月度走势铺满在同一块工作区里。 + +
+ +
-
-
-
- - 高峰时段 -
-
- {analyticsPeakHour - ? `${String(analyticsPeakHour.hour).padStart(2, "0")}:00` - : "--:--"} -
-
- {analyticsPeakHour - ? `${formatAnalyticsNumber(analyticsPeakHour.count)} 次生成` - : "暂无小时数据"} -
+
+
+ + 高峰时段
+
+ {analyticsPeakHour + ? `${String(analyticsPeakHour.hour).padStart(2, "0")}:00` + : "--:--"} +
+
+ {analyticsPeakHour + ? `${formatAnalyticsNumber(analyticsPeakHour.count)} 次生成` + : "暂无小时数据"} +
+
-
-
- - 领先模型 -
-
- {resolveRuntimeModelMeta( +
+
+ + 领先模型 +
+
+ {formatAnalyticsModelLabel( + resolveRuntimeModelMeta( runtimeModelMetaById, analyticsData?.modelDetailStats?.[0]?.modelId || null - ).modelName || - analyticsData?.modelDetailStats?.[0]?.modelId || - "暂无"} -
-
- {analyticsData?.modelDetailStats?.[0] - ? `${formatAnalyticsNumber( - analyticsData.modelDetailStats[0].count - )} 次调用` - : "还没有模型统计结果"} -
+ ), + analyticsData?.modelDetailStats?.[0]?.modelId || null + )} +
+
+ {analyticsData?.modelDetailStats?.[0] + ? `${formatAnalyticsNumber( + analyticsData.modelDetailStats[0].count + )} 次调用` + : "还没有模型统计结果"}
-
-
+
+
recent months
-
- {analyticsRecentMonths.map((stat) => ( +
+ {analyticsRecentMonths.slice(-3).map((stat) => (
- {stat.month} + {stat.month.slice(5)}
-
+
{formatAnalyticsNumber(stat.count)}
))}
- - -
+
+ + -
- - -
-
- throughput -
- 生成节奏 - - 左侧看最近 30 天总量,右侧看 - {selectedDate ? ` ${selectedDate} ` : "当前选择日期 "} - 的小时分布。 - -
- {selectedDate ? ( - - ) : null} -
- -
-
- 近 30 天生成量 - 点击柱体联动小时分布 -
-
- {analyticsData?.dailyStats?.map((stat, index) => { - const maxCount = Math.max( - ...(analyticsData.dailyStats?.map((s) => s.count) || [1]) - ); - const height = (stat.count / maxCount) * 100; - const isHovered = - hoveredBar?.type === "daily" && - hoveredBar?.index === index; - const isSelected = selectedDate === stat.date; - return ( -
- setSelectedDate( - selectedDate === stat.date ? null : stat.date - ) - } - onMouseEnter={() => - setHoveredBar({ - type: "daily", - index, - value: stat.count, - label: stat.date, - }) - } - onMouseLeave={() => setHoveredBar(null)} - style={{ height: `${Math.max(height, 5)}%` }} - > - {isHovered ? ( -
- {stat.date} · {formatAnalyticsNumber(stat.count)} 次 -
- ) : null} -
- ); - })} -
-
- {analyticsData?.dailyStats?.[0]?.date || "--"} - {analyticsData?.dailyStats?.at(-1)?.date || "--"} -
-
- -
-
- 24 小时分布 - {selectedDate || "默认最近样本"} -
-
- {Array.from({ length: 24 }, (_, hour) => { - const stat = analyticsData?.hourlyStats?.find( - (item) => item.hour === hour - ); - const count = stat?.count || 0; - const maxCount = Math.max( - ...(analyticsData?.hourlyStats?.map((s) => s.count) || [ - 1, - ]), - 1 - ); - const height = (count / maxCount) * 100; - const isHovered = - hoveredBar?.type === "hourly" && - hoveredBar?.index === hour; - return ( -
- setHoveredBar({ - type: "hourly", - index: hour, - value: count, - label: `${hour}:00`, - }) - } - onMouseLeave={() => setHoveredBar(null)} - style={{ height: `${Math.max(height, 4)}%` }} - > - {isHovered ? ( -
- {String(hour).padStart(2, "0")}:00 ·{" "} - {formatAnalyticsNumber(count)} 次 -
- ) : null} -
- ); - })} -
-
- 00:00 - 12:00 - 23:00 -
-
-
-
- -
- - -
- top users -
- 高活跃用户 - 按生成次数排序,便于识别主要使用群体。 -
- -
- {analyticsData?.userStats?.slice(0, 8).map((user, index) => ( +
+ {index + 1} +
+
+
+ {user.userName || user.userEmail?.split("@")[0]} +
+
+ {user.userEmail} +
+
+
+
+
+ {formatAnalyticsNumber(user.imageCount)} +
+
+ 次生成 +
+
+
+ ))} + {(!analyticsData?.userStats || analyticsData.userStats.length === 0) && ( +
+ 暂无用户排行数据 +
+ )} +
+ + + + + +
+ output sizes +
+ 分辨率分布 + + 统一按 4K、3K、2K、1K 四档归并统计,不再展示零散原始尺寸。 + +
+ +
+ {analyticsData?.sizeStats?.map((stat) => { + const percentage = formatAnalyticsRatio( + stat.count, + analyticsData?.overview.totalImages || 0 + ); + return ( +
+
+ {stat.imageSize} +
+
+ {formatAnalyticsNumber(stat.count)} +
+
+ 占比 {percentage} +
+
+
+
+
+ ); + })} +
+ + + + + +
+ model usage +
+ 模型使用概览 + + 兼容历史 UUID 与 runtime modelId,并按统一模型归并后展示后台配置名称。 + +
+ + {(analyticsData?.modelDetailStats || []).slice(0, 8).map((stat, index) => { + const resolved = resolveRuntimeModelMeta( + runtimeModelMetaById, + stat.modelId + ); + const modelLabel = formatAnalyticsModelLabel( + resolved, + stat.modelId + ); + return ( +
+
+
-
+ {index + 1} -
-
-
- {user.userName || user.userEmail?.split("@")[0]} -
-
- {user.userEmail} -
-
-
-
-
- {formatAnalyticsNumber(user.imageCount)} -
-
- 次生成 -
-
-
- ))} - {(!analyticsData?.userStats || - analyticsData.userStats.length === 0) && ( -
- 暂无用户排行数据 -
- )} -
- - - - - -
- output sizes -
- 尺寸分布 - 观察用户输出尺寸的偏好与占比。 -
- - {analyticsData?.sizeStats?.map((stat) => { - const percentage = formatAnalyticsRatio( - stat.count, - analyticsData?.overview.totalImages || 0 - ); - return ( -
-
- - {stat.imageSize || "未知"} - - {formatAnalyticsNumber(stat.count)} · {percentage} - -
-
-
-
-
- ); - })} - - -
-
- -
- - -
- model usage -
- 模型使用概览 - - 修正为按 runtime modelId 聚合后再解析名称,避免跨 provider 串名。 - -
- -
- {(analyticsData?.modelDetailStats || []).slice(0, 6).map((stat, index) => { - const resolved = resolveRuntimeModelMeta( - runtimeModelMetaById, - stat.modelId - ); - return ( -
-
-
- - {index + 1} - -
- {resolved.modelName || stat.modelId || "未知模型"} -
+
+ {modelLabel}
-
+
{resolved.providerName || "未知提供商"} {formatAnalyticsNumber(stat.userCount)} 位用户
-
-
- {formatAnalyticsNumber(stat.count)} -
-
- {formatAnalyticsRatio(stat.count, analyticsModelTotal)} -
-
-
-
-
- ); - })} - {(!analyticsData?.modelDetailStats || - analyticsData.modelDetailStats.length === 0) && ( -
- 暂无模型使用数据 +
+
+ {formatAnalyticsNumber(stat.count)} +
+
+ {formatAnalyticsRatio(stat.count, analyticsModelTotal)} +
+
- )} -
- -
-
-
- model traffic -
-
- {formatAnalyticsNumber(analyticsModelTotal)} -
-
- 当前榜单内模型累计调用次数 +
+
+ ); + })} + {(!analyticsData?.modelDetailStats || + analyticsData.modelDetailStats.length === 0) && ( +
+ 暂无模型使用数据 +
+ )} + + -
- {(analyticsData?.providerTypeStats || []).map((stat, index) => { - const total = - analyticsData?.providerTypeStats?.reduce( - (sum, item) => sum + item.count, - 0 - ) || 0; - const width = formatAnalyticsRatio(stat.count, total); - return ( + + +
+ provider distribution +
+ 提供商分布 + 按 provider 汇总模型调用量,使用整行进度条展示占比。 +
+ + {(analyticsData?.providerTypeStats || []).map((stat, index) => { + const total = + analyticsData?.providerTypeStats?.reduce( + (sum, item) => sum + item.count, + 0 + ) || 0; + const width = formatAnalyticsRatio(stat.count, total); + return ( +
+
+ + {stat.providerName || stat.providerType || "未知提供商"} + + + {formatAnalyticsNumber(stat.count)} · {width} + +
+
+
+
+
+ ); + })} + + + + + +
+ recent 7 days +
+ 模型日趋势 + 每天显示总量,并列出当日最活跃的模型名称。 +
+ + {analyticsTrendDays.map((day) => { + const topModels = day.models.slice(0, 3); + return ( +
+
+
+
+ {day.date} +
+
+ 当日共 {formatAnalyticsNumber(day.total)} 次生成 +
+
+
+ {formatAnalyticsNumber(day.models.length)} 个模型活跃 +
+
+
+ {topModels.map((stat) => (
-
- - {stat.providerName || stat.providerType || "未知提供商"} - - - {formatAnalyticsNumber(stat.count)} · {width} - -
-
-
-
+ + {formatAnalyticsModelLabel( + { + modelName: stat.modelName, + providerName: stat.providerName, + providerType: stat.providerType, + }, + stat.modelId + )} + + + {formatAnalyticsNumber(stat.count)} +
- ); - })} -
-
- - - - - -
- recent 7 days -
- 模型日趋势 - 每天显示总量,并直接列出当日最活跃的模型。 -
- - {analyticsTrendDays.map((day) => { - const topModels = day.models.slice(0, 3); - return ( -
-
-
-
- {day.date} -
-
- 当日共 {formatAnalyticsNumber(day.total)} 次生成 -
-
-
- {formatAnalyticsNumber(day.models.length)} 个模型活跃 -
-
-
- {topModels.map((stat) => ( -
- - {stat.modelName || stat.modelId || "未知模型"} - - - {formatAnalyticsNumber(stat.count)} - -
- ))} -
+ ))}
- ); - })} - {analyticsTrendDays.length === 0 ? ( -
- 暂无趋势数据
- ) : null} -
-
-
+ ); + })} + {analyticsTrendDays.length === 0 ? ( +
+ 暂无趋势数据 +
+ ) : null} +
+
{/* 网站设置 */} diff --git a/app/(chat)/gallery/page.tsx b/app/(chat)/gallery/page.tsx index f5e69863e..bb41768eb 100644 --- a/app/(chat)/gallery/page.tsx +++ b/app/(chat)/gallery/page.tsx @@ -538,13 +538,17 @@ export default function GalleryPage() { />
-
+
-

{selectedImage.alt}

-

- {formatBeijingTime(selectedImage.createdAt)} -

+
+ + 模型 ID: {selectedImage.modelId || "未知模型"} + + + {formatBeijingTime(selectedImage.createdAt)} + +
+
+ + + + 风格化工作流快照中心 + + 查看最近快照、手动创建安全点,并一键恢复到指定版本。恢复前系统会自动再备份一次当前状态。 + + + +
+
+
当前共有 {snapshotsData?.snapshots?.length || 0} 条最近快照
+
+ 自动快照会在高风险保存前生成,手动快照适合在批量编辑前先留一个恢复点。 +
+
+
+ + +
+
+ +
+ {isLoadingSnapshots ? ( +
+ + 正在加载快照列表... +
+ ) : (snapshotsData?.snapshots?.length || 0) === 0 ? ( +
+ 还没有可用快照,先创建一个安全点吧。 +
+ ) : ( + (snapshotsData?.snapshots || []).map((snapshot, index) => ( +
+
+
+
+ + {index === 0 ? "最新" : `#${index + 1}`} + + + {snapshot.label || "未命名快照"} + + + {snapshot.id.slice(0, 8)} + +
+ +
+ {new Date(snapshot.createdAt).toLocaleString("zh-CN")} + {" · "} + {snapshot.createdByName || + snapshot.createdByEmail || + "系统自动创建"} +
+ +
+ + 配置键 {snapshot.summary?.keyCount ?? "-"} + + + 站点字段 {snapshot.summary?.siteFieldCount ?? 0} + + + 非空项 {snapshot.summary?.nonNullKeyCount ?? "-"} + + + 大小 {formatSnapshotByteLength(snapshot.summary?.byteLength)} + +
+
+ + +
+
+ )) + )} +
+
+
+ {/* Stats Bar */}
diff --git a/app/api/admin/system-settings/snapshots/route.ts b/app/api/admin/system-settings/snapshots/route.ts new file mode 100644 index 000000000..2559a625d --- /dev/null +++ b/app/api/admin/system-settings/snapshots/route.ts @@ -0,0 +1,257 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "@/lib/db"; +import { + siteSettings, + systemSettingHistory, + systemSettingSnapshot, + systemSettings, + user, +} from "@/lib/db/schema"; +import { ChatSDKError } from "@/lib/errors"; +import { requireAgentWorkflowAdmin } from "@/lib/middleware/admin"; +import { clearScopedSiteSettingsCache } from "@/lib/site-settings/public-cache"; +import { + AGENT_WORKFLOW_SNAPSHOT_SCOPE, + createSystemSettingSnapshotData, + getSystemSettingSnapshotScopeKeys, + readSystemSettingSnapshotData, + summarizeSystemSettingSnapshotData, + type SystemSettingSnapshotScope, +} from "@/lib/system-settings/snapshot"; + +const snapshotScopeSchema = z.enum([AGENT_WORKFLOW_SNAPSHOT_SCOPE]); + +const listQuerySchema = z.object({ + scope: snapshotScopeSchema, + limit: z.coerce.number().int().min(1).max(50).optional(), +}); + +const mutationSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("create"), + scope: snapshotScopeSchema, + label: z.string().trim().min(1).max(128).optional(), + }), + z.object({ + action: z.literal("restore"), + snapshotId: z.string().uuid(), + reason: z.string().trim().min(1).max(128).optional(), + }), +]); + +async function loadSnapshotSource(scope: SystemSettingSnapshotScope) { + const keys = getSystemSettingSnapshotScopeKeys(scope); + const rows = await db + .select({ + key: systemSettings.key, + value: systemSettings.value, + }) + .from(systemSettings) + .where(inArray(systemSettings.key, [...keys])); + const valueByKey = new Map(rows.map((row) => [row.key, row.value])); + const [currentSiteSettings] = await db + .select({ + agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId, + }) + .from(siteSettings) + .limit(1); + + return { + snapshotData: createSystemSettingSnapshotData(scope, valueByKey, { + agentWorkflowDefaultModelId: + String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null, + }), + }; +} + +async function createSnapshot(params: { + scope: SystemSettingSnapshotScope; + label?: string; + createdBy: string; +}) { + const { snapshotData } = await loadSnapshotSource(params.scope); + const [snapshot] = await db + .insert(systemSettingSnapshot) + .values({ + scope: params.scope, + label: params.label || `manual-${params.scope}-snapshot`, + summary: summarizeSystemSettingSnapshotData(snapshotData), + data: snapshotData, + createdAt: new Date(), + createdBy: params.createdBy, + }) + .returning({ + id: systemSettingSnapshot.id, + scope: systemSettingSnapshot.scope, + label: systemSettingSnapshot.label, + createdAt: systemSettingSnapshot.createdAt, + }); + + return snapshot ?? null; +} + +export async function GET(request: NextRequest) { + try { + await requireAgentWorkflowAdmin(); + + const parsed = listQuerySchema.parse( + Object.fromEntries(new URL(request.url).searchParams.entries()) + ); + + const snapshots = await db + .select({ + id: systemSettingSnapshot.id, + scope: systemSettingSnapshot.scope, + label: systemSettingSnapshot.label, + summary: systemSettingSnapshot.summary, + createdAt: systemSettingSnapshot.createdAt, + createdByName: user.name, + createdByEmail: user.email, + }) + .from(systemSettingSnapshot) + .leftJoin(user, eq(user.id, systemSettingSnapshot.createdBy)) + .where(eq(systemSettingSnapshot.scope, parsed.scope)) + .orderBy(desc(systemSettingSnapshot.createdAt)) + .limit(parsed.limit ?? 12); + + return NextResponse.json({ snapshots }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + return new ChatSDKError("offline:admin").toResponse(); + } +} + +export async function POST(request: NextRequest) { + try { + const session = await requireAgentWorkflowAdmin(); + const body = mutationSchema.parse(await request.json()); + + if (body.action === "create") { + const snapshot = await createSnapshot({ + scope: body.scope, + label: body.label, + createdBy: session.user.id, + }); + return NextResponse.json({ snapshot }); + } + + const [targetSnapshot] = await db + .select({ + id: systemSettingSnapshot.id, + scope: systemSettingSnapshot.scope, + data: systemSettingSnapshot.data, + }) + .from(systemSettingSnapshot) + .where(eq(systemSettingSnapshot.id, body.snapshotId)) + .limit(1); + + if (!targetSnapshot) { + return NextResponse.json({ error: "快照不存在" }, { status: 404 }); + } + + const scope = snapshotScopeSchema.parse(targetSnapshot.scope); + const parsedSnapshot = readSystemSettingSnapshotData(scope, targetSnapshot.data); + const currentSource = await loadSnapshotSource(scope); + + const [backupSnapshot] = await db.transaction(async (tx) => { + const [preRestoreSnapshot] = await tx + .insert(systemSettingSnapshot) + .values({ + scope, + label: `before-restore-${body.snapshotId.slice(0, 8)}`, + summary: summarizeSystemSettingSnapshotData(currentSource.snapshotData), + data: currentSource.snapshotData, + createdAt: new Date(), + createdBy: session.user.id, + }) + .returning({ + id: systemSettingSnapshot.id, + }); + + for (const [key, nextValue] of Object.entries(parsedSnapshot.systemSettings)) { + const [existingSetting] = await tx + .select({ value: systemSettings.value }) + .from(systemSettings) + .where(eq(systemSettings.key, key)) + .limit(1); + + await tx.insert(systemSettingHistory).values({ + key, + previousValue: existingSetting?.value ?? null, + nextValue, + reason: body.reason || `restore-snapshot:${body.snapshotId}`, + changedAt: new Date(), + changedBy: session.user.id, + }); + + await tx + .insert(systemSettings) + .values({ + key, + value: nextValue, + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .onConflictDoUpdate({ + target: systemSettings.key, + set: { + value: nextValue, + updatedAt: new Date(), + updatedBy: session.user.id, + }, + }); + } + + if ("agentWorkflowDefaultModelId" in parsedSnapshot.siteSettings) { + const [existingSiteSettings] = await tx.select().from(siteSettings).limit(1); + const nextAgentWorkflowDefaultModelId = + String(parsedSnapshot.siteSettings.agentWorkflowDefaultModelId || "").trim() || null; + + if (existingSiteSettings) { + await tx + .update(siteSettings) + .set({ + agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId, + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .where(eq(siteSettings.id, existingSiteSettings.id)); + } else { + await tx.insert(siteSettings).values({ + siteName: "AI Chatbot", + agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId, + updatedAt: new Date(), + updatedBy: session.user.id, + }); + } + } + + return [preRestoreSnapshot]; + }); + + clearScopedSiteSettingsCache(); + + return NextResponse.json({ + restoredSnapshotId: body.snapshotId, + backupSnapshotId: backupSnapshot?.id ?? null, + }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: "请求参数不合法", + details: error.issues, + }, + { status: 400 } + ); + } + return new ChatSDKError("offline:admin").toResponse(); + } +} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index c00320735..5e798f6ed 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -2224,9 +2224,19 @@ export async function PUT(request: NextRequest) { return null; } + const [currentSiteSettings] = await db + .select({ + agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId, + }) + .from(siteSettings) + .limit(1); const snapshotData = createSystemSettingSnapshotData( AGENT_WORKFLOW_SNAPSHOT_SCOPE, - currentWorkflowValues + currentWorkflowValues, + { + agentWorkflowDefaultModelId: + String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null, + } ); const [snapshot] = await db .insert(systemSettingSnapshot) diff --git a/lib/system-settings/snapshot.ts b/lib/system-settings/snapshot.ts index 7efe68b5f..aa3b4a468 100644 --- a/lib/system-settings/snapshot.ts +++ b/lib/system-settings/snapshot.ts @@ -38,6 +38,10 @@ export const SYSTEM_SETTING_SNAPSHOT_SCOPES = { ], } as const; +export const SYSTEM_SETTING_SNAPSHOT_SITE_FIELDS = { + [AGENT_WORKFLOW_SNAPSHOT_SCOPE]: ["agentWorkflowDefaultModelId"], +} as const; + export type SystemSettingSnapshotScope = keyof typeof SYSTEM_SETTING_SNAPSHOT_SCOPES; export function getSystemSettingSnapshotScopeKeys( @@ -46,21 +50,83 @@ export function getSystemSettingSnapshotScopeKeys( return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope]; } +export function getSystemSettingSnapshotScopeSiteFields( + scope: SystemSettingSnapshotScope +) { + return SYSTEM_SETTING_SNAPSHOT_SITE_FIELDS[scope]; +} + export function createSystemSettingSnapshotData( scope: SystemSettingSnapshotScope, - valueByKey: ReadonlyMap + valueByKey: ReadonlyMap, + siteSettingsValues: Record = {} ) { const keys = getSystemSettingSnapshotScopeKeys(scope); - return Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null])); + const siteFields = getSystemSettingSnapshotScopeSiteFields(scope); + return { + version: 2, + scope, + systemSettings: Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null])), + siteSettings: Object.fromEntries( + siteFields.map((field) => [field, siteSettingsValues[field] ?? null]) + ), + }; +} + +export function readSystemSettingSnapshotData( + scope: SystemSettingSnapshotScope, + rawData: unknown +) { + const normalized = + rawData && typeof rawData === "object" && !Array.isArray(rawData) + ? (rawData as Record) + : {}; + + if ( + normalized.systemSettings && + typeof normalized.systemSettings === "object" && + !Array.isArray(normalized.systemSettings) + ) { + return { + systemSettings: normalized.systemSettings as Record, + siteSettings: + normalized.siteSettings && + typeof normalized.siteSettings === "object" && + !Array.isArray(normalized.siteSettings) + ? (normalized.siteSettings as Record) + : {}, + }; + } + + const keys = new Set(getSystemSettingSnapshotScopeKeys(scope)); + return { + systemSettings: Object.fromEntries( + Object.entries(normalized).filter(([key]) => keys.has(key)) + ), + siteSettings: {}, + }; } export function summarizeSystemSettingSnapshotData( data: Record ) { - const entries = Object.entries(data); + const systemEntries = + data.systemSettings && + typeof data.systemSettings === "object" && + !Array.isArray(data.systemSettings) + ? Object.entries(data.systemSettings as Record) + : Object.entries(data); + const siteEntries = + data.siteSettings && + typeof data.siteSettings === "object" && + !Array.isArray(data.siteSettings) + ? Object.entries(data.siteSettings as Record) + : []; + const entries = [...systemEntries, ...siteEntries]; return { keyCount: entries.length, nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length, byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"), + siteFieldCount: siteEntries.length, }; } diff --git a/scripts/system-settings/create-snapshot.ts b/scripts/system-settings/create-snapshot.ts index b1dd22c83..379c03c25 100644 --- a/scripts/system-settings/create-snapshot.ts +++ b/scripts/system-settings/create-snapshot.ts @@ -22,7 +22,7 @@ async function main() { const snapshotModule = await import("../../lib/system-settings/snapshot"); const { db } = dbModule; - const { systemSettingSnapshot, systemSettings } = schemaModule; + const { siteSettings, systemSettingSnapshot, systemSettings } = schemaModule; const { createSystemSettingSnapshotData, getSystemSettingSnapshotScopeKeys, @@ -40,9 +40,19 @@ async function main() { .from(systemSettings) .where(inArray(systemSettings.key, [...keys])); const valueByKey = new Map(rows.map((row) => [row.key, row.value])); + const [currentSiteSettings] = await db + .select({ + agentWorkflowDefaultModelId: siteSettings.agentWorkflowDefaultModelId, + }) + .from(siteSettings) + .limit(1); const snapshotData = createSystemSettingSnapshotData( scopeArg as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES, - valueByKey + valueByKey, + { + agentWorkflowDefaultModelId: + String(currentSiteSettings?.agentWorkflowDefaultModelId || "").trim() || null, + } ); const [snapshot] = await db diff --git a/scripts/system-settings/restore-snapshot.ts b/scripts/system-settings/restore-snapshot.ts index c92742309..eba69be20 100644 --- a/scripts/system-settings/restore-snapshot.ts +++ b/scripts/system-settings/restore-snapshot.ts @@ -21,9 +21,12 @@ async function main() { const dbModule = await import("../../lib/db"); const schemaModule = await import("../../lib/db/schema"); + const snapshotModule = await import("../../lib/system-settings/snapshot"); const { db } = dbModule; - const { systemSettingHistory, systemSettingSnapshot, systemSettings } = schemaModule; + const { siteSettings, systemSettingHistory, systemSettingSnapshot, systemSettings } = + schemaModule; + const { readSystemSettingSnapshotData } = snapshotModule; const [snapshot] = await db .select({ @@ -39,11 +42,11 @@ async function main() { throw new Error(`Snapshot not found: ${snapshotId}`); } - const data = - snapshot.data && typeof snapshot.data === "object" && !Array.isArray(snapshot.data) - ? (snapshot.data as Record) - : {}; - const keys = Object.keys(data); + const parsedSnapshot = readSystemSettingSnapshotData( + snapshot.scope as keyof typeof snapshotModule.SYSTEM_SETTING_SNAPSHOT_SCOPES, + snapshot.data + ); + const keys = Object.keys(parsedSnapshot.systemSettings); await db.transaction(async (tx) => { for (const key of keys) { @@ -53,7 +56,7 @@ async function main() { .where(eq(systemSettings.key, key)) .limit(1); - const nextValue = data[key] ?? null; + const nextValue = parsedSnapshot.systemSettings[key] ?? null; await tx.insert(systemSettingHistory).values({ key, @@ -81,6 +84,30 @@ async function main() { }, }); } + + if ("agentWorkflowDefaultModelId" in parsedSnapshot.siteSettings) { + const [existingSiteSettings] = await tx.select().from(siteSettings).limit(1); + const nextAgentWorkflowDefaultModelId = + String(parsedSnapshot.siteSettings.agentWorkflowDefaultModelId || "").trim() || null; + + if (existingSiteSettings) { + await tx + .update(siteSettings) + .set({ + agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId, + updatedAt: new Date(), + updatedBy: null, + }) + .where(eq(siteSettings.id, existingSiteSettings.id)); + } else { + await tx.insert(siteSettings).values({ + siteName: "AI Chatbot", + agentWorkflowDefaultModelId: nextAgentWorkflowDefaultModelId, + updatedAt: new Date(), + updatedBy: null, + }); + } + } }); console.log( From fec4b9d5aa49a5464210beead20166b5a4d5c2f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 30 May 2026 12:53:18 +0800 Subject: [PATCH 12/19] fix: refresh krea image provider integration --- app/api/enhance/pro/route.ts | 6 +- lib/ai/krea-models.ts | 132 ++++++++++++++------------ lib/ai/providers/krea.ts | 177 ++++++++++++++++++++++++++--------- 3 files changed, 209 insertions(+), 106 deletions(-) diff --git a/app/api/enhance/pro/route.ts b/app/api/enhance/pro/route.ts index 2cbc28596..c34986a1f 100644 --- a/app/api/enhance/pro/route.ts +++ b/app/api/enhance/pro/route.ts @@ -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, diff --git a/lib/ai/krea-models.ts b/lib/ai/krea-models.ts index 4b4cc10d3..1eb559900 100644 --- a/lib/ai/krea-models.ts +++ b/lib/ai/krea-models.ts @@ -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) { diff --git a/lib/ai/providers/krea.ts b/lib/ai/providers/krea.ts index 807b4942f..ba418726a 100644 --- a/lib/ai/providers/krea.ts +++ b/lib/ai/providers/krea.ts @@ -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) { return false; } +function hasAnyKreaField(fields: Set, aliases: string[]) { + return aliases.some((field) => fields.has(field)); +} + +function getPreferredKreaField(fields: Set, 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) { 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 = {}; 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 = From 163497630db27a8a00e1a42a500885a91b7dbd81 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 22:43:50 +0800 Subject: [PATCH 13/19] fix: sync browser title with site settings --- app/layout.tsx | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 07529a624..0a774cc8c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 { + 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 = From de91bd396f275d02f9aff62f2f1fcd33d93e3791 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 31 May 2026 22:53:08 +0800 Subject: [PATCH 14/19] fix: add collapse control to external image api settings --- .../settings/_lib/local-ai-sections.ts | 1 + .../settings/admin-settings-client.tsx | 7 +- .../external-image-api-settings-card.tsx | 91 ++++++++++++------- 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/app/(admin)/settings/_lib/local-ai-sections.ts b/app/(admin)/settings/_lib/local-ai-sections.ts index a25c6a0de..9205397b3 100644 --- a/app/(admin)/settings/_lib/local-ai-sections.ts +++ b/app/(admin)/settings/_lib/local-ai-sections.ts @@ -17,6 +17,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record = { "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, diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index cf482f75b..bda1b6733 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -17435,9 +17435,10 @@ export function AdminSettingsClient() { title="外部开放能力" /> -
- -
+ toggleLocalAiSection("external-image-api")} + /> 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 ( - - -
-
- -
-
- 第三方生图 API - - 对外开放当前系统里已启用的生图模型。第三方先调 - - /api/external/image/models - - 获取模型名称,再调用 - - /api/external/image/generate - - 生图。结果会继续走我们的储存桶并落到数据库。 - -
-
-
- + + 对外开放当前系统里已启用的生图模型。第三方先调 + + /api/external/image/models + + 获取模型名称,再调用 + + /api/external/image/generate + + 生图。结果会继续走我们的储存桶并落到数据库。 + + } + expanded={expanded} + id={sectionId} + onToggle={onToggle} + summary={ + 0 ? "enabled" : "warning", + }, + { + label: `启用中 ${enabledKeyCount} 个`, + tone: enabledKeyCount > 0 ? "enabled" : "neutral", + }, + ]} + /> + } + title={ + + + 第三方生图 API + + } + > +
@@ -452,7 +477,7 @@ export function ExternalImageApiSettingsCard() { )}
- - +
+ ); } From 1c7d00508887cca14d260e1253e84b2c8740a83f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 1 Jun 2026 00:34:37 +0800 Subject: [PATCH 15/19] feat: add configurable ai wash workspace --- .../image-pipeline-sections.tsx | 798 ++++++++- .../settings/_lib/local-ai-sections.ts | 1 + app/(admin)/settings/_lib/types.ts | 7 + .../settings/admin-settings-client.tsx | 119 +- app/api/ai-wash/config/route.ts | 58 + app/api/ai-wash/generate/route.ts | 133 ++ app/api/ai-wash/model-options/route.ts | 38 + app/api/site-settings/route.ts | 16 + app/canvas/[id]/realtime/page.tsx | 9 +- components/canvas/AiWashWorkspace.tsx | 1429 +++++++++++++++++ components/canvas/RealtimeCanvasDialog.tsx | 75 +- lib/ai-wash/config.ts | 310 ++++ 12 files changed, 2983 insertions(+), 10 deletions(-) create mode 100644 app/api/ai-wash/config/route.ts create mode 100644 app/api/ai-wash/generate/route.ts create mode 100644 app/api/ai-wash/model-options/route.ts create mode 100644 components/canvas/AiWashWorkspace.tsx create mode 100644 lib/ai-wash/config.ts diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index c2ef656c8..cffff1672 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -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({
- + + + + 配置无限画布“AI洗图”子页面使用的生图模型与提示词。前台会基于同一张原图并行生成多组洗图结果,并支持拖动对比。 + + } + expanded={isLocalAiSectionExpanded("ai-wash")} + id="local-ai-section-ai-wash" + onToggle={() => toggleLocalAiSection("ai-wash")} + summary={ + 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洗图配置" + > +
+
+ +
+ 开启后,图像增强模式顶部会新增一个“AI洗图”入口,用户可上传底图并批量对比洗图结果。 +
+
+
+ + setAiWashConfig((prev) => ({ + ...prev, + enabled: checked, + })) + } + /> + + {aiWashConfig.enabled ? "已开启" : "已关闭"} + +
+
+ +
+
+
+
+
前台入口名称与图标
+
+ 会同步到图像增强顶部的 AI洗图模式按钮,也会用于洗图工作区标题。 +
+
+
+ + {aiWashConfig.modeIconUrl ? ( + + ) : null} +
+ { + const file = event.target.files?.[0]; + if (file) { + await handleAiWashModeIconUpload(file); + } + event.target.value = ""; + }} + type="file" + /> +
+ +
+
+ + + setAiWashConfig((prev) => ({ + ...prev, + modeLabel: event.target.value, + })) + } + placeholder="AI洗图" + value={aiWashConfig.modeLabel} + /> +
+
+ +
+
+ + {aiWashConfig.modeLabel || "AI洗图"} +
+
+ + {aiWashConfig.modeLabel || "AI洗图"} +
+
+
+
+
+
+ + +
+
+ + + setAiWashConfig((prev) => ({ + ...prev, + historyLimit: Number(event.target.value || 8), + })) + } + type="number" + value={aiWashConfig.historyLimit} + /> +
+
+
+
启用提示词扩写
+
+ 会将预设提示词交给生图供应商做额外 prompt enhance。 +
+
+ + setAiWashConfig((prev) => ({ + ...prev, + promptExtend: checked, + })) + } + /> +
+
+ +
+
+
+
洗图预设
+
+ 前台会横向展示启用中的预设结果。建议至少配置 2 到 3 个不同风格的结果卡位。 +
+
+
+ 共 {aiWashConfig.presets.length} 个预设 +
+
+ +
+ {aiWashConfig.presets.map((preset, index) => ( +
+
+
+
{preset.title || `预设 ${index + 1}`}
+
+ 这个卡位会在前台结果区展示成一张独立的可拖动对比卡片。 +
+
+
+ +
+ + setAiWashConfig((prev) => ({ + ...prev, + presets: prev.presets.map((item, itemIndex) => + itemIndex === index + ? { ...item, enabled: checked } + : item + ), + })) + } + /> + + {preset.enabled ? "启用" : "停用"} + +
+
+
+ +
+
+ + + setAiWashConfig((prev) => ({ + ...prev, + presets: prev.presets.map((item, itemIndex) => + itemIndex === index + ? { ...item, title: event.target.value } + : item + ), + })) + } + placeholder="例如:质感提升(细节更精准)" + value={preset.title} + /> +
+
+ + +
+ 如果这里指定了模型,前台默认不再展示生图模型下拉;创造力档位里若再指定模型,会以档位模型优先。 +
+
+
+ +
+
+ +