feat: persist style pro history and model pricing
This commit is contained in:
parent
0ac796b9ff
commit
e8a3212b35
@ -2,7 +2,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
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 {
|
import {
|
||||||
STYLE_PRO_CONFIG_KEY,
|
STYLE_PRO_CONFIG_KEY,
|
||||||
type StyleProCategory,
|
type StyleProCategory,
|
||||||
@ -16,6 +20,7 @@ export const dynamic = "force-dynamic";
|
|||||||
const generateRequestSchema = z.object({
|
const generateRequestSchema = z.object({
|
||||||
canvasId: z.string().trim().min(1).max(160).optional(),
|
canvasId: z.string().trim().min(1).max(160).optional(),
|
||||||
sourceImageUrl: z.string().trim().min(1).max(4000),
|
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)),
|
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,7 +84,9 @@ function validateSelections(
|
|||||||
categories: StyleProCategory[],
|
categories: StyleProCategory[],
|
||||||
rawSelections: Record<string, string[]>
|
rawSelections: Record<string, string[]>
|
||||||
) {
|
) {
|
||||||
const categoryById = new Map(categories.map((category) => [category.id, category]));
|
const categoryById = new Map(
|
||||||
|
categories.map((category) => [category.id, category])
|
||||||
|
);
|
||||||
const selectedOptions: SelectedOption[] = [];
|
const selectedOptions: SelectedOption[] = [];
|
||||||
const normalizedSelections: Record<string, string[]> = {};
|
const normalizedSelections: Record<string, string[]> = {};
|
||||||
|
|
||||||
@ -121,7 +128,10 @@ function validateSelections(
|
|||||||
const missingCategory = categories.find((category) => {
|
const missingCategory = categories.find((category) => {
|
||||||
if (!category.required && category.minSelections <= 0) return false;
|
if (!category.required && category.minSelections <= 0) return false;
|
||||||
const selectedCount = normalizedSelections[category.id]?.length || 0;
|
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) {
|
if (missingCategory) {
|
||||||
return {
|
return {
|
||||||
@ -141,7 +151,10 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
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());
|
const body = generateRequestSchema.parse(await request.json());
|
||||||
@ -170,7 +183,10 @@ export async function POST(request: Request) {
|
|||||||
options: category.options.filter((option) => option.enabled),
|
options: category.options.filter((option) => option.enabled),
|
||||||
}))
|
}))
|
||||||
.filter((category) => category.options.length > 0);
|
.filter((category) => category.options.length > 0);
|
||||||
const selectionResult = validateSelections(enabledCategories, body.selections);
|
const selectionResult = validateSelections(
|
||||||
|
enabledCategories,
|
||||||
|
body.selections
|
||||||
|
);
|
||||||
if (!selectionResult.ok) {
|
if (!selectionResult.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: selectionResult.error },
|
{ 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({
|
const prompt = composePrompt({
|
||||||
defaultPrompt: config.defaultPrompt,
|
defaultPrompt: config.defaultPrompt,
|
||||||
selectedOptions: selectionResult.selectedOptions,
|
selectedOptions: selectionResult.selectedOptions,
|
||||||
@ -185,7 +224,7 @@ export async function POST(request: Request) {
|
|||||||
const result = await generateCanvasImage({
|
const result = await generateCanvasImage({
|
||||||
prompt,
|
prompt,
|
||||||
negativePrompt: config.negativePrompt || undefined,
|
negativePrompt: config.negativePrompt || undefined,
|
||||||
model: config.modelId || undefined,
|
model: selectedModelRef,
|
||||||
aspectRatio: config.aspectRatio || "auto",
|
aspectRatio: config.aspectRatio || "auto",
|
||||||
imageSize: config.imageSize || "2K",
|
imageSize: config.imageSize || "2K",
|
||||||
imageCount: 1,
|
imageCount: 1,
|
||||||
@ -194,6 +233,7 @@ export async function POST(request: Request) {
|
|||||||
debugLabel: "style-pro",
|
debugLabel: "style-pro",
|
||||||
debugMeta: {
|
debugMeta: {
|
||||||
canvasId: body.canvasId || null,
|
canvasId: body.canvasId || null,
|
||||||
|
requestedStyleProModelId: body.modelId || null,
|
||||||
selectedOptionCount: selectionResult.selectedOptions.length,
|
selectedOptionCount: selectionResult.selectedOptions.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -235,4 +275,3 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
app/api/style-pro/model-options/route.ts
Normal file
58
app/api/style-pro/model-options/route.ts
Normal file
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
|
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
|
||||||
import { canvasService } from "@/services/canvas";
|
import { canvasService } from "@/services/canvas";
|
||||||
@ -10,6 +10,7 @@ import type {
|
|||||||
StyleProHistoryItem,
|
StyleProHistoryItem,
|
||||||
StyleProPersistedState,
|
StyleProPersistedState,
|
||||||
} from "@/lib/style-pro/config";
|
} from "@/lib/style-pro/config";
|
||||||
|
import { normalizeStyleProPersistedState } from "@/lib/style-pro/config";
|
||||||
|
|
||||||
export default function CanvasStyleProPage() {
|
export default function CanvasStyleProPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -21,9 +22,52 @@ export default function CanvasStyleProPage() {
|
|||||||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||||||
const styleProState = useCanvasStore((state) => state.styleProState);
|
const styleProState = useCanvasStore((state) => state.styleProState);
|
||||||
const addNode = useCanvasStore((state) => state.addNode);
|
const addNode = useCanvasStore((state) => state.addNode);
|
||||||
|
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||||
|
const setProject = useCanvasStore((state) => state.setProject);
|
||||||
|
const setStyleProState = useCanvasStore((state) => state.setStyleProState);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
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 =
|
const selectedImageNode =
|
||||||
selectedNodeIds.length === 1
|
selectedNodeIds.length === 1
|
||||||
? nodes.find(
|
? nodes.find(
|
||||||
@ -46,7 +90,10 @@ export default function CanvasStyleProPage() {
|
|||||||
useCanvasStore.getState().getCanvasState()
|
useCanvasStore.getState().getCanvasState()
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error);
|
console.error(
|
||||||
|
"[CanvasStyleProPage] 保存风格化 PRO 状态失败:",
|
||||||
|
result.error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id]
|
[id]
|
||||||
@ -62,8 +109,10 @@ export default function CanvasStyleProPage() {
|
|||||||
const viewportX = viewport.x || 0;
|
const viewportX = viewport.x || 0;
|
||||||
const viewportY = viewport.y || 0;
|
const viewportY = viewport.y || 0;
|
||||||
const viewportZoom = viewport.zoom || 1;
|
const viewportZoom = viewport.zoom || 1;
|
||||||
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
|
const stageWidth =
|
||||||
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
|
typeof window !== "undefined" ? window.innerWidth : 1280;
|
||||||
|
const stageHeight =
|
||||||
|
typeof window !== "undefined" ? window.innerHeight : 720;
|
||||||
const naturalWidth = Number(item.width || 0);
|
const naturalWidth = Number(item.width || 0);
|
||||||
const naturalHeight = Number(item.height || 0);
|
const naturalHeight = Number(item.height || 0);
|
||||||
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
|
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
|
||||||
@ -111,7 +160,15 @@ export default function CanvasStyleProPage() {
|
|||||||
}
|
}
|
||||||
toast.success("风格化 PRO 结果已添加到画布");
|
toast.success("风格化 PRO 结果已添加到画布");
|
||||||
},
|
},
|
||||||
[addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom]
|
[
|
||||||
|
addNode,
|
||||||
|
getCanvasState,
|
||||||
|
id,
|
||||||
|
selectNodes,
|
||||||
|
viewport.x,
|
||||||
|
viewport.y,
|
||||||
|
viewport.zoom,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -125,4 +182,3 @@ export default function CanvasStyleProPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Clock3, Loader2, Sparkles, Upload } from "lucide-react";
|
||||||
Clock3,
|
|
||||||
Loader2,
|
|
||||||
Sparkles,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
@ -41,6 +36,25 @@ type StyleProConfigResponse = {
|
|||||||
error?: string;
|
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 = {
|
type StyleProGenerateResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
@ -63,6 +77,8 @@ type StyleProWorkspaceProps = {
|
|||||||
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
|
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STYLE_PRO_HISTORY_LIMIT = 4;
|
||||||
|
|
||||||
function getHistoryId() {
|
function getHistoryId() {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@ -89,7 +105,93 @@ function loadImageDimensions(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
|
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<StyleProHistoryItem[] | undefined>
|
||||||
|
) {
|
||||||
|
const byKey = new Map<string, StyleProHistoryItem>();
|
||||||
|
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 | null | undefined>
|
||||||
|
): StyleProPersistedState {
|
||||||
|
return {
|
||||||
|
sourceImageUrl:
|
||||||
|
states.find((state) => String(state?.sourceImageUrl || "").trim())
|
||||||
|
?.sourceImageUrl || undefined,
|
||||||
|
history: mergeStyleProHistory(...states.map((state) => state?.history)),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImagePreview({
|
function ImagePreview({
|
||||||
@ -361,7 +463,13 @@ export function StyleProWorkspace({
|
|||||||
);
|
);
|
||||||
const { data: configData, isLoading: configLoading } =
|
const { data: configData, isLoading: configLoading } =
|
||||||
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
|
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
|
||||||
|
const { data: modelsData, isLoading: modelsLoading } =
|
||||||
|
useSWR<StyleProModelsResponse>("/api/style-pro/model-options", fetcher);
|
||||||
const config = configData?.config;
|
const config = configData?.config;
|
||||||
|
const modelOptions = useMemo(
|
||||||
|
() => (modelsData?.models || []).filter((model) => Boolean(model.id)),
|
||||||
|
[modelsData?.models]
|
||||||
|
);
|
||||||
const enabledCategories = useMemo(
|
const enabledCategories = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(config?.categories || [])
|
(config?.categories || [])
|
||||||
@ -375,27 +483,73 @@ export function StyleProWorkspace({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [sourceImageUrl, setSourceImageUrl] = useState(
|
const [sourceImageUrl, setSourceImageUrl] = useState(
|
||||||
persistedState?.sourceImageUrl || initialSourceImageUrl || ""
|
() =>
|
||||||
|
mergeStyleProState(
|
||||||
|
persistedState,
|
||||||
|
readLocalStyleProState(canvasId),
|
||||||
|
initialSourceImageUrl
|
||||||
|
? { sourceImageUrl: initialSourceImageUrl, history: [] }
|
||||||
|
: null
|
||||||
|
).sourceImageUrl || ""
|
||||||
);
|
);
|
||||||
const [currentResult, setCurrentResult] =
|
const [currentResult, setCurrentResult] =
|
||||||
useState<StyleProHistoryItem | null>(
|
useState<StyleProHistoryItem | null>(() => {
|
||||||
normalizeHistory(persistedState?.history)[0] || null
|
const merged = mergeStyleProState(
|
||||||
);
|
persistedState,
|
||||||
|
readLocalStyleProState(canvasId)
|
||||||
|
);
|
||||||
|
return merged.history[0] || null;
|
||||||
|
});
|
||||||
const [history, setHistory] = useState<StyleProHistoryItem[]>(
|
const [history, setHistory] = useState<StyleProHistoryItem[]>(
|
||||||
normalizeHistory(persistedState?.history)
|
() =>
|
||||||
|
mergeStyleProState(persistedState, readLocalStyleProState(canvasId))
|
||||||
|
.history
|
||||||
);
|
);
|
||||||
const [selections, setSelections] = useState<StyleProSelectionMap>({});
|
const [selections, setSelections] = useState<StyleProSelectionMap>({});
|
||||||
|
const [selectedModelId, setSelectedModelId] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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 =
|
const nextSource =
|
||||||
persistedState?.sourceImageUrl || initialSourceImageUrl || "";
|
persistedState?.sourceImageUrl ||
|
||||||
|
localState?.sourceImageUrl ||
|
||||||
|
initialSourceImageUrl ||
|
||||||
|
"";
|
||||||
setSourceImageUrl((current) => current || nextSource);
|
setSourceImageUrl((current) => current || nextSource);
|
||||||
const nextHistory = normalizeHistory(persistedState?.history);
|
setHistory((currentHistory) =>
|
||||||
setHistory(nextHistory);
|
mergeStyleProHistory(
|
||||||
setCurrentResult((current) => current || nextHistory[0] || null);
|
currentHistory,
|
||||||
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
|
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 siteName = brandingData?.siteName || "由壹点AI";
|
||||||
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
|
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
|
||||||
@ -414,6 +568,22 @@ export function StyleProWorkspace({
|
|||||||
return labels;
|
return labels;
|
||||||
}, [enabledCategories, selections]);
|
}, [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(
|
const missingRequiredCategories = useMemo(
|
||||||
() =>
|
() =>
|
||||||
enabledCategories.filter((category) => {
|
enabledCategories.filter((category) => {
|
||||||
@ -429,12 +599,14 @@ export function StyleProWorkspace({
|
|||||||
|
|
||||||
const persistState = useCallback(
|
const persistState = useCallback(
|
||||||
async (nextState: StyleProPersistedState) => {
|
async (nextState: StyleProPersistedState) => {
|
||||||
await onPersistState?.({
|
const normalizedState = {
|
||||||
sourceImageUrl: nextState.sourceImageUrl,
|
sourceImageUrl: nextState.sourceImageUrl,
|
||||||
history: normalizeHistory(nextState.history),
|
history: normalizeHistory(nextState.history),
|
||||||
});
|
};
|
||||||
|
writeLocalStyleProState(canvasId, normalizedState);
|
||||||
|
await onPersistState?.(normalizedState);
|
||||||
},
|
},
|
||||||
[onPersistState]
|
[canvasId, onPersistState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateSourceImage = useCallback(
|
const updateSourceImage = useCallback(
|
||||||
@ -515,6 +687,7 @@ export function StyleProWorkspace({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
canvasId,
|
canvasId,
|
||||||
|
modelId: selectedModel?.id || selectedModelId || undefined,
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
selections,
|
selections,
|
||||||
}),
|
}),
|
||||||
@ -538,10 +711,10 @@ export function StyleProWorkspace({
|
|||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
};
|
};
|
||||||
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
|
const nextHistory = [
|
||||||
0,
|
item,
|
||||||
4
|
...history.filter((entry) => entry.id !== item.id),
|
||||||
);
|
].slice(0, 4);
|
||||||
setHistory(nextHistory);
|
setHistory(nextHistory);
|
||||||
setCurrentResult(item);
|
setCurrentResult(item);
|
||||||
await persistState({ sourceImageUrl, history: nextHistory });
|
await persistState({ sourceImageUrl, history: nextHistory });
|
||||||
@ -558,6 +731,8 @@ export function StyleProWorkspace({
|
|||||||
missingRequiredCategories,
|
missingRequiredCategories,
|
||||||
persistState,
|
persistState,
|
||||||
selectedOptionLabels,
|
selectedOptionLabels,
|
||||||
|
selectedModel?.id,
|
||||||
|
selectedModelId,
|
||||||
selections,
|
selections,
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
]);
|
]);
|
||||||
@ -570,9 +745,11 @@ export function StyleProWorkspace({
|
|||||||
|
|
||||||
const disabledReason = !sourceImageUrl
|
const disabledReason = !sourceImageUrl
|
||||||
? "请先上传图片"
|
? "请先上传图片"
|
||||||
: missingRequiredCategories.length > 0
|
: modelsData?.success === true && modelOptions.length === 0
|
||||||
? `还差:${missingRequiredCategories[0].title}`
|
? "暂无可用生图模型"
|
||||||
: "";
|
: missingRequiredCategories.length > 0
|
||||||
|
? `还差:${missingRequiredCategories[0].title}`
|
||||||
|
: "";
|
||||||
const promptDisplayMode = config?.promptDisplayMode || "stacked";
|
const promptDisplayMode = config?.promptDisplayMode || "stacked";
|
||||||
const renderPromptCategory = (
|
const renderPromptCategory = (
|
||||||
category: PublicStyleProCategory,
|
category: PublicStyleProCategory,
|
||||||
@ -762,23 +939,69 @@ export function StyleProWorkspace({
|
|||||||
生成后拖动滑块查看原图与结果图。
|
生成后拖动滑块查看原图与结果图。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex h-8 items-center gap-2 rounded-[14px] border border-black/10 bg-white px-2 text-[12px] text-black/65 shadow-sm">
|
||||||
|
<span className="text-black/45">模型</span>
|
||||||
|
<select
|
||||||
|
aria-label="选择生图模型"
|
||||||
|
className="h-6 max-w-[210px] bg-transparent font-semibold text-black/75 outline-none"
|
||||||
|
disabled={
|
||||||
|
modelsLoading ||
|
||||||
|
modelOptions.length === 0 ||
|
||||||
|
generating
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSelectedModelId(event.target.value)
|
||||||
|
}
|
||||||
|
value={selectedModel?.id || selectedModelId}
|
||||||
|
>
|
||||||
|
{modelOptions.length > 0 ? (
|
||||||
|
modelOptions.map((model) => (
|
||||||
|
<option key={model.id} value={model.id}>
|
||||||
|
{model.displayName} ·{" "}
|
||||||
|
{model.unitCost > 0
|
||||||
|
? `${model.unitCost}积分/张`
|
||||||
|
: "未设置积分"}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="">
|
||||||
|
{modelsLoading ? "加载模型" : "暂无模型"}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<span className="rounded-full bg-black/[0.04] px-2 py-0.5 font-semibold text-black/55">
|
||||||
|
{selectedModelCostLabel}
|
||||||
|
</span>
|
||||||
|
{selectedModel?.creditDiscountLabel ? (
|
||||||
|
<span className="rounded-full bg-emerald-50 px-2 py-0.5 font-semibold text-emerald-600">
|
||||||
|
{selectedModel.creditDiscountLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
|
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
|
||||||
disabled={generating || Boolean(disabledReason)}
|
disabled={generating || Boolean(disabledReason)}
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
|
title={disabledReason || selectedModelCostLabel}
|
||||||
>
|
>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{generating ? "正在创建图片" : "执行"}
|
{generating
|
||||||
|
? "正在创建图片"
|
||||||
|
: selectedModel?.unitCost
|
||||||
|
? `执行 · ${selectedModel.unitCost}积分`
|
||||||
|
: "执行"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="h-8 rounded-[14px] border-black/10 bg-white px-4 text-[12px] text-black/70 shadow-sm hover:bg-black/5"
|
className="h-8 rounded-[14px] border-black/10 bg-white px-4 text-[12px] text-black/70 shadow-sm hover:bg-black/5"
|
||||||
disabled={!currentResult}
|
disabled={!currentResult}
|
||||||
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
|
onClick={() =>
|
||||||
|
currentResult && onAddToCanvas?.(currentResult)
|
||||||
|
}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
添加到画布
|
添加到画布
|
||||||
@ -821,10 +1044,13 @@ export function StyleProWorkspace({
|
|||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
<div className="flex items-center gap-1 text-[11px] text-gray-400">
|
<div className="flex items-center gap-1 text-[11px] text-gray-400">
|
||||||
<Clock3 className="h-3 w-3" />
|
<Clock3 className="h-3 w-3" />
|
||||||
{new Date(item.createdAt).toLocaleTimeString("zh-CN", {
|
{new Date(item.createdAt).toLocaleTimeString(
|
||||||
hour: "2-digit",
|
"zh-CN",
|
||||||
minute: "2-digit",
|
{
|
||||||
})}
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="line-clamp-1 text-[12px] text-black/70">
|
<div className="line-clamp-1 text-[12px] text-black/70">
|
||||||
{item.optionLabels[0] || "风格化结果"}
|
{item.optionLabels[0] || "风格化结果"}
|
||||||
|
|||||||
92
lib/style-pro/model-options.ts
Normal file
92
lib/style-pro/model-options.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
|
||||||
|
import {
|
||||||
|
getImageCreditCostFromConfig,
|
||||||
|
getImageModelCreditDiscountLabel,
|
||||||
|
} from "@/lib/ai/image-model-pricing";
|
||||||
|
import { isImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||||
|
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||||
|
|
||||||
|
const STYLE_PRO_SUPPORTED_PROVIDER_TYPES = new Set([
|
||||||
|
"gemini",
|
||||||
|
"grsai-gpt-image",
|
||||||
|
"alibaba-image",
|
||||||
|
"bfl-image",
|
||||||
|
"ruxa-image",
|
||||||
|
"fal-image",
|
||||||
|
"openai-image",
|
||||||
|
"stepfun-image",
|
||||||
|
"midjourney",
|
||||||
|
"runway",
|
||||||
|
"krea",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type StyleProModelEntry = Awaited<
|
||||||
|
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
export type PublicStyleProModelOption = {
|
||||||
|
id: string;
|
||||||
|
modelId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
providerName: string;
|
||||||
|
providerType: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
unitCost: number;
|
||||||
|
creditDiscountLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isStyleProImageModel(entry: StyleProModelEntry) {
|
||||||
|
const { model, provider } = entry;
|
||||||
|
if (!model.supportsImageGen) return false;
|
||||||
|
if (!STYLE_PRO_SUPPORTED_PROVIDER_TYPES.has(provider.type)) return false;
|
||||||
|
return isImageGenerationModelDescriptor({
|
||||||
|
providerType: provider.type,
|
||||||
|
modelId: model.modelId,
|
||||||
|
capabilities: model.capabilities,
|
||||||
|
supportsImageGen: model.supportsImageGen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStyleProImageModelEntries(options?: {
|
||||||
|
includeHidden?: boolean;
|
||||||
|
}) {
|
||||||
|
const models = await getEnabledAiModelsWithProvider(
|
||||||
|
false,
|
||||||
|
options?.includeHidden === true
|
||||||
|
);
|
||||||
|
return models.filter(isStyleProImageModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findStyleProImageModelByRef(
|
||||||
|
entries: StyleProModelEntry[],
|
||||||
|
modelRef?: string | null
|
||||||
|
) {
|
||||||
|
const normalized = String(modelRef || "").trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return (
|
||||||
|
entries.find(
|
||||||
|
({ model }) => model.id === normalized || model.modelId === normalized
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPublicStyleProModelOption(
|
||||||
|
entry: StyleProModelEntry
|
||||||
|
): PublicStyleProModelOption {
|
||||||
|
const imageGenConfig = (entry.model.imageGenConfig ||
|
||||||
|
null) as DiscountableImageGenConfigLike | null;
|
||||||
|
return {
|
||||||
|
id: entry.model.id,
|
||||||
|
modelId: entry.model.modelId,
|
||||||
|
displayName: entry.model.displayName,
|
||||||
|
avatarUrl: entry.model.avatarUrl || null,
|
||||||
|
providerName: entry.provider.name,
|
||||||
|
providerType: entry.provider.type,
|
||||||
|
isDefault: entry.model.isDefault,
|
||||||
|
unitCost: getImageCreditCostFromConfig(imageGenConfig),
|
||||||
|
creditDiscountLabel: getImageModelCreditDiscountLabel(imageGenConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user