180 lines
6.0 KiB
TypeScript
180 lines
6.0 KiB
TypeScript
"use client";
|
||
|
||
/* eslint-disable @next/next/no-img-element */
|
||
import dynamic from "next/dynamic";
|
||
import { Box, Download, Loader2, RotateCcw } from "lucide-react";
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
buildOpenClawArtifactProxyUrl,
|
||
isOpenClawInteractive3DArtifact,
|
||
type OpenClawArtifact,
|
||
} from "@/lib/openclaw-chat/artifacts";
|
||
|
||
const Model3DViewer = dynamic(
|
||
() =>
|
||
import("@/components/canvas/model3d/Model3DViewer").then(
|
||
(module) => module.Model3DViewer
|
||
),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-400">
|
||
<Loader2 className="h-6 w-6 animate-spin" />
|
||
</div>
|
||
),
|
||
}
|
||
);
|
||
|
||
function supportsWebGl() {
|
||
try {
|
||
const canvas = document.createElement("canvas");
|
||
return Boolean(
|
||
canvas.getContext("webgl2") ||
|
||
canvas.getContext("webgl") ||
|
||
canvas.getContext("experimental-webgl")
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export function OpenClawModel3DPreview({
|
||
artifacts,
|
||
chatId,
|
||
messageId,
|
||
}: {
|
||
artifacts: OpenClawArtifact[];
|
||
chatId: string;
|
||
messageId: string;
|
||
}) {
|
||
const modelIndex = useMemo(
|
||
() => artifacts.findIndex(isOpenClawInteractive3DArtifact),
|
||
[artifacts]
|
||
);
|
||
const model = modelIndex >= 0 ? artifacts[modelIndex] : null;
|
||
const fallbackRole = model?.viewer?.fallbackArtifactRole || "cad_preview";
|
||
const previewIndex = artifacts.findIndex(
|
||
(artifact) => artifact.artifactRole === fallbackRole
|
||
);
|
||
const [isActive, setIsActive] = useState(false);
|
||
const [webGlUnavailable, setWebGlUnavailable] = useState(false);
|
||
const [resetViewToken, setResetViewToken] = useState(0);
|
||
|
||
if (!model) return null;
|
||
|
||
const modelUrl = buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: modelIndex,
|
||
});
|
||
const downloadUrl = buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: modelIndex,
|
||
download: true,
|
||
});
|
||
const previewUrl =
|
||
previewIndex >= 0
|
||
? buildOpenClawArtifactProxyUrl({
|
||
chatId,
|
||
messageId,
|
||
artifactIndex: previewIndex,
|
||
})
|
||
: null;
|
||
|
||
const activateViewer = () => {
|
||
if (!supportsWebGl()) {
|
||
setWebGlUnavailable(true);
|
||
return;
|
||
}
|
||
setWebGlUnavailable(false);
|
||
setIsActive(true);
|
||
};
|
||
|
||
return (
|
||
<section className="mt-3 w-full max-w-[760px] overflow-hidden rounded-lg border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950">
|
||
<header className="flex h-11 items-center justify-between gap-3 border-b border-black/8 px-3 dark:border-white/10">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<Box className="h-4 w-4 shrink-0 text-[#687386] dark:text-zinc-400" />
|
||
<span className="truncate text-[13px] font-medium text-[#2f3640] dark:text-zinc-200">
|
||
{model.displayName || model.filename || "3D 模型"}
|
||
</span>
|
||
<span className="shrink-0 text-[11px] uppercase text-[#9aa2af] dark:text-zinc-500">
|
||
{model.viewer?.format || "glb"}
|
||
</span>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
{isActive ? (
|
||
<button
|
||
type="button"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||
aria-label="复位3D视角"
|
||
title="复位3D视角"
|
||
onClick={() => setResetViewToken((value) => value + 1)}
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</button>
|
||
) : null}
|
||
<a
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||
aria-label="下载3D模型"
|
||
title="下载3D模型"
|
||
href={downloadUrl}
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div
|
||
className="relative h-[clamp(280px,42vw,430px)] w-full overflow-hidden"
|
||
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
|
||
>
|
||
{isActive ? (
|
||
<Model3DViewer
|
||
autoFitModel
|
||
modelFormat={model.viewer?.format || "glb"}
|
||
modelUrl={modelUrl}
|
||
previewImageUrl={previewUrl}
|
||
resetViewToken={resetViewToken}
|
||
settings={{
|
||
autoRotate: model.viewer?.autoRotate || false,
|
||
cameraPreset: "iso",
|
||
envPreset: "studio",
|
||
lightIntensity: 1,
|
||
showGrid: false,
|
||
wireframe: false,
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
|
||
{previewUrl ? (
|
||
<img
|
||
alt={`${model.displayName || "3D 模型"}静态预览`}
|
||
className="absolute inset-0 h-full w-full object-contain"
|
||
loading="lazy"
|
||
src={previewUrl}
|
||
/>
|
||
) : (
|
||
<Box className="h-16 w-16 text-[#c7cdd6] dark:text-zinc-700" />
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="relative inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#202631] px-4 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-[#11151c] dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white"
|
||
onClick={activateViewer}
|
||
>
|
||
<Box className="h-4 w-4" />
|
||
查看 3D
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{webGlUnavailable ? (
|
||
<p className="border-t border-black/8 px-3 py-2 text-xs text-amber-700 dark:border-white/10 dark:text-amber-300">
|
||
当前浏览器无法启动3D预览,已保留静态预览和模型下载。
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|