"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 (
{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 ? (

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