ai-chatbot/components/canvas/chat-panel/OpenClawModel3DPreview.tsx

180 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}