"use client"; /* eslint-disable @next/next/no-img-element */ import dynamic from "next/dynamic"; import { Box, Download, Loader2, Maximize2, Minimize2, RotateCcw, Undo2, X, } from "lucide-react"; import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { buildOpenClawArtifactProxyUrl, getOpenClaw3DArtifactBundle, type OpenClawArtifact, } from "@/lib/openclaw-chat/artifacts"; import { normalizeOpenClawModelInteractionManifest, type OpenClawModelInteractionManifest, type OpenClawModelInteractionPart, } from "@/lib/openclaw-chat/model-interaction"; const Model3DViewer = dynamic( () => import("@/components/canvas/model3d/Model3DViewer").then( (module) => module.Model3DViewer ), { ssr: false, loading: () => , } ); const OpenClawInteractiveModelViewer = dynamic( () => import( "@/components/canvas/chat-panel/OpenClawInteractiveModelViewer" ).then((module) => module.OpenClawInteractiveModelViewer), { ssr: false, loading: () => , } ); const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer"; function ViewerLoading() { return (
); } function supportsWebGl() { try { const canvas = document.createElement("canvas"); return Boolean( canvas.getContext("webgl2") || canvas.getContext("webgl") || canvas.getContext("experimental-webgl") ); } catch { return false; } } function formatDimensions(part: OpenClawModelInteractionPart) { const dimensions = part.dimensionsMm; if (!dimensions) return null; return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`; } export function OpenClawModel3DPreview({ artifacts, chatId, messageId, }: { artifacts: OpenClawArtifact[]; chatId: string; messageId: string; }) { const viewerId = useId(); const bundle = useMemo( () => getOpenClaw3DArtifactBundle(artifacts), [artifacts] ); const model = bundle?.model || null; const [isActive, setIsActive] = useState(false); const [webGlUnavailable, setWebGlUnavailable] = useState(false); const [resetViewToken, setResetViewToken] = useState(0); const [resetSelectedToken, setResetSelectedToken] = useState(0); const [resetAllToken, setResetAllToken] = useState(0); const [manifest, setManifest] = useState(); const [manifestError, setManifestError] = useState(null); const [selectedPart, setSelectedPart] = useState(null); const [matchState, setMatchState] = useState({ matched: 0, total: 0 }); const [explodeAmount, setExplodeAmount] = useState(0); const [transformRequest, setTransformRequest] = useState({ actionId: null as string | null, amount: 0, animate: false, durationMs: 700, nonce: 0, }); const closeViewer = useCallback(() => { setIsActive(false); setSelectedPart(null); setMatchState({ matched: 0, total: 0 }); }, []); const handleMatchChange = useCallback((matched: number, total: number) => { setMatchState((current) => current.matched === matched && current.total === total ? current : { matched, total } ); }, []); useEffect(() => { const handleOtherViewerActivated = (event: Event) => { const activeId = (event as CustomEvent).detail; if (activeId !== viewerId) closeViewer(); }; window.addEventListener(ACTIVE_VIEWER_EVENT, handleOtherViewerActivated); return () => { window.removeEventListener(ACTIVE_VIEWER_EVENT, handleOtherViewerActivated); }; }, [closeViewer, viewerId]); useEffect(() => { if (!isActive) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); closeViewer(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [closeViewer, isActive]); useEffect(() => { if (!isActive || !bundle || manifest !== undefined) return; if (bundle.manifestIndex < 0) { setManifest(null); return; } const controller = new AbortController(); const manifestUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, artifactIndex: bundle.manifestIndex, }); setManifestError(null); fetch(manifestUrl, { cache: "no-store", signal: controller.signal }) .then(async (response) => { if (!response.ok) { throw new Error(`交互清单读取失败(HTTP ${response.status})`); } const normalized = normalizeOpenClawModelInteractionManifest( await response.json() ); if (!normalized) throw new Error("交互清单格式不正确"); setManifest(normalized); }) .catch((error) => { if (controller.signal.aborted) return; setManifest(null); setManifestError( error instanceof Error ? error.message : "交互清单读取失败" ); }); return () => controller.abort(); }, [bundle, chatId, isActive, manifest, messageId]); if (!bundle || !model) return null; const modelUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, artifactIndex: bundle.modelIndex, }); const downloadUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, artifactIndex: bundle.modelIndex, download: true, }); const previewUrl = bundle.previewIndex >= 0 ? buildOpenClawArtifactProxyUrl({ chatId, messageId, artifactIndex: bundle.previewIndex, }) : null; const isAssembledModel = model.artifactRole.toLowerCase() === "assembled_3d" || model.viewer?.variant === "assembled"; const supportsNodeInteraction = Boolean(manifest && isAssembledModel); const canDragParts = Boolean( supportsNodeInteraction && (model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable)) ); const canExplode = Boolean( supportsNodeInteraction && (model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode) ); const canAssemble = Boolean( supportsNodeInteraction && (model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble) ); const showExplodeSlider = Boolean( supportsNodeInteraction && (model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider) ); const canResetParts = Boolean( supportsNodeInteraction && (model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable)) ); const activateViewer = () => { if (!supportsWebGl()) { setWebGlUnavailable(true); return; } setWebGlUnavailable(false); window.dispatchEvent(new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId })); setIsActive(true); }; const updateExplodeAmount = ( amount: number, animate: boolean, actionId: string | null = null ) => { const normalized = Math.max(0, Math.min(1, amount)); setExplodeAmount(normalized); setTransformRequest((current) => ({ actionId, amount: normalized, animate, durationMs: model.viewer?.transitionDurationMs || 700, nonce: current.nonce + 1, })); }; const selectedDimensions = selectedPart ? formatDimensions(selectedPart) : null; return (
{model.displayName || model.filename || "3D 模型"} {model.viewer?.format || "glb"}
{isActive ? ( <> ) : null}
{isActive ? ( manifest === undefined && bundle.manifestIndex >= 0 ? ( ) : supportsNodeInteraction && manifest ? ( <> {selectedPart ? (

{selectedPart.partId} · {selectedPart.partName}

{selectedDimensions ? (

{selectedDimensions}

) : null}
) : null}
{canExplode ? ( ) : null} {canAssemble ? ( ) : null} {selectedPart && canResetParts ? ( ) : null} {canResetParts ? ( ) : null} {matchState.total > 0 && matchState.matched < matchState.total ? ( 已识别 {matchState.matched}/{matchState.total} 个配件 ) : null}
{showExplodeSlider ? ( ) : null}
) : ( ) ) : (
{previewUrl ? ( {`${model.displayName ) : ( )}
)}
{webGlUnavailable || manifestError ? (

{webGlUnavailable ? "当前浏览器无法启动3D预览,已保留静态预览和模型下载。" : `${manifestError},已切换为基础3D预览。`}

) : null}
); }