diff --git a/app/api/style-pro/generate/route.ts b/app/api/style-pro/generate/route.ts
index e38d4b525..67f2279ef 100644
--- a/app/api/style-pro/generate/route.ts
+++ b/app/api/style-pro/generate/route.ts
@@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
-import { getSystemSetting } from "@/lib/db/queries";
+import { getSystemSetting, getUserById } from "@/lib/db/queries";
+import {
+ findStyleProImageModelByRef,
+ getStyleProImageModelEntries,
+} from "@/lib/style-pro/model-options";
import {
STYLE_PRO_CONFIG_KEY,
type StyleProCategory,
@@ -16,6 +20,7 @@ export const dynamic = "force-dynamic";
const generateRequestSchema = z.object({
canvasId: z.string().trim().min(1).max(160).optional(),
sourceImageUrl: z.string().trim().min(1).max(4000),
+ modelId: z.string().trim().min(1).max(160).optional(),
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
});
@@ -79,7 +84,9 @@ function validateSelections(
categories: StyleProCategory[],
rawSelections: Record
) {
- const categoryById = new Map(categories.map((category) => [category.id, category]));
+ const categoryById = new Map(
+ categories.map((category) => [category.id, category])
+ );
const selectedOptions: SelectedOption[] = [];
const normalizedSelections: Record = {};
@@ -121,7 +128,10 @@ function validateSelections(
const missingCategory = categories.find((category) => {
if (!category.required && category.minSelections <= 0) return false;
const selectedCount = normalizedSelections[category.id]?.length || 0;
- return selectedCount < Math.max(category.required ? 1 : 0, category.minSelections);
+ return (
+ selectedCount <
+ Math.max(category.required ? 1 : 0, category.minSelections)
+ );
});
if (missingCategory) {
return {
@@ -141,7 +151,10 @@ export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
- return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
+ return NextResponse.json(
+ { success: false, error: "未登录" },
+ { status: 401 }
+ );
}
const body = generateRequestSchema.parse(await request.json());
@@ -170,7 +183,10 @@ export async function POST(request: Request) {
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0);
- const selectionResult = validateSelections(enabledCategories, body.selections);
+ const selectionResult = validateSelections(
+ enabledCategories,
+ body.selections
+ );
if (!selectionResult.ok) {
return NextResponse.json(
{ success: false, error: selectionResult.error },
@@ -178,6 +194,29 @@ export async function POST(request: Request) {
);
}
+ let selectedModelRef = config.modelId || undefined;
+ if (body.modelId) {
+ const currentUser = await getUserById({ id: session.user.id }).catch(
+ () => null
+ );
+ const includeHidden =
+ session.user.type === "admin" || Boolean(currentUser?.isHighVip);
+ const imageModelEntries = await getStyleProImageModelEntries({
+ includeHidden,
+ });
+ const selectedModel = findStyleProImageModelByRef(
+ imageModelEntries,
+ body.modelId
+ );
+ if (!selectedModel) {
+ return NextResponse.json(
+ { success: false, error: "所选生图模型不可用" },
+ { status: 400 }
+ );
+ }
+ selectedModelRef = selectedModel.model.id;
+ }
+
const prompt = composePrompt({
defaultPrompt: config.defaultPrompt,
selectedOptions: selectionResult.selectedOptions,
@@ -185,7 +224,7 @@ export async function POST(request: Request) {
const result = await generateCanvasImage({
prompt,
negativePrompt: config.negativePrompt || undefined,
- model: config.modelId || undefined,
+ model: selectedModelRef,
aspectRatio: config.aspectRatio || "auto",
imageSize: config.imageSize || "2K",
imageCount: 1,
@@ -194,6 +233,7 @@ export async function POST(request: Request) {
debugLabel: "style-pro",
debugMeta: {
canvasId: body.canvasId || null,
+ requestedStyleProModelId: body.modelId || null,
selectedOptionCount: selectionResult.selectedOptions.length,
},
});
@@ -235,4 +275,3 @@ export async function POST(request: Request) {
);
}
}
-
diff --git a/app/api/style-pro/model-options/route.ts b/app/api/style-pro/model-options/route.ts
new file mode 100644
index 000000000..0b363ae7c
--- /dev/null
+++ b/app/api/style-pro/model-options/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/app/(auth)/auth";
+import { getSystemSetting, getUserById } from "@/lib/db/queries";
+import {
+ findStyleProImageModelByRef,
+ getStyleProImageModelEntries,
+ toPublicStyleProModelOption,
+} from "@/lib/style-pro/model-options";
+import {
+ STYLE_PRO_CONFIG_KEY,
+ normalizeStyleProConfig,
+} from "@/lib/style-pro/config";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ try {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { success: false, error: "未登录" },
+ { status: 401 }
+ );
+ }
+
+ const currentUser = await getUserById({ id: session.user.id }).catch(
+ () => null
+ );
+ const includeHidden =
+ session.user.type === "admin" || Boolean(currentUser?.isHighVip);
+ const [entries, setting] = await Promise.all([
+ getStyleProImageModelEntries({ includeHidden }),
+ getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
+ ]);
+ const config = normalizeStyleProConfig(setting?.value);
+ const configuredDefault = findStyleProImageModelByRef(
+ entries,
+ config.modelId
+ );
+ const defaultEntry =
+ configuredDefault ||
+ entries.find((entry) => entry.model.isDefault) ||
+ entries[0] ||
+ null;
+
+ return NextResponse.json({
+ success: true,
+ defaultModelId: defaultEntry?.model.id || null,
+ models: entries.map(toPublicStyleProModelOption),
+ });
+ } catch (error) {
+ console.error("[Style Pro Model Options] Failed:", error);
+ return NextResponse.json(
+ { success: false, error: "获取风格化 PRO 生图模型失败" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/canvas/[id]/style-pro/page.tsx b/app/canvas/[id]/style-pro/page.tsx
index 056d5423c..0ba03f4aa 100644
--- a/app/canvas/[id]/style-pro/page.tsx
+++ b/app/canvas/[id]/style-pro/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { useParams, useRouter } from "next/navigation";
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
import { toast } from "sonner";
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
import { canvasService } from "@/services/canvas";
@@ -10,6 +10,7 @@ import type {
StyleProHistoryItem,
StyleProPersistedState,
} from "@/lib/style-pro/config";
+import { normalizeStyleProPersistedState } from "@/lib/style-pro/config";
export default function CanvasStyleProPage() {
const router = useRouter();
@@ -21,9 +22,52 @@ export default function CanvasStyleProPage() {
const nodes = useCanvasStore(canvasSelectors.nodes);
const styleProState = useCanvasStore((state) => state.styleProState);
const addNode = useCanvasStore((state) => state.addNode);
+ const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const selectNodes = useCanvasStore((state) => state.selectNodes);
+ const setProject = useCanvasStore((state) => state.setProject);
+ const setStyleProState = useCanvasStore((state) => state.setStyleProState);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadPersistedStyleProState = async () => {
+ if (!id) return;
+ const currentState = useCanvasStore.getState();
+ if (
+ currentState.projectId === id &&
+ (currentState.nodes.length > 0 ||
+ currentState.styleProState.history.length > 0)
+ ) {
+ return;
+ }
+
+ const result = await canvasService.getProject(id);
+ if (cancelled || !result.success || !result.data?.canvasData) return;
+
+ const canvasData = result.data.canvasData;
+ const latestState = useCanvasStore.getState();
+ setProject(result.data.id, result.data.title);
+ if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
+ loadCanvas(canvasData);
+ return;
+ }
+
+ const nextStyleProState = normalizeStyleProPersistedState(
+ canvasData.styleProState
+ );
+ if (nextStyleProState.history.length > 0) {
+ setStyleProState(nextStyleProState);
+ }
+ };
+
+ void loadPersistedStyleProState();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [id, loadCanvas, setProject, setStyleProState]);
+
const selectedImageNode =
selectedNodeIds.length === 1
? nodes.find(
@@ -46,7 +90,10 @@ export default function CanvasStyleProPage() {
useCanvasStore.getState().getCanvasState()
);
if (!result.success) {
- console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error);
+ console.error(
+ "[CanvasStyleProPage] 保存风格化 PRO 状态失败:",
+ result.error
+ );
}
},
[id]
@@ -62,8 +109,10 @@ export default function CanvasStyleProPage() {
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
- const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
- const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
+ const stageWidth =
+ typeof window !== "undefined" ? window.innerWidth : 1280;
+ const stageHeight =
+ typeof window !== "undefined" ? window.innerHeight : 720;
const naturalWidth = Number(item.width || 0);
const naturalHeight = Number(item.height || 0);
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
@@ -111,7 +160,15 @@ export default function CanvasStyleProPage() {
}
toast.success("风格化 PRO 结果已添加到画布");
},
- [addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom]
+ [
+ addNode,
+ getCanvasState,
+ id,
+ selectNodes,
+ viewport.x,
+ viewport.y,
+ viewport.zoom,
+ ]
);
return (
@@ -125,4 +182,3 @@ export default function CanvasStyleProPage() {
/>
);
}
-
diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx
index 278078d7b..c09db3312 100644
--- a/components/style-pro/StyleProWorkspace.tsx
+++ b/components/style-pro/StyleProWorkspace.tsx
@@ -1,11 +1,6 @@
"use client";
-import {
- Clock3,
- Loader2,
- Sparkles,
- Upload,
-} from "lucide-react";
+import { Clock3, Loader2, Sparkles, Upload } from "lucide-react";
import {
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
@@ -41,6 +36,25 @@ type StyleProConfigResponse = {
error?: string;
};
+type StyleProModelOption = {
+ id: string;
+ modelId: string;
+ displayName: string;
+ avatarUrl?: string | null;
+ providerName: string;
+ providerType: string;
+ isDefault: boolean;
+ unitCost: number;
+ creditDiscountLabel?: string | null;
+};
+
+type StyleProModelsResponse = {
+ success: boolean;
+ defaultModelId?: string | null;
+ models?: StyleProModelOption[];
+ error?: string;
+};
+
type StyleProGenerateResponse = {
success: boolean;
error?: string;
@@ -63,6 +77,8 @@ type StyleProWorkspaceProps = {
onPersistState?: (state: StyleProPersistedState) => void | Promise;
};
+const STYLE_PRO_HISTORY_LIMIT = 4;
+
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
@@ -89,7 +105,93 @@ function loadImageDimensions(url: string) {
}
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
- return Array.isArray(history) ? history.slice(0, 4) : [];
+ return Array.isArray(history)
+ ? history.slice(0, STYLE_PRO_HISTORY_LIMIT)
+ : [];
+}
+
+function getLocalStyleProStateKey(canvasId: string) {
+ return `style-pro-state:${canvasId || "draft"}`;
+}
+
+function readLocalStyleProState(
+ canvasId: string
+): StyleProPersistedState | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const raw = window.localStorage.getItem(getLocalStyleProStateKey(canvasId));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as StyleProPersistedState;
+ return {
+ sourceImageUrl:
+ typeof parsed.sourceImageUrl === "string"
+ ? parsed.sourceImageUrl
+ : undefined,
+ history: normalizeHistory(
+ Array.isArray(parsed.history)
+ ? parsed.history.filter(
+ (item) =>
+ item &&
+ typeof item.sourceImageUrl === "string" &&
+ typeof item.resultImageUrl === "string"
+ )
+ : []
+ ),
+ };
+ } catch {
+ return null;
+ }
+}
+
+function writeLocalStyleProState(
+ canvasId: string,
+ state: StyleProPersistedState
+) {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.setItem(
+ getLocalStyleProStateKey(canvasId),
+ JSON.stringify({
+ sourceImageUrl: state.sourceImageUrl,
+ history: normalizeHistory(state.history),
+ })
+ );
+ } catch {
+ // Local persistence is a safety net; server persistence remains authoritative.
+ }
+}
+
+function mergeStyleProHistory(
+ ...historyGroups: Array
+) {
+ const byKey = new Map();
+ for (const history of historyGroups) {
+ for (const item of history || []) {
+ if (!item?.sourceImageUrl || !item?.resultImageUrl) continue;
+ const key = item.id || `${item.sourceImageUrl}::${item.resultImageUrl}`;
+ if (!byKey.has(key)) {
+ byKey.set(key, item);
+ }
+ }
+ }
+ return Array.from(byKey.values())
+ .sort((left, right) => {
+ const leftTime = Date.parse(left.createdAt || "") || 0;
+ const rightTime = Date.parse(right.createdAt || "") || 0;
+ return rightTime - leftTime;
+ })
+ .slice(0, STYLE_PRO_HISTORY_LIMIT);
+}
+
+function mergeStyleProState(
+ ...states: Array
+): StyleProPersistedState {
+ return {
+ sourceImageUrl:
+ states.find((state) => String(state?.sourceImageUrl || "").trim())
+ ?.sourceImageUrl || undefined,
+ history: mergeStyleProHistory(...states.map((state) => state?.history)),
+ };
}
function ImagePreview({
@@ -361,7 +463,13 @@ export function StyleProWorkspace({
);
const { data: configData, isLoading: configLoading } =
useSWR("/api/style-pro/config", fetcher);
+ const { data: modelsData, isLoading: modelsLoading } =
+ useSWR("/api/style-pro/model-options", fetcher);
const config = configData?.config;
+ const modelOptions = useMemo(
+ () => (modelsData?.models || []).filter((model) => Boolean(model.id)),
+ [modelsData?.models]
+ );
const enabledCategories = useMemo(
() =>
(config?.categories || [])
@@ -375,27 +483,73 @@ export function StyleProWorkspace({
);
const [sourceImageUrl, setSourceImageUrl] = useState(
- persistedState?.sourceImageUrl || initialSourceImageUrl || ""
+ () =>
+ mergeStyleProState(
+ persistedState,
+ readLocalStyleProState(canvasId),
+ initialSourceImageUrl
+ ? { sourceImageUrl: initialSourceImageUrl, history: [] }
+ : null
+ ).sourceImageUrl || ""
);
const [currentResult, setCurrentResult] =
- useState(
- normalizeHistory(persistedState?.history)[0] || null
- );
+ useState(() => {
+ const merged = mergeStyleProState(
+ persistedState,
+ readLocalStyleProState(canvasId)
+ );
+ return merged.history[0] || null;
+ });
const [history, setHistory] = useState(
- normalizeHistory(persistedState?.history)
+ () =>
+ mergeStyleProState(persistedState, readLocalStyleProState(canvasId))
+ .history
);
const [selections, setSelections] = useState({});
+ const [selectedModelId, setSelectedModelId] = useState("");
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
+ if (modelOptions.length === 0) {
+ setSelectedModelId("");
+ return;
+ }
+ setSelectedModelId((current) => {
+ if (current && modelOptions.some((model) => model.id === current)) {
+ return current;
+ }
+ return modelsData?.defaultModelId || modelOptions[0]?.id || "";
+ });
+ }, [modelOptions, modelsData?.defaultModelId]);
+
+ useEffect(() => {
+ const localState = readLocalStyleProState(canvasId);
const nextSource =
- persistedState?.sourceImageUrl || initialSourceImageUrl || "";
+ persistedState?.sourceImageUrl ||
+ localState?.sourceImageUrl ||
+ initialSourceImageUrl ||
+ "";
setSourceImageUrl((current) => current || nextSource);
- const nextHistory = normalizeHistory(persistedState?.history);
- setHistory(nextHistory);
- setCurrentResult((current) => current || nextHistory[0] || null);
- }, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
+ setHistory((currentHistory) =>
+ mergeStyleProHistory(
+ currentHistory,
+ persistedState?.history,
+ localState?.history
+ )
+ );
+ setCurrentResult(
+ (current) =>
+ current ||
+ mergeStyleProHistory(persistedState?.history, localState?.history)[0] ||
+ null
+ );
+ }, [
+ canvasId,
+ initialSourceImageUrl,
+ persistedState?.history,
+ persistedState?.sourceImageUrl,
+ ]);
const siteName = brandingData?.siteName || "由壹点AI";
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
@@ -414,6 +568,22 @@ export function StyleProWorkspace({
return labels;
}, [enabledCategories, selections]);
+ const selectedModel = useMemo(
+ () =>
+ modelOptions.find((model) => model.id === selectedModelId) ||
+ modelOptions[0] ||
+ null,
+ [modelOptions, selectedModelId]
+ );
+
+ const selectedModelCostLabel = selectedModel
+ ? selectedModel.unitCost > 0
+ ? `${selectedModel.unitCost} 积分/张`
+ : "未设置积分"
+ : modelsLoading
+ ? "加载价格"
+ : "暂无模型";
+
const missingRequiredCategories = useMemo(
() =>
enabledCategories.filter((category) => {
@@ -429,12 +599,14 @@ export function StyleProWorkspace({
const persistState = useCallback(
async (nextState: StyleProPersistedState) => {
- await onPersistState?.({
+ const normalizedState = {
sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history),
- });
+ };
+ writeLocalStyleProState(canvasId, normalizedState);
+ await onPersistState?.(normalizedState);
},
- [onPersistState]
+ [canvasId, onPersistState]
);
const updateSourceImage = useCallback(
@@ -515,6 +687,7 @@ export function StyleProWorkspace({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasId,
+ modelId: selectedModel?.id || selectedModelId || undefined,
sourceImageUrl,
selections,
}),
@@ -538,10 +711,10 @@ export function StyleProWorkspace({
width: dimensions.width,
height: dimensions.height,
};
- const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
- 0,
- 4
- );
+ const nextHistory = [
+ item,
+ ...history.filter((entry) => entry.id !== item.id),
+ ].slice(0, 4);
setHistory(nextHistory);
setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory });
@@ -558,6 +731,8 @@ export function StyleProWorkspace({
missingRequiredCategories,
persistState,
selectedOptionLabels,
+ selectedModel?.id,
+ selectedModelId,
selections,
sourceImageUrl,
]);
@@ -570,9 +745,11 @@ export function StyleProWorkspace({
const disabledReason = !sourceImageUrl
? "请先上传图片"
- : missingRequiredCategories.length > 0
- ? `还差:${missingRequiredCategories[0].title}`
- : "";
+ : modelsData?.success === true && modelOptions.length === 0
+ ? "暂无可用生图模型"
+ : missingRequiredCategories.length > 0
+ ? `还差:${missingRequiredCategories[0].title}`
+ : "";
const promptDisplayMode = config?.promptDisplayMode || "stacked";
const renderPromptCategory = (
category: PublicStyleProCategory,
@@ -762,23 +939,69 @@ export function StyleProWorkspace({
生成后拖动滑块查看原图与结果图。
-
+
+
+ 模型
+
+
+ {selectedModelCostLabel}
+
+ {selectedModel?.creditDiscountLabel ? (
+
+ {selectedModel.creditDiscountLabel}
+
+ ) : null}
+