feat: add discounted model pricing and fix realtime 3d media handling
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-13 20:02:52 +08:00
parent d0bb8b7cf7
commit 1b1ff65de8
20 changed files with 583 additions and 145 deletions

View File

@ -275,6 +275,10 @@ import {
recommendedVideoDurationReferenceCosts,
recommendedVideoResolutionBaseCosts,
} from "@/lib/ai/video-model-config";
import {
formatImageModelCreditDiscountLabel,
getGenerationCreditCostFromImageGenConfig,
} from "@/lib/ai/image-model-pricing";
import type { AgentWorkflowUiConfig } from "@/types/agent";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -595,6 +599,7 @@ type ImageGenConfig = {
supportsWatermark?: boolean; // 是否支持水印开关
supportsSeed?: boolean; // 是否支持随机种子
creditCostPerImage?: number; // 每张图片积分消耗
creditDiscountRate?: number; // 积分折扣,按“折”配置,如 5 = 5折
creditCostPerVideo?: number; // 每条视频积分消耗
videoDurationOptions?: number[]; // 视频秒数选项
creditCostPerVideoByDuration?: Record<string, number>; // 各秒数积分
@ -15025,6 +15030,57 @@ export function AdminSettingsClient() {
</p>
</div>
<div className="space-y-2">
<Label>积分折扣(折)</Label>
<Input
type="number"
min={0.1}
max={10}
step={0.1}
placeholder="留空或 10 表示不打折"
value={
modelForm.imageGenConfig?.creditDiscountRate ?? ""
}
onChange={(e) => {
const rawValue = String(e.target.value || "").trim();
const nextRate = rawValue
? Math.min(
10,
Math.max(0.1, Number.parseFloat(rawValue) || 0)
)
: undefined;
setModelForm({
...modelForm,
imageGenConfig: {
...modelForm.imageGenConfig,
creditDiscountRate: nextRate,
},
});
}}
/>
<p className="text-xs text-muted-foreground">
例如输入 5 表示 5折,7.5 表示 7.5折;前台模型列表会自动显示折扣标识。
</p>
{formatImageModelCreditDiscountLabel(
modelForm.imageGenConfig?.creditDiscountRate
) ? (
<p className="text-xs text-emerald-600">
当前生效:
{formatImageModelCreditDiscountLabel(
modelForm.imageGenConfig?.creditDiscountRate
)}
,图片单价将按{" "}
{
getGenerationCreditCostFromImageGenConfig(
modelForm.imageGenConfig,
"image"
)
}{" "}
积分结算。
</p>
) : null}
</div>
<div className="space-y-2">
<Label>每条视频积分消耗</Label>
<Input
@ -15370,6 +15426,18 @@ export function AdminSettingsClient() {
duration,
resolution
)}{" "}
{formatImageModelCreditDiscountLabel(
modelForm.imageGenConfig?.creditDiscountRate
)
? `→ ${getGenerationCreditCostFromImageGenConfig(
modelForm.imageGenConfig,
"video",
{
duration,
resolution,
}
)} `
: ""}
积分
</td>
)

View File

@ -94,7 +94,7 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import {
bootstrapConversationStateProtocol,
composeConversationStateProtocol,
@ -4218,21 +4218,7 @@ function getModelCreditCost(
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null }
): number {
const config = (modelConfig as any)?.imageGenConfig || {};
const sourceValue =
target === "video"
? (() => {
const resolvedVideoCost = getVideoCreditCostFromConfig(
config,
options?.duration,
options?.resolution
);
if (resolvedVideoCost > 0) return resolvedVideoCost;
return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0;
})()
: config.creditCostPerImage ?? 0;
const normalized = Number(sourceValue);
return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0;
return getModelGenerationCreditCost(modelConfig, target, options);
}
async function resolveBillingModelByReference(modelRef: string): Promise<any | undefined> {
@ -9695,9 +9681,7 @@ export async function POST(request: Request) {
}
: undefined
);
const modelCreditCostPerImage = Number(
(currentModelConfig as any)?.imageGenConfig?.creditCostPerImage ?? 0
);
const modelCreditCostPerImage = getModelCreditCost(currentModelConfig, "image");
const modelCreditCostPerVideo = getModelCreditCost(currentModelConfig, "video", {
duration: Number(effectiveVideoParams?.duration) || undefined,
resolution: String(effectiveVideoParams?.resolution || "").trim() || undefined,

View File

@ -20,6 +20,7 @@ import {
getConfiguredVideoDurationOptions,
normalizeVideoResolutionCostMap,
} from "@/lib/ai/video-model-config";
import { normalizeImageGenPricingConfig } from "@/lib/ai/image-model-pricing";
// 🆕 图片生成模型特性配置 Schema
const imageGenConfigSchema = z.object({
@ -33,6 +34,7 @@ const imageGenConfigSchema = z.object({
supportsWatermark: z.boolean().optional(),
supportsSeed: z.boolean().optional(),
creditCostPerImage: z.number().min(0).max(100000).optional(),
creditDiscountRate: z.number().min(0.1).max(10).optional(),
creditCostPerVideo: z.number().min(0).max(100000).optional(),
videoDurationOptions: z.array(z.number().min(1).max(600)).max(20).optional(),
creditCostPerVideoByDuration: z.record(z.string(), z.number().min(0).max(100000)).optional(),
@ -53,14 +55,14 @@ function normalizeImageGenConfig(config: z.infer<typeof imageGenConfigSchema>) {
fallbackCost: config.creditCostPerVideo,
});
return {
return normalizeImageGenPricingConfig({
...config,
videoDurationOptions: videoDurationOptions.length ? videoDurationOptions : undefined,
creditCostPerVideoByDuration,
creditCostPerVideoByResolution: normalizeVideoResolutionCostMap(
config.creditCostPerVideoByResolution
),
};
});
}
const createModelSchema = z.object({

View File

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
export const runtime = "nodejs";
export const maxDuration = 60;
@ -22,6 +23,62 @@ function isAllowedModelHost(hostname: string) {
);
}
function isGltfAssetRequest(targetUrl: URL, contentType: string | null) {
const normalizedType = String(contentType || "")
.toLowerCase()
.split(";")[0]
?.trim();
if (normalizedType === "model/gltf+json") return true;
if (targetUrl.pathname.toLowerCase().endsWith(".gltf")) return true;
return false;
}
function rewriteGltfAssetUri(assetUri: string, sourceUrl: URL) {
const normalized = assetUri.trim();
if (!normalized) return normalized;
if (normalized.startsWith("data:") || normalized.startsWith("blob:")) {
return normalized;
}
try {
const resolved = new URL(normalized, sourceUrl).toString();
return buildThreeAssetUrl(resolved);
} catch {
return normalized;
}
}
function rewriteGltfAssetList(value: unknown, sourceUrl: URL) {
if (!Array.isArray(value)) return value;
return value.map((item) => {
if (!item || typeof item !== "object") return item;
const next = { ...(item as Record<string, unknown>) };
if (typeof next.uri === "string") {
next.uri = rewriteGltfAssetUri(next.uri, sourceUrl);
}
return next;
});
}
function rewriteGltfPayload(raw: string, sourceUrl: URL) {
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const next = { ...parsed };
if (Array.isArray(parsed.buffers)) {
next.buffers = rewriteGltfAssetList(parsed.buffers, sourceUrl);
}
if (Array.isArray(parsed.images)) {
next.images = rewriteGltfAssetList(parsed.images, sourceUrl);
}
return JSON.stringify(next);
} catch {
return raw;
}
}
export async function GET(request: Request) {
try {
const session = await auth();
@ -75,6 +132,7 @@ export async function GET(request: Request) {
const responseHeaders = new Headers();
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
const shouldRewriteGltf = !range && isGltfAssetRequest(targetUrl, contentType);
responseHeaders.set("content-type", contentType);
const contentLength = upstream.headers.get("content-length");
@ -88,6 +146,17 @@ export async function GET(request: Request) {
responseHeaders.set("cache-control", "private, no-store");
if (shouldRewriteGltf) {
const rewrittenPayload = rewriteGltfPayload(await upstream.text(), targetUrl);
responseHeaders.set("content-type", "model/gltf+json; charset=utf-8");
responseHeaders.delete("content-length");
return new NextResponse(rewrittenPayload, {
status: upstream.status,
headers: responseHeaders,
});
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,

View File

@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
import {
resolveThreeDModelDisplayName,
resolveThreeDModelRealId,
} from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { get3DTaskCreditCost } from "@/lib/points/config";
@ -375,6 +378,17 @@ export async function POST(request: Request) {
);
}
const billableTaskLabel =
taskType === "text_to_model" ||
taskType === "image_to_model" ||
taskType === "multiview_to_model"
? resolveThreeDModelDisplayName(
payload.model_version,
modelAliasConfig.tripoModels,
taskType,
)
: taskType;
if (body.compress === true) {
payload.compress = "geometry";
}
@ -391,7 +405,7 @@ export async function POST(request: Request) {
unitCost,
reason: "model3d_task",
modelId: "tripo-3d",
note: `3D模型任务消耗积分(${taskType})`,
note: `3D模型任务消耗积分(${billableTaskLabel})`,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
@ -440,7 +454,7 @@ export async function POST(request: Request) {
if (pointsCharge.chargeable) {
try {
const consumeResult = await pointsCharge.consume({
note: `3D模型任务消耗积分(${taskType} / ${taskId})`,
note: `3D模型任务消耗积分(${billableTaskLabel} / ${taskId})`,
});
if (!consumeResult?.ok) {

View File

@ -5,7 +5,10 @@ import {
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config";
import {
resolveThreeDModelDisplayName,
resolveThreeDModelRealId,
} from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { get3DWorldCreditCost } from "@/lib/points/config";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
@ -160,6 +163,11 @@ export async function POST(request: Request) {
readOptionalTrimmedText(body.model),
modelAliasConfig.worldModels,
);
const worldModelDisplayName = resolveThreeDModelDisplayName(
resolvedWorldModel || "marble-1.1",
modelAliasConfig.worldModels,
resolvedWorldModel || "marble-1.1",
);
const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode);
@ -269,7 +277,7 @@ export async function POST(request: Request) {
amount: unitCost,
reason: "world3d_generation",
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
note: `3D世界生成消耗积分(${resolvedWorldModel || "marble-1.1"})`,
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
});
} catch (error) {
console.error("[Points] Failed to consume World Labs 3D points:", error);

View File

@ -1,13 +1,15 @@
import { NextResponse } from "next/server";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { paymentOrder, systemSettings } from "@/lib/db/schema";
import { model3dAsset, paymentOrder, systemSettings } from "@/lib/db/schema";
import {
getPointsLedgerByUserId,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { resolveThreeDModelDisplayName } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import {
defaultMembershipConfig,
defaultMembershipUserOverrides,
@ -19,6 +21,9 @@ import {
const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
const MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN = /^3D模型任务消耗积分((.+?) \/ ([^)]+))$/;
const MODEL3D_LEDGER_NOTE_PATTERN = /^3D模型任务消耗积分((.+?))$/;
const WORLD3D_LEDGER_NOTE_PATTERN = /^3D世界生成消耗积分((.+?))$/;
function mapLedgerReasonLabel(reason: string) {
if (reason === "image_generation") return "图片生成";
@ -36,12 +41,61 @@ function mapLedgerReasonLabel(reason: string) {
return reason || "积分变动";
}
function formatLedgerDetail(reason: string, note: string | null | undefined) {
function extractWorldBillingModelId(modelId: string | null | undefined) {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
if (!normalized) return "";
if (!normalized.startsWith("worldlabs-3d:")) return normalized;
return normalized.slice("worldlabs-3d:".length).trim();
}
function formatLedgerDetail(params: {
reason: string;
note: string | null | undefined;
modelId?: string | null;
tripoModels: Array<{ id: string; displayName: string; realModelId?: string }>;
worldModels: Array<{
id: string;
displayName: string;
realModelId?: string;
billingModelId?: string;
}>;
tripoTaskDisplayNamesByTaskId: Map<string, string>;
}) {
const { reason, note, modelId, tripoModels, worldModels, tripoTaskDisplayNamesByTaskId } =
params;
if (reason === "recharge") {
return "充值积分";
}
const trimmedNote = typeof note === "string" ? note.trim() : "";
if (reason === "model3d_task") {
const taskMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN);
if (taskMatch) {
const [, rawLabel, taskId] = taskMatch;
const displayName =
tripoTaskDisplayNamesByTaskId.get(taskId) ||
resolveThreeDModelDisplayName(rawLabel, tripoModels, rawLabel);
return `3D模型任务消耗积分(${displayName} / ${taskId})`;
}
const modelMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_PATTERN);
if (modelMatch) {
const [, rawLabel] = modelMatch;
const displayName = resolveThreeDModelDisplayName(rawLabel, tripoModels, rawLabel);
return `3D模型任务消耗积分(${displayName})`;
}
}
if (reason === "world3d_generation") {
const noteMatch = trimmedNote.match(WORLD3D_LEDGER_NOTE_PATTERN);
const rawLabel = extractWorldBillingModelId(modelId) || noteMatch?.[1] || "";
const displayName = resolveThreeDModelDisplayName(rawLabel, worldModels, rawLabel);
if (displayName) {
return `3D世界生成消耗积分(${displayName})`;
}
}
return trimmedNote || mapLedgerReasonLabel(reason);
}
@ -89,6 +143,43 @@ export async function GET() {
.then((rows) => rows[0]),
]);
const threeDModelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const model3dTaskIds = ledger
.filter((item) => item.reason === "model3d_task")
.map((item) => {
const match = String(item.note || "").trim().match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN);
return match?.[2] || "";
})
.filter(Boolean);
const uniqueModel3dTaskIds = Array.from(new Set(model3dTaskIds));
const model3dTaskDisplayNamesByTaskId = new Map<string, string>();
if (uniqueModel3dTaskIds.length > 0) {
const relatedModelTasks = await db
.select({
taskId: model3dAsset.taskId,
modelVersion: model3dAsset.modelVersion,
})
.from(model3dAsset)
.where(
and(
eq(model3dAsset.userId, session.user.id),
inArray(model3dAsset.taskId, uniqueModel3dTaskIds),
),
);
relatedModelTasks.forEach((task) => {
const displayName = resolveThreeDModelDisplayName(
task.modelVersion,
threeDModelAliasConfig.tripoModels,
String(task.modelVersion || "").trim(),
);
if (task.taskId && displayName) {
model3dTaskDisplayNamesByTaskId.set(task.taskId, displayName);
}
});
}
const membershipConfig = configSetting?.value
? normalizeMembershipConfig(configSetting.value)
: defaultMembershipConfig;
@ -105,7 +196,14 @@ export async function GET() {
const usageRecords = ledger.map((item) => ({
id: item.id,
detail: formatLedgerDetail(item.reason, item.note),
detail: formatLedgerDetail({
reason: item.reason,
note: item.note,
modelId: item.modelId,
tripoModels: threeDModelAliasConfig.tripoModels,
worldModels: threeDModelAliasConfig.worldModels,
tripoTaskDisplayNamesByTaskId: model3dTaskDisplayNamesByTaskId,
}),
status: item.amount < 0 ? "consumed" : "earned",
amount: item.amount,
createdAt: item.createdAt?.toISOString() || new Date().toISOString(),

View File

@ -99,6 +99,10 @@ import {
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
extractModelAssetFormat,
resolvePreferredModelAssetUrl,
} from "@/lib/model3d/model-url";
import {
defaultPointsSystemConfig,
get3DTaskCreditCost,
@ -152,6 +156,8 @@ type WorldViewportMode = "world" | "imagery" | "mesh" | "splat";
type Model3DTaskAssetPayload = WorldLabsTaskAssetPayload & {
providerId?: string | null;
providerName?: string | null;
sourceModelUrl?: string | null;
storageArchivedAt?: string | null;
};
type TaskProvider = "tripo-3d" | "worldlabs-3d";
type TaskMode =
@ -319,23 +325,17 @@ function normalizeTaskStatus(status: string | undefined): Model3DTaskStatus {
}
function extractModelFormat(modelUrl?: string | null): string {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "3d";
if (value.includes(".gltf")) return "gltf";
if (value.includes(".glb")) return "glb";
if (value.includes(".usdz")) return "usdz";
if (value.includes(".fbx")) return "fbx";
if (value.includes(".obj")) return "obj";
if (value.includes(".stl")) return "stl";
if (value.includes(".3mf")) return "3mf";
return "3d";
return extractModelAssetFormat(modelUrl);
}
function resolveTaskModelUrl(task?: TaskRecord | null): string | null {
if (!task) return null;
if (task.modelUrl) return task.modelUrl;
const meshUrl = task.assetPayload?.meshUrl;
return typeof meshUrl === "string" && meshUrl.trim() ? meshUrl.trim() : null;
return resolvePreferredModelAssetUrl({
primaryUrl: task.modelUrl,
sourceUrl: task.assetPayload?.sourceModelUrl,
fallbackUrl: typeof meshUrl === "string" ? meshUrl.trim() : null,
});
}
function createMultiViewSlots(count = 4): MultiViewSlot[] {

View File

@ -69,7 +69,10 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import {
getGenerationCreditCostFromImageGenConfig,
getImageCreditCostFromConfig,
} from "@/lib/ai/image-model-pricing";
import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability";
import type { Attachment, ChatMessage } from "@/lib/types";
import type {
@ -3008,6 +3011,7 @@ export function CanvasChatPanel({
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditDiscountRate?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
@ -3034,6 +3038,7 @@ export function CanvasChatPanel({
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditDiscountRate?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
@ -8040,10 +8045,7 @@ export function CanvasChatPanel({
const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true;
const blockOnInsufficient =
membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false;
const unitCost = Math.max(
0,
Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0))
);
const unitCost = getImageCreditCostFromConfig(currentModel?.imageGenConfig);
if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) {
return true;
@ -8073,7 +8075,7 @@ export function CanvasChatPanel({
return false;
}, [
activeComposerMode,
currentModel?.imageGenConfig?.creditCostPerImage,
currentModel?.imageGenConfig,
imageParams?.imageCount,
isVideoModel,
membershipSummary?.isHighVip,
@ -8088,10 +8090,13 @@ export function CanvasChatPanel({
if (activeComposerMode === "agent") return null;
if (isVideoModel) {
const cost = getVideoCreditCostFromConfig(
const cost = getGenerationCreditCostFromImageGenConfig(
currentModel?.imageGenConfig,
videoParams?.duration,
videoParams?.resolution
"video",
{
duration: videoParams?.duration,
resolution: videoParams?.resolution,
}
);
return cost > 0
? {
@ -8101,10 +8106,7 @@ export function CanvasChatPanel({
: null;
}
const unitCost = Math.max(
0,
Math.round(Number(currentModel?.imageGenConfig?.creditCostPerImage ?? 0))
);
const unitCost = getImageCreditCostFromConfig(currentModel?.imageGenConfig);
const imageCount = Math.max(
1,
Math.round(Number(imageParams?.imageCount || 1))

View File

@ -46,7 +46,10 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import {
getGenerationCreditCostFromImageGenConfig,
getImageCreditCostFromConfig,
} from "@/lib/ai/image-model-pricing";
import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
@ -262,6 +265,7 @@ type CanvasModelOption = {
capabilities?: unknown;
imageGenConfig?: {
creditCostPerImage?: number | null;
creditDiscountRate?: number | null;
creditCostPerVideo?: number | null;
videoDurationOptions?: number[] | null;
creditCostPerVideoByDuration?: Record<string, number> | null;
@ -1833,10 +1837,13 @@ function resolveVideoCanvasBillingDisplay(params: {
}
if (params.modelKind === "video") {
const videoCost = getVideoCreditCostFromConfig(
const videoCost = getGenerationCreditCostFromImageGenConfig(
matchedModel.imageGenConfig,
params.duration,
params.resolution,
"video",
{
duration: params.duration,
resolution: params.resolution,
},
);
if (videoCost > 0) {
return {
@ -1848,10 +1855,7 @@ function resolveVideoCanvasBillingDisplay(params: {
}
if (params.modelKind === "image") {
const unitCost = Math.max(
0,
Math.round(Number(matchedModel.imageGenConfig.creditCostPerImage ?? 0)),
);
const unitCost = getImageCreditCostFromConfig(matchedModel.imageGenConfig);
if (unitCost > 0) {
const safeImageCount = Math.max(
1,

View File

@ -943,6 +943,12 @@ export function RealtimeCanvasDialog({
previewUrl,
selectedImageLayer?.src,
]);
const previewDisplayUrl = useMemo(() => {
if (!previewUrl) return "";
return /^https?:\/\//i.test(previewUrl)
? buildImageProxyUrl(previewUrl, { delivery: "proxy" })
: previewUrl;
}, [previewUrl]);
const resolveRealtimeModeItem = useCallback(
(
key: "realtimeModeRealtime" | "realtimeModeEnhance",
@ -3787,7 +3793,7 @@ export function RealtimeCanvasDialog({
<img
alt="实时预览"
className="max-h-full max-w-full object-contain shadow-sm"
src={previewUrl}
src={previewDisplayUrl}
/>
) : (
<div className="flex flex-col items-center justify-center gap-3 text-[#666666] dark:text-white/55">

View File

@ -5344,6 +5344,26 @@ export function ImageNode({
result.data.previewImageUrl ||
params.previewImageUrl ||
params.sourceImageUrl;
const responseAssetPayload =
result.data.assetPayload &&
typeof result.data.assetPayload === "object" &&
!Array.isArray(result.data.assetPayload)
? (result.data.assetPayload as Record<string, unknown>)
: null;
const nextAssetPayload = {
...(responseAssetPayload || {}),
providerId:
params.providerId ||
(typeof responseAssetPayload?.providerId === "string"
? responseAssetPayload.providerId
: null),
providerName:
params.providerName ||
(typeof responseAssetPayload?.providerName === "string"
? responseAssetPayload.providerName
: null),
sourceImageUrl: params.sourceImageUrl,
};
if (!modelUrl) {
const error = "3D 模型已完成,但没有返回模型文件";
updateNode(params.nodeId, {
@ -5401,11 +5421,7 @@ export function ImageNode({
sourceImageUrl: params.sourceImageUrl,
progress: 100,
output: result.data.output || null,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
assetPayload: nextAssetPayload,
},
},
});
@ -5420,11 +5436,7 @@ export function ImageNode({
modelUrl,
previewImageUrl,
error: null,
assetPayload: {
providerId: params.providerId || null,
providerName: params.providerName || null,
sourceImageUrl: params.sourceImageUrl,
},
assetPayload: nextAssetPayload,
createdAt: Date.now(),
});
persistCanvasStateSnapshot();
@ -6621,7 +6633,7 @@ export function ImageNode({
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 z-30 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.45),0_0_10px_rgba(255,255,255,0.45)]"
className="pointer-events-none absolute inset-y-0 z-30 w-px bg-white/95 shadow-[0_0_0_1px_rgba(15,23,42,0.18),0_0_6px_rgba(255,255,255,0.28)]"
style={{
left: `${compareSplitPercent}%`,
transform: "translateX(-50%)",
@ -7176,13 +7188,6 @@ export function ImageNode({
</div>
) : null}
{/* 提示词显示 */}
{data.prompt && isHovered && !isProcessing && (
<div className="absolute right-3 bottom-3 left-3 rounded-[16px] border border-[#dbe2ee] bg-white px-3 py-2 shadow-[0_0_0_1px_rgba(219,226,238,0.26)]">
<p className="line-clamp-2 text-xs leading-5 text-[#556072]">{data.prompt}</p>
</div>
)}
{data.note && !isSelected && (
<div
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-1 rounded-full border border-[#f5e0a7] bg-white px-2 py-1 text-[11px] text-[#c48917] shadow-[0_0_0_1px_rgba(245,224,167,0.22)]"

View File

@ -30,6 +30,10 @@ import {
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
extractModelAssetFormat,
resolvePreferredModelAssetUrl,
} from "@/lib/model3d/model-url";
import {
Model3DViewer,
type Model3DViewerSettings,
@ -57,8 +61,8 @@ const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
wireframe: false,
};
function inferModelFileName(data: Model3DNodeData, nodeId: string) {
const raw = String(data.src || "").trim();
function inferModelFileName(modelUrl: string | null, nodeId: string) {
const raw = String(modelUrl || "").trim();
if (!raw) return `model3d-${nodeId.slice(0, 6)}.glb`;
try {
const url = new URL(raw);
@ -68,17 +72,9 @@ function inferModelFileName(data: Model3DNodeData, nodeId: string) {
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
}
function inferModelFormat(data: Model3DNodeData) {
if (data.format) return data.format.toUpperCase();
const src = String(data.src || "").toLowerCase();
if (src.endsWith(".gltf")) return "GLTF";
if (src.endsWith(".glb")) return "GLB";
if (src.endsWith(".usdz")) return "USDZ";
if (src.endsWith(".fbx")) return "FBX";
if (src.endsWith(".obj")) return "OBJ";
if (src.endsWith(".stl")) return "STL";
if (src.endsWith(".3mf")) return "3MF";
return "3D";
function inferModelFormat(modelUrl: string | null, explicitFormat?: string) {
if (explicitFormat) return explicitFormat.toUpperCase();
return extractModelAssetFormat(modelUrl).toUpperCase();
}
function readWorldAssetPayload(data: Model3DNodeData) {
@ -149,16 +145,33 @@ export function Model3DNode({
[data.modelVersion, providerLabel, publicModelAliasConfig.tripoModels],
);
const worldAssetPayload = readWorldAssetPayload(data);
const sourceModelUrl = readOptionalUrl(worldAssetPayload?.sourceModelUrl);
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
const worldImageUrl =
readOptionalUrl(worldAssetPayload?.panoUrl) ||
readOptionalUrl(worldAssetPayload?.thumbnailUrl) ||
readOptionalUrl(data.previewImage);
const primaryOpenUrl = worldUrl || data.src || null;
const resolvedModelUrl = useMemo(
() =>
resolvePreferredModelAssetUrl({
primaryUrl: data.src,
sourceUrl: sourceModelUrl,
}),
[data.src, sourceModelUrl]
);
const modelFileName = useMemo(
() => inferModelFileName(resolvedModelUrl || data.src || null, node.id),
[data.src, node.id, resolvedModelUrl]
);
const modelFormatLabel = useMemo(
() => inferModelFormat(resolvedModelUrl || data.src || null, data.format),
[data.format, data.src, resolvedModelUrl]
);
const primaryOpenUrl = worldUrl || resolvedModelUrl || null;
const primaryOpenItem = worldUrl ? model3dOpenWorldItem : model3dOpen3dItem;
const primaryOpenLabel = primaryOpenItem.label;
const shouldRenderLiveViewer =
isSelected && data.status === "completed" && Boolean(data.src);
isSelected && data.status === "completed" && Boolean(resolvedModelUrl);
const placeholderSizeScale = useMemo(
() => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76),
[node.size.height, node.size.width]
@ -195,11 +208,11 @@ export function Model3DNode({
};
const handleOpenMesh = () => {
if (!data.src) {
if (!resolvedModelUrl) {
toast.error("暂无可打开的 Mesh 地址");
return;
}
window.open(data.src, "_blank", "noopener,noreferrer");
window.open(resolvedModelUrl, "_blank", "noopener,noreferrer");
};
const handleOpenImage = () => {
@ -211,13 +224,13 @@ export function Model3DNode({
};
const handleDownload = () => {
if (!data.src) {
if (!resolvedModelUrl) {
toast.error("暂无可下载的 3D 文件");
return;
}
const link = document.createElement("a");
link.href = data.src;
link.download = inferModelFileName(data, node.id);
link.href = resolvedModelUrl;
link.download = modelFileName;
link.click();
};
@ -296,7 +309,7 @@ export function Model3DNode({
>
<Model3DViewer
emptyStateTitle="准备查看 3D 模型"
modelUrl={data.src}
modelUrl={resolvedModelUrl || data.src}
previewImageUrl={data.previewImage}
settings={CANVAS_MODEL_VIEWER_SETTINGS}
/>
@ -314,12 +327,12 @@ export function Model3DNode({
</div>
)}
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
{inferModelFormat(data)}
{modelFormatLabel}
</div>
</div>
<div className="space-y-1 border-t px-3 py-2">
<p className="truncate text-xs font-medium">
{data.prompt || inferModelFileName(data, node.id)}
{data.prompt || modelFileName}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}
@ -367,7 +380,7 @@ export function Model3DNode({
/>
{primaryOpenLabel}
</DropdownMenuItem>
{worldUrl && data.src && data.src !== worldUrl && (
{worldUrl && resolvedModelUrl && resolvedModelUrl !== worldUrl && (
<DropdownMenuItem onClick={handleOpenMesh}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"

View File

@ -20,6 +20,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, ChevronDownIcon } from "@/components/icons";
import { getImageModelCreditDiscountLabel } from "@/lib/ai/image-model-pricing";
// 从 API 获取的动态模型类型
type DynamicModel = {
@ -32,6 +33,9 @@ type DynamicModel = {
isDefault: boolean;
providerName: string;
providerType: string;
imageGenConfig?: {
creditDiscountRate?: number | null;
} | null;
};
const fetcher = (url: string) => fetch(url).then((res) => res.json());
@ -217,8 +221,15 @@ export function ModelSelector({
</div>
</div>
<div className="shrink-0 text-foreground opacity-0 group-data-[active=true]/item:opacity-100 dark:text-foreground">
<CheckCircleFillIcon />
<div className="flex shrink-0 items-center gap-2">
{getImageModelCreditDiscountLabel(model.imageGenConfig) ? (
<span className="rounded-md border border-[#d0d4dc] bg-[#eef1f5] px-2 py-1 text-[11px] font-semibold leading-none text-[#5c6574]">
{getImageModelCreditDiscountLabel(model.imageGenConfig)}
</span>
) : null}
<div className="text-foreground opacity-0 group-data-[active=true]/item:opacity-100 dark:text-foreground">
<CheckCircleFillIcon />
</div>
</div>
</button>
</DropdownMenuItem>

View File

@ -7,7 +7,11 @@ import {
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import {
getGenerationCreditCostFromImageGenConfig,
getImageCreditCostFromConfig,
getImageModelCreditDiscountLabel,
} from "@/lib/ai/image-model-pricing";
// 动态模型类型定义
type DynamicModel = {
@ -37,6 +41,7 @@ type DynamicModel = {
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditDiscountRate?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
@ -211,6 +216,7 @@ function PureMultimodalInput({
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditDiscountRate?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;
@ -327,10 +333,13 @@ function PureMultimodalInput({
if (membershipSummary?.pointsSystemConfig?.enabled !== true) return null;
if (isVideoModel) {
const cost = getVideoCreditCostFromConfig(
const cost = getGenerationCreditCostFromImageGenConfig(
imageGenConfig,
videoParams?.duration,
videoParams?.resolution
"video",
{
duration: videoParams?.duration,
resolution: videoParams?.resolution,
}
);
return cost > 0
? {
@ -341,10 +350,7 @@ function PureMultimodalInput({
}
if (!isImageModel) return null;
const unitCost = Math.max(
0,
Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0))
);
const unitCost = getImageCreditCostFromConfig(imageGenConfig);
const imageCount = Math.max(
1,
Math.round(Number(imageParams?.imageCount || 1))
@ -438,10 +444,7 @@ function PureMultimodalInput({
const pointsEnabled = membershipSummary?.pointsSystemConfig?.enabled === true;
const blockOnInsufficient =
membershipSummary?.pointsSystemConfig?.blockOnInsufficient !== false;
const unitCost = Math.max(
0,
Math.round(Number(imageGenConfig?.creditCostPerImage ?? 0))
);
const unitCost = getImageCreditCostFromConfig(imageGenConfig);
if (!pointsEnabled || !blockOnInsufficient || unitCost <= 0) {
return true;
@ -470,7 +473,7 @@ function PureMultimodalInput({
toast.error("积分不足,请先充值后再生图");
return false;
}, [
imageGenConfig?.creditCostPerImage,
imageGenConfig,
imageParams?.imageCount,
isImageModel,
isVideoModel,
@ -1835,7 +1838,7 @@ function PureModelSelectorCompact({
key={model.id}
value={model.id}
className={cn(
"h-10 rounded-[14px] px-3 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
"h-11 rounded-[14px] px-3 text-[#2f3640] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full",
"focus:bg-[#e7ebf0] focus:text-[#1f2937]",
"data-[state=checked]:bg-[#e7ebf0] data-[state=checked]:text-[#1f2937]",
model.isSubModel ? "pl-8" : ""
@ -1871,9 +1874,16 @@ function PureModelSelectorCompact({
{model.isSubModel ? `└ ${model.displayName}` : model.displayName}
</span>
</span>
{optimisticModelId === model.id ? (
<span className="text-[18px] font-semibold text-[#1f2937]">✓</span>
) : null}
<span className="flex items-center gap-2">
{getImageModelCreditDiscountLabel(model.imageGenConfig) ? (
<span className="rounded-[10px] border border-[#c7ccd6] bg-[#eef1f5] px-2 py-0.5 text-[11px] font-semibold leading-none text-[#5b6472] shadow-[inset_0_1px_0_rgba(255,255,255,0.85)]">
{getImageModelCreditDiscountLabel(model.imageGenConfig)}
</span>
) : null}
{optimisticModelId === model.id ? (
<span className="text-[18px] font-semibold text-[#1f2937]">✓</span>
) : null}
</span>
</span>
</SelectItem>
))}

View File

@ -0,0 +1,104 @@
import { getVideoCreditCostFromConfig, type VideoImageGenConfigLike } from "@/lib/ai/video-model-config";
export type DiscountableImageGenConfigLike = VideoImageGenConfigLike & {
creditCostPerImage?: number | null;
creditDiscountRate?: number | null;
};
function normalizeRoundedCost(value: unknown) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) return 0;
return Math.max(0, Math.round(normalized));
}
export function normalizeImageModelCreditDiscountRate(value: unknown) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) return null;
const rounded = Math.round(normalized * 10) / 10;
if (rounded <= 0 || rounded >= 10) return null;
return rounded;
}
export function formatImageModelCreditDiscountLabel(value: unknown) {
const normalized = normalizeImageModelCreditDiscountRate(value);
if (normalized === null) return null;
const displayValue = Number.isInteger(normalized)
? String(normalized)
: normalized.toFixed(1).replace(/\.0$/, "");
return `${displayValue}折`;
}
export function getImageModelCreditDiscountLabel(
config: DiscountableImageGenConfigLike | null | undefined
) {
return formatImageModelCreditDiscountLabel(config?.creditDiscountRate);
}
export function applyImageModelCreditDiscount(
rawCost: unknown,
config: DiscountableImageGenConfigLike | null | undefined
) {
const baseCost = normalizeRoundedCost(rawCost);
if (baseCost <= 0) return 0;
const normalizedDiscountRate = normalizeImageModelCreditDiscountRate(
config?.creditDiscountRate
);
if (normalizedDiscountRate === null) return baseCost;
return Math.max(0, Math.round((baseCost * normalizedDiscountRate) / 10));
}
export function getGenerationCreditCostFromImageGenConfig(
config: DiscountableImageGenConfigLike | null | undefined,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null }
) {
const sourceValue =
target === "video"
? (() => {
const resolvedVideoCost = getVideoCreditCostFromConfig(
config,
options?.duration,
options?.resolution
);
if (resolvedVideoCost > 0) return resolvedVideoCost;
return config?.creditCostPerVideo ?? config?.creditCostPerImage ?? 0;
})()
: config?.creditCostPerImage ?? 0;
return applyImageModelCreditDiscount(sourceValue, config);
}
export function getImageCreditCostFromConfig(
config: DiscountableImageGenConfigLike | null | undefined
) {
return getGenerationCreditCostFromImageGenConfig(config, "image");
}
export function getModelGenerationCreditCost(
modelConfig: { imageGenConfig?: DiscountableImageGenConfigLike | null } | null | undefined,
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null }
) {
return getGenerationCreditCostFromImageGenConfig(
modelConfig?.imageGenConfig,
target,
options
);
}
export function normalizeImageGenPricingConfig<
T extends DiscountableImageGenConfigLike | null | undefined,
>(config: T): T {
if (!config) return config;
const normalizedDiscountRate = normalizeImageModelCreditDiscountRate(
config.creditDiscountRate
);
return {
...config,
creditDiscountRate: normalizedDiscountRate ?? undefined,
} as T;
}

View File

@ -11,7 +11,7 @@ import {
upsertMediaAsset,
} from "@/lib/db/queries";
import { persistImageWorkflowTurn } from "@/lib/ai/conversation-state-protocol";
import { getVideoCreditCostFromConfig } from "@/lib/ai/video-model-config";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
const MODEL_UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@ -68,22 +68,7 @@ function getModelCreditCost(
target: "image" | "video",
options?: { duration?: number | null; resolution?: string | null },
): number {
const config = (modelConfig as any)?.imageGenConfig || {};
const sourceValue =
target === "video"
? (() => {
const resolvedVideoCost = getVideoCreditCostFromConfig(
config,
options?.duration,
options?.resolution,
);
if (resolvedVideoCost > 0) return resolvedVideoCost;
return config.creditCostPerVideo ?? config.creditCostPerImage ?? 0;
})()
: config.creditCostPerImage ?? 0;
const normalized = Number(sourceValue);
return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0;
return getModelGenerationCreditCost(modelConfig, target, options);
}
async function resolveBillingModelByReference(modelRef: string): Promise<any | undefined> {

View File

@ -4009,6 +4009,7 @@ interface ImageGenConfig {
supportsWatermark?: boolean;
supportsSeed?: boolean;
creditCostPerImage?: number;
creditDiscountRate?: number;
creditCostPerVideo?: number;
videoDurationOptions?: number[];
creditCostPerVideoByDuration?: Record<string, number>;

43
lib/model3d/model-url.ts Normal file
View File

@ -0,0 +1,43 @@
export type ModelAssetFormat = "gltf" | "glb" | "usdz" | "fbx" | "obj" | "stl" | "3mf" | "3d";
export function normalizeModelAssetUrl(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized || null;
}
export function extractModelAssetFormat(modelUrl?: string | null): ModelAssetFormat {
const value = String(modelUrl || "").toLowerCase();
if (!value) return "3d";
if (value.includes(".gltf")) return "gltf";
if (value.includes(".glb")) return "glb";
if (value.includes(".usdz")) return "usdz";
if (value.includes(".fbx")) return "fbx";
if (value.includes(".obj")) return "obj";
if (value.includes(".stl")) return "stl";
if (value.includes(".3mf")) return "3mf";
return "3d";
}
export function resolvePreferredModelAssetUrl(params: {
primaryUrl?: string | null;
sourceUrl?: string | null;
fallbackUrl?: string | null;
}) {
const primaryUrl = normalizeModelAssetUrl(params.primaryUrl);
const sourceUrl = normalizeModelAssetUrl(params.sourceUrl);
const fallbackUrl = normalizeModelAssetUrl(params.fallbackUrl);
if (sourceUrl) {
if (!primaryUrl) return sourceUrl;
if (primaryUrl !== sourceUrl) {
const primaryFormat = extractModelAssetFormat(primaryUrl);
const sourceFormat = extractModelAssetFormat(sourceUrl);
if (primaryFormat === "gltf") return sourceUrl;
if (primaryFormat !== "glb" && sourceFormat === "glb") return sourceUrl;
}
}
return primaryUrl || sourceUrl || fallbackUrl || null;
}

View File

@ -170,10 +170,11 @@ export function pickTaskModelUrl(output?: Record<string, unknown> | null): strin
output.obj,
output.stl,
];
const normalizedCandidates: string[] = [];
for (const item of candidates) {
if (typeof item === "string") {
const value = item.trim();
if (value) return value;
if (value) normalizedCandidates.push(value);
continue;
}
if (item && typeof item === "object") {
@ -181,10 +182,20 @@ export function pickTaskModelUrl(output?: Record<string, unknown> | null): strin
typeof (item as Record<string, unknown>).url === "string"
? String((item as Record<string, unknown>).url).trim()
: "";
if (nested) return nested;
if (nested) normalizedCandidates.push(nested);
}
}
return null;
const firstCandidate = normalizedCandidates[0] || null;
if (!firstCandidate) return null;
if (/\.gltf(\?|#|$)/i.test(firstCandidate)) {
const embeddedCandidate = normalizedCandidates.find((value) =>
/\.glb(\?|#|$)/i.test(value)
);
if (embeddedCandidate) return embeddedCandidate;
}
return firstCandidate;
}
export function pickTaskPreviewImageUrl(output?: Record<string, unknown> | null): string | null {