ai-chatbot/components/canvas/nodes/Model3DNode.tsx
Your Name 1b1ff65de8
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add discounted model pricing and fix realtime 3d media handling
2026-05-13 20:02:52 +08:00

435 lines
15 KiB
TypeScript

"use client";
import React, { useMemo, useState } from "react";
import {
AlertCircle,
Box,
Copy,
Download,
ExternalLink,
Globe,
ImageIcon,
Loader2,
MoreVertical,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import useSWR from "swr";
import { toast } from "sonner";
import { getNodeContentScale } from "@/lib/canvas/node-content-scale";
import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
resolveThreeDModelDisplayName,
type PublicThreeDModelAliasConfig,
} from "@/lib/model3d/model-alias-config";
import {
extractModelAssetFormat,
resolvePreferredModelAssetUrl,
} from "@/lib/model3d/model-url";
import {
Model3DViewer,
type Model3DViewerSettings,
} from "@/components/canvas/model3d/Model3DViewer";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
import { cn, fetcher } from "@/lib/utils";
interface Model3DNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
const CANVAS_MODEL_VIEWER_SETTINGS: Model3DViewerSettings = {
cameraPreset: "iso",
envPreset: "studio",
lightIntensity: 1,
autoRotate: false,
showGrid: true,
wireframe: false,
};
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);
const filename = url.pathname.split("/").filter(Boolean).pop();
if (filename) return decodeURIComponent(filename);
} catch {}
return raw.split("/").pop() || `model3d-${nodeId.slice(0, 6)}.glb`;
}
function inferModelFormat(modelUrl: string | null, explicitFormat?: string) {
if (explicitFormat) return explicitFormat.toUpperCase();
return extractModelAssetFormat(modelUrl).toUpperCase();
}
function readWorldAssetPayload(data: Model3DNodeData) {
const raw = data.metadata?.assetPayload;
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
}
function readOptionalUrl(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function Model3DNode({
node,
isSelected,
canvasToolbarConfig,
}: Model3DNodeProps) {
const data = node.data as Model3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const model3dOpen3dItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpen3d"
);
const model3dOpenWorldItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpenWorld"
);
const model3dMoreItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dMore"
);
const model3dOpenMeshItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpenMesh"
);
const model3dOpenImageItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dOpenImage"
);
const model3dDownloadItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dDownload"
);
const model3dCopyItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dCopy"
);
const model3dDeleteItem = getCanvasToolbarItem(
canvasToolbarConfig,
"model3dDelete"
);
const viewportZoom = useCanvasStore((state) => state.viewport.zoom);
const { data: modelAliasConfigResponse } = useSWR<{
success?: boolean;
data?: PublicThreeDModelAliasConfig;
}>("/api/canvas/model-aliases", fetcher);
const providerLabel =
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const modelVersionLabel = useMemo(
() =>
resolveThreeDModelDisplayName(
data.modelVersion,
publicModelAliasConfig.tripoModels,
providerLabel,
),
[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 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(resolvedModelUrl);
const placeholderSizeScale = useMemo(
() => getNodeContentScale(node.size, { width: 360, height: 300 }, 0.76),
[node.size.height, node.size.width]
);
const placeholderViewportScale = useMemo(
() =>
Math.min(
1.55,
Math.max(0.72, placeholderSizeScale / Math.max(viewportZoom, 0.42))
),
[placeholderSizeScale, viewportZoom]
);
const placeholderCenterStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "center center",
}),
[placeholderViewportScale]
);
const placeholderBadgeStyle = useMemo<React.CSSProperties>(
() => ({
transform: `scale(${placeholderViewportScale})`,
transformOrigin: "top left",
}),
[placeholderViewportScale]
);
const handleOpen = () => {
if (!primaryOpenUrl) {
toast.error("暂无可打开的地址");
return;
}
window.open(primaryOpenUrl, "_blank", "noopener,noreferrer");
};
const handleOpenMesh = () => {
if (!resolvedModelUrl) {
toast.error("暂无可打开的 Mesh 地址");
return;
}
window.open(resolvedModelUrl, "_blank", "noopener,noreferrer");
};
const handleOpenImage = () => {
if (!worldImageUrl) {
toast.error("暂无可打开的图像预览");
return;
}
window.open(worldImageUrl, "_blank", "noopener,noreferrer");
};
const handleDownload = () => {
if (!resolvedModelUrl) {
toast.error("暂无可下载的 3D 文件");
return;
}
const link = document.createElement("a");
link.href = resolvedModelUrl;
link.download = modelFileName;
link.click();
};
const handleCopy = async () => {
if (!primaryOpenUrl) {
toast.error("暂无可复制的地址");
return;
}
try {
await navigator.clipboard.writeText(primaryOpenUrl);
toast.success(worldUrl ? "已复制 World 链接" : "已复制 3D 链接");
} catch {
toast.error("复制失败");
}
};
return (
<div
className={cn(
"relative h-full w-full overflow-hidden rounded-lg border bg-card shadow-sm",
isSelected && "border-primary"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{data.status === "generating" ? (
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#f7f9fc] dark:bg-zinc-900">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-[#dde5f4] bg-white text-[#566374] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-200">
<Box className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-cyan-200">
<Loader2 className="h-5 w-5 animate-spin" />
</span>
<span className="text-sm font-medium text-[#3f4b63] dark:text-zinc-100">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成中..."
: "3D 模型生成中..."}
</span>
<span className="text-xs text-[#7a8395] dark:text-zinc-400">
{data.modelVersion ? `模型:${modelVersionLabel}` : providerLabel}
</span>
</div>
</div>
) : data.status === "failed" ? (
<div className="relative flex h-full w-full select-none items-center justify-center overflow-hidden bg-[#fff7f8] dark:bg-red-950/30">
<div className="absolute left-4 top-4" style={placeholderBadgeStyle}>
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col items-center gap-2 px-6 text-center" style={placeholderCenterStyle}>
<span className="flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)] dark:border-red-900/60 dark:bg-zinc-950 dark:text-red-300">
<AlertCircle className="h-5 w-5" />
</span>
<span className="text-sm font-medium text-[#c84b5e] dark:text-red-200">
{data.metadata?.provider === "worldlabs-3d"
? "3D 世界生成失败"
: "3D 模型生成失败"}
</span>
<span className="text-xs text-[#b77480] dark:text-red-300/80">
可以重新选择模型后再试一次
</span>
</div>
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="relative min-h-0 flex-1 overflow-hidden bg-muted/30">
{shouldRenderLiveViewer ? (
<div
className="h-full w-full"
data-canvas-no-drag="true"
onWheel={(event) => event.stopPropagation()}
>
<Model3DViewer
emptyStateTitle="准备查看 3D 模型"
modelUrl={resolvedModelUrl || data.src}
previewImageUrl={data.previewImage}
settings={CANVAS_MODEL_VIEWER_SETTINGS}
/>
</div>
) : data.previewImage ? (
<img
src={data.previewImage}
alt={data.prompt || "3D 预览"}
className="h-full w-full object-cover"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Box className="h-12 w-12 text-muted-foreground/60" />
</div>
)}
<div className="absolute left-2 top-2 rounded-md bg-background/85 px-2 py-0.5 text-[10px] font-medium">
{modelFormatLabel}
</div>
</div>
<div className="space-y-1 border-t px-3 py-2">
<p className="truncate text-xs font-medium">
{data.prompt || modelFileName}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}
</p>
</div>
</div>
)}
{isHovered && data.status === "completed" && (
<div className="absolute right-2 top-2 flex items-center gap-1">
<Button
variant="secondary"
size="icon"
className="h-7 w-7"
onClick={handleOpen}
title={primaryOpenLabel}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={worldUrl ? Globe : ExternalLink}
item={primaryOpenItem}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-7 w-7"
title={model3dMoreItem.label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={model3dMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleOpen}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={worldUrl ? Globe : ExternalLink}
item={primaryOpenItem}
/>
{primaryOpenLabel}
</DropdownMenuItem>
{worldUrl && resolvedModelUrl && resolvedModelUrl !== worldUrl && (
<DropdownMenuItem onClick={handleOpenMesh}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={model3dOpenMeshItem}
/>
{model3dOpenMeshItem.label}
</DropdownMenuItem>
)}
{worldImageUrl && (
<DropdownMenuItem onClick={handleOpenImage}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={ImageIcon}
item={model3dOpenImageItem}
/>
{model3dOpenImageItem.label}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleDownload}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={model3dDownloadItem}
/>
{model3dDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Copy}
item={model3dCopyItem}
/>
{model3dCopyItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={() => deleteNode(node.id)}>
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={model3dDeleteItem}
/>
{model3dDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
}