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 { 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<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 normalizedSelections: Record<string, string[]> = {};
|
||||
|
||||
@ -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) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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";
|
||||
|
||||
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() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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<void>;
|
||||
};
|
||||
|
||||
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<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({
|
||||
@ -361,7 +463,13 @@ export function StyleProWorkspace({
|
||||
);
|
||||
const { data: configData, isLoading: configLoading } =
|
||||
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 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<StyleProHistoryItem | null>(
|
||||
normalizeHistory(persistedState?.history)[0] || null
|
||||
);
|
||||
useState<StyleProHistoryItem | null>(() => {
|
||||
const merged = mergeStyleProState(
|
||||
persistedState,
|
||||
readLocalStyleProState(canvasId)
|
||||
);
|
||||
return merged.history[0] || null;
|
||||
});
|
||||
const [history, setHistory] = useState<StyleProHistoryItem[]>(
|
||||
normalizeHistory(persistedState?.history)
|
||||
() =>
|
||||
mergeStyleProState(persistedState, readLocalStyleProState(canvasId))
|
||||
.history
|
||||
);
|
||||
const [selections, setSelections] = useState<StyleProSelectionMap>({});
|
||||
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({
|
||||
生成后拖动滑块查看原图与结果图。
|
||||
</p>
|
||||
</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
|
||||
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
|
||||
disabled={generating || Boolean(disabledReason)}
|
||||
onClick={handleGenerate}
|
||||
title={disabledReason || selectedModelCostLabel}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{generating ? "正在创建图片" : "执行"}
|
||||
{generating
|
||||
? "正在创建图片"
|
||||
: selectedModel?.unitCost
|
||||
? `执行 · ${selectedModel.unitCost}积分`
|
||||
: "执行"}
|
||||
</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"
|
||||
disabled={!currentResult}
|
||||
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
|
||||
onClick={() =>
|
||||
currentResult && onAddToCanvas?.(currentResult)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
添加到画布
|
||||
@ -821,10 +1044,13 @@ export function StyleProWorkspace({
|
||||
<div className="space-y-1 p-2">
|
||||
<div className="flex items-center gap-1 text-[11px] text-gray-400">
|
||||
<Clock3 className="h-3 w-3" />
|
||||
{new Date(item.createdAt).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{new Date(item.createdAt).toLocaleTimeString(
|
||||
"zh-CN",
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-[12px] text-black/70">
|
||||
{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