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

1205 lines
45 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,
CheckCircle2,
Download,
File,
FileSpreadsheet,
ImageIcon,
Loader2,
Maximize2,
Minimize2,
Package,
RotateCcw,
ShieldAlert,
Undo2,
X,
} from "lucide-react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import {
buildOpenClawArtifactProxyUrl,
getOpenClaw3DArtifactVariant,
getOpenClaw3DArtifactBundle,
type OpenClawArtifact,
} from "@/lib/openclaw-chat/artifacts";
import {
getOpenClawPackagingInteractionParts,
normalizeOpenClawModelInteractionManifest,
type OpenClawModelInteractionManifest,
type OpenClawModelInteractionPart,
} from "@/lib/openclaw-chat/model-interaction";
import {
isOpenClawProductionDrawingReleased,
normalizeOpenClawProductionDrawingManifest,
type OpenClawProductionDrawingManifest,
} from "@/lib/openclaw-chat/production-drawing";
import {
clearOpenClaw3DViewerSession,
readOpenClaw3DViewerSession,
writeOpenClaw3DViewerSession,
} from "@/lib/openclaw-chat/viewer-session";
import type { OpenClawModelCameraState } from "@/components/canvas/chat-panel/OpenClawInteractiveModelViewer";
const Model3DViewer = dynamic(
() =>
import("@/components/canvas/model3d/Model3DViewer").then(
(module) => module.Model3DViewer
),
{
ssr: false,
loading: () => <ViewerLoading />,
}
);
const OpenClawInteractiveModelViewer = dynamic(
() =>
import(
"@/components/canvas/chat-panel/OpenClawInteractiveModelViewer"
).then((module) => module.OpenClawInteractiveModelViewer),
{
ssr: false,
loading: () => <ViewerLoading />,
}
);
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
type OpenClaw3DViewMode = "product" | "packaging";
function ViewerLoading() {
return (
<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" />
<span className="ml-2 text-xs">正在准备 3D 预览…</span>
</div>
);
}
function supportsWebGl() {
try {
const canvas = document.createElement("canvas");
return Boolean(
canvas.getContext("webgl2") ||
canvas.getContext("webgl") ||
canvas.getContext("experimental-webgl")
);
} catch {
return false;
}
}
function getViewerSessionStorage() {
try {
return window.sessionStorage;
} catch {
return null;
}
}
function formatDimensions(part: OpenClawModelInteractionPart) {
const dimensions = part.dimensionsMm;
if (!dimensions) return null;
return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`;
}
function formatArtifactDimensions(artifact: OpenClawArtifact) {
const dimensions = artifact.dimensionsMm;
if (!dimensions) return null;
return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`;
}
function OpenClawProductionDrawingPanel({
artifacts,
chatId,
messageId,
}: {
artifacts: Array<{ artifact: OpenClawArtifact; index: number }>;
chatId: string;
messageId: string;
}) {
const manifestArtifact = artifacts.find(
({ artifact }) =>
artifact.artifactRole.toLowerCase() === "production_drawing_manifest"
);
const pdfs = artifacts.filter(
({ artifact }) =>
artifact.artifactRole.toLowerCase() === "production_drawing_pdf"
);
const dxfBundles = artifacts.filter(
({ artifact }) =>
artifact.artifactRole.toLowerCase() === "production_drawing_dxf_bundle"
);
const [manifest, setManifest] =
useState<OpenClawProductionDrawingManifest | null>();
const [manifestError, setManifestError] = useState(false);
// The parent normalizes artifacts on each render. Object identity must not
// restart an in-flight request (or refetch an already loaded manifest).
const manifestIndex = manifestArtifact?.index ?? -1;
const proxyUrl = useCallback(
(index: number, download = false) =>
buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: index,
download,
}),
[chatId, messageId]
);
useEffect(() => {
if (manifestIndex < 0) {
setManifest(null);
return;
}
const controller = new AbortController();
setManifestError(false);
fetch(proxyUrl(manifestIndex), {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) throw new Error("production manifest unavailable");
const normalized = normalizeOpenClawProductionDrawingManifest(
await response.json()
);
if (!normalized) throw new Error("invalid production manifest");
setManifest(normalized);
})
.catch(() => {
if (controller.signal.aborted) return;
setManifest(null);
setManifestError(true);
});
return () => controller.abort();
}, [manifestIndex, proxyUrl]);
if (!manifestArtifact && pdfs.length === 0 && dxfBundles.length === 0) {
return null;
}
const isReleased = isOpenClawProductionDrawingReleased(manifest || null);
const awaitingEngineeringRelease = Boolean(
manifest?.automatedValidationStatus === "production_ready" &&
manifest.approvalStatus === "awaiting_engineering_release"
);
const statusLabel = isReleased
? "生产图已签批发布"
: awaitingEngineeringRelease
? "自动检查通过,待结构工程师签批"
: "未批准不得下料";
return (
<section className="mt-3 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
<div
className={
isReleased
? "flex items-start gap-2 bg-emerald-50 px-3 py-2.5 text-emerald-800 dark:bg-emerald-950/35 dark:text-emerald-300"
: awaitingEngineeringRelease
? "flex items-start gap-2 bg-amber-50 px-3 py-2.5 text-amber-800 dark:bg-amber-950/35 dark:text-amber-300"
: "flex items-start gap-2 bg-red-50 px-3 py-2.5 text-red-700 dark:bg-red-950/35 dark:text-red-300"
}
>
{isReleased ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
)}
<div className="min-w-0">
<p className="text-xs font-semibold">{statusLabel}</p>
{manifestError ? (
<p className="mt-1 text-[11px] opacity-80">
发布清单读取失败,已按未发布状态保护。
</p>
) : null}
{!manifestArtifact ? (
<p className="mt-1 text-[11px] opacity-80">
缺少生产图发布清单,不能确认签批状态。
</p>
) : null}
{manifest?.releaseBlockers.length ? (
<ul className="mt-1 space-y-0.5 text-[11px] opacity-90">
{manifest.releaseBlockers.map((blocker, index) => (
<li key={`${blocker}:${index}`}>· {blocker}</li>
))}
</ul>
) : null}
{isReleased ? (
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] opacity-85">
{manifest?.version ? <span>版本:{manifest.version}</span> : null}
{manifest?.approvedBy ? (
<span>签批人:{manifest.approvedBy}</span>
) : null}
{manifest?.fileSha256 ? (
<span className="break-all">SHA-256:{manifest.fileSha256}</span>
) : null}
</div>
) : null}
</div>
</div>
{pdfs.length > 0 || dxfBundles.length > 0 ? (
<div className="flex flex-wrap gap-2 px-3 py-2.5">
{pdfs.map(({ artifact, index }) => (
<a
key={`${artifact.downloadUrl}:${index}`}
className="inline-flex h-9 items-center gap-2 rounded-md border border-black/10 px-3 text-xs font-medium text-[#535d6d] hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/8"
href={proxyUrl(index)}
rel="noreferrer"
target="_blank"
>
<File className="h-4 w-4" />
预览/下载生产工艺图
</a>
))}
{dxfBundles.map(({ artifact, index }) => (
<a
key={`${artifact.downloadUrl}:${index}`}
className="inline-flex h-9 items-center gap-2 rounded-md border border-black/10 px-3 text-xs font-medium text-[#535d6d] hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/8"
href={proxyUrl(index, true)}
>
<Download className="h-4 w-4" />
下载DXF图包
</a>
))}
</div>
) : null}
</section>
);
}
function OpenClawSupportingArtifacts({
artifacts,
chatId,
messageId,
}: {
artifacts: OpenClawArtifact[];
chatId: string;
messageId: string;
}) {
const withIndex = artifacts.map((artifact, index) => ({ artifact, index }));
const overview = withIndex.find(
({ artifact }) =>
artifact.artifactRole.toLowerCase() === "bom_component_overview"
);
const thumbnails = withIndex.filter(
({ artifact }) =>
artifact.artifactRole.toLowerCase() === "bom_component_thumbnail"
);
const workbooks = withIndex.filter(
({ artifact }) => artifact.artifactRole.toLowerCase() === "bom_workbook"
);
const advanced = withIndex.filter(({ artifact }) => {
const role = artifact.artifactRole.toLowerCase();
return (
artifact.displayMode.toLowerCase() === "advanced" &&
role !== "model_interaction_manifest" &&
!role.startsWith("production_drawing_")
);
});
const downloads = withIndex.filter(({ artifact }) => {
const role = artifact.artifactRole.toLowerCase();
return (
artifact.displayMode.toLowerCase() === "download" &&
role !== "bom_workbook" &&
role !== "model_interaction_manifest" &&
role !== "production_drawing_manifest" &&
role !== "production_drawing_pdf" &&
role !== "production_drawing_dxf_bundle"
);
});
if (
!overview &&
thumbnails.length === 0 &&
workbooks.length === 0 &&
downloads.length === 0 &&
advanced.length === 0 &&
!withIndex.some(({ artifact }) =>
artifact.artifactRole.toLowerCase().startsWith("production_drawing_")
)
) {
return null;
}
const proxyUrl = (index: number, download = false) =>
buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: index,
download,
});
return (
<section className="mt-3 w-full max-w-[760px] border-t border-black/8 pt-3 dark:border-white/10">
{overview ? (
<figure className="overflow-hidden rounded-lg border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950">
<img
alt={overview.artifact.displayName || "配件图总览"}
className="max-h-[460px] w-full object-contain"
loading="lazy"
src={proxyUrl(overview.index)}
/>
<figcaption className="border-t border-black/8 px-3 py-2 text-xs font-medium text-[#535d6d] dark:border-white/10 dark:text-zinc-300">
{overview.artifact.displayName || "配件图总览"}
</figcaption>
</figure>
) : null}
{thumbnails.length > 0 ? (
<div className="mt-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#535d6d] dark:text-zinc-300">
<ImageIcon className="h-4 w-4" />
<span>配件图对照</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{thumbnails.map(({ artifact, index }) => {
const dimensions = formatArtifactDimensions(artifact);
return (
<figure
key={`${artifact.partId || artifact.filename}:${index}`}
className="min-w-0 overflow-hidden rounded-md border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950"
>
<img
alt={artifact.displayName || artifact.partName || "配件图"}
className="aspect-square w-full object-contain"
loading="lazy"
src={proxyUrl(index)}
/>
<figcaption className="border-t border-black/8 px-2.5 py-2 dark:border-white/10">
<p className="truncate text-xs font-medium text-[#303744] dark:text-zinc-200">
{[artifact.partId, artifact.partName]
.filter(Boolean)
.join(" · ") || artifact.displayName}
</p>
{dimensions ? (
<p className="mt-0.5 truncate text-[10px] text-[#929baa] dark:text-zinc-500">
{dimensions}
</p>
) : null}
</figcaption>
</figure>
);
})}
</div>
</div>
) : null}
{workbooks.length > 0 || downloads.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{workbooks.map(({ artifact, index }) => (
<a
key={`${artifact.downloadUrl}:${index}`}
className="inline-flex h-9 items-center gap-2 rounded-md bg-[#202631] px-3 text-xs font-medium text-white hover:bg-[#11151c] dark:bg-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
href={proxyUrl(index, true)}
>
<FileSpreadsheet className="h-4 w-4" />
{artifact.displayName || "下载带配件图BOM与成本报价"}
</a>
))}
{downloads.map(({ artifact, index }) => (
<a
key={`${artifact.downloadUrl}:${index}`}
className="inline-flex h-9 items-center gap-2 rounded-md border border-black/10 px-3 text-xs font-medium text-[#535d6d] hover:bg-black/5 dark:border-white/15 dark:text-zinc-300 dark:hover:bg-white/8"
href={proxyUrl(index, true)}
>
<Download className="h-4 w-4" />
{artifact.displayName || artifact.filename}
</a>
))}
</div>
) : null}
<OpenClawProductionDrawingPanel
artifacts={withIndex}
chatId={chatId}
messageId={messageId}
/>
{advanced.length > 0 ? (
<details className="mt-3 border-t border-black/8 pt-2 dark:border-white/10">
<summary className="cursor-pointer text-xs font-medium text-[#687386] dark:text-zinc-400">
高级工程文件({advanced.length})
</summary>
<div className="mt-2 flex flex-wrap gap-2">
{advanced.map(({ artifact, index }) => (
<a
key={`${artifact.downloadUrl}:${index}`}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/10 px-2.5 text-[11px] text-[#687386] hover:bg-black/5 dark:border-white/15 dark:text-zinc-400 dark:hover:bg-white/8"
href={proxyUrl(index, true)}
>
<File className="h-3.5 w-3.5" />
{artifact.displayName || artifact.filename}
</a>
))}
</div>
</details>
) : null}
</section>
);
}
export function OpenClawModel3DPreview({
artifacts,
chatId,
messageId,
}: {
artifacts: OpenClawArtifact[];
chatId: string;
messageId: string;
}) {
const viewerId = useId();
const bundle = useMemo(
() => getOpenClaw3DArtifactBundle(artifacts),
[artifacts]
);
const primaryModel = bundle?.model || null;
const packagingModel =
bundle && bundle.packagingModelIndex >= 0
? artifacts[bundle.packagingModelIndex] || null
: null;
const primaryIsPackaging =
primaryModel && getOpenClaw3DArtifactVariant(primaryModel) === "packaging";
const productModel = primaryIsPackaging ? null : primaryModel;
const [viewMode, setViewMode] = useState<OpenClaw3DViewMode>(
productModel ? "product" : "packaging"
);
const model =
viewMode === "packaging" && packagingModel
? packagingModel
: productModel || packagingModel;
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<OpenClawModelInteractionManifest | null>();
const [manifestError, setManifestError] = useState<string | null>(null);
const interactionManifestIndex = bundle?.manifestIndex ?? -1;
const [failedPreviewUrl, setFailedPreviewUrl] = useState<string | null>(null);
const [previewRetry, setPreviewRetry] = useState(0);
const [selectedPart, setSelectedPart] =
useState<OpenClawModelInteractionPart | null>(null);
const [selectedPackagingPart, setSelectedPackagingPart] =
useState<OpenClawModelInteractionPart | null>(null);
const [matchState, setMatchState] = useState({ matched: 0, total: 0 });
const [explodeAmount, setExplodeAmount] = useState(0);
const [packagingExplodeAmount, setPackagingExplodeAmount] = useState(1);
const [transformRequest, setTransformRequest] = useState({
actionId: null as string | null,
amount: productModel ? 0 : 1,
animate: false,
durationMs: 700,
nonce: 0,
});
const hasRestoredSessionRef = useRef(false);
const restoredSelectedNodeNameRef = useRef<string | null>(null);
const restoredSelectedPackagingNodeNameRef = useRef<string | null>(null);
const cameraStatesRef = useRef<
Partial<Record<OpenClaw3DViewMode, OpenClawModelCameraState>>
>({});
const closeViewer = useCallback(() => {
setIsActive(false);
setSelectedPart(null);
setSelectedPackagingPart(null);
setMatchState({ matched: 0, total: 0 });
if (typeof window !== "undefined") {
const storage = getViewerSessionStorage();
if (!storage) return;
clearOpenClaw3DViewerSession({
storage,
chatId,
messageId,
});
}
}, [chatId, messageId]);
const handleMatchChange = useCallback((matched: number, total: number) => {
setMatchState((current) =>
current.matched === matched && current.total === total
? current
: { matched, total }
);
}, []);
useEffect(() => {
if (hasRestoredSessionRef.current || !primaryModel) return;
hasRestoredSessionRef.current = true;
const storage = getViewerSessionStorage();
if (!storage) return;
const session = readOpenClaw3DViewerSession({
storage,
chatId,
});
if (session?.messageId !== messageId) return;
if (!supportsWebGl()) {
setWebGlUnavailable(true);
clearOpenClaw3DViewerSession({
storage,
chatId,
messageId,
});
return;
}
const restoredViewMode =
session.viewMode === "packaging" && packagingModel
? "packaging"
: productModel
? "product"
: "packaging";
restoredSelectedNodeNameRef.current = session.selectedNodeName;
restoredSelectedPackagingNodeNameRef.current =
session.selectedPackagingNodeName;
setViewMode(restoredViewMode);
setExplodeAmount(session.explodeAmount);
setPackagingExplodeAmount(session.packagingExplodeAmount);
setTransformRequest((current) => ({
actionId: null,
amount:
restoredViewMode === "packaging"
? session.packagingExplodeAmount
: session.explodeAmount,
animate: false,
durationMs:
(restoredViewMode === "packaging"
? packagingModel?.viewer?.transitionDurationMs
: productModel?.viewer?.transitionDurationMs) || 700,
nonce: current.nonce + 1,
}));
window.dispatchEvent(
new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId })
);
setIsActive(true);
}, [chatId, messageId, packagingModel, primaryModel, productModel, viewerId]);
useEffect(() => {
if (!isActive) return;
const storage = getViewerSessionStorage();
if (!storage) return;
writeOpenClaw3DViewerSession({
storage,
chatId,
messageId,
viewMode,
explodeAmount,
packagingExplodeAmount,
selectedNodeName: selectedPart?.nodeName || null,
selectedPackagingNodeName: selectedPackagingPart?.nodeName || null,
});
}, [
chatId,
explodeAmount,
isActive,
messageId,
packagingExplodeAmount,
selectedPackagingPart?.nodeName,
selectedPart?.nodeName,
viewMode,
]);
useEffect(() => {
const nodeName = restoredSelectedNodeNameRef.current;
if (!manifest || !nodeName) return;
restoredSelectedNodeNameRef.current = null;
setSelectedPart(
manifest.parts.find((part) => part.nodeName === nodeName) || null
);
}, [manifest]);
useEffect(() => {
const nodeName = restoredSelectedPackagingNodeNameRef.current;
if (!manifest || !nodeName) return;
restoredSelectedPackagingNodeNameRef.current = null;
setSelectedPackagingPart(
getOpenClawPackagingInteractionParts(manifest).find(
(part) => part.nodeName === nodeName
) || null
);
}, [manifest]);
useEffect(() => {
const handleOtherViewerActivated = (event: Event) => {
const activeId = (event as CustomEvent<string>).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 || manifest !== undefined) return;
if (interactionManifestIndex < 0) {
setManifest(null);
return;
}
const controller = new AbortController();
const manifestUrl = buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: interactionManifestIndex,
});
const timeout = setTimeout(() => {
controller.abort();
setManifest(null);
setManifestError("交互清单加载超时");
}, 15000);
setManifestError(null);
fetch(manifestUrl, { 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 : "交互清单读取失败"
);
})
.finally(() => clearTimeout(timeout));
return () => {
clearTimeout(timeout);
controller.abort();
};
}, [interactionManifestIndex, chatId, isActive, manifest, messageId]);
useEffect(() => {
if (viewMode === "product" && !productModel && packagingModel) {
setViewMode("packaging");
} else if (viewMode === "packaging" && !packagingModel && productModel) {
setViewMode("product");
}
}, [packagingModel, productModel, viewMode]);
const packagingInteractionParts = useMemo(
() => (manifest ? getOpenClawPackagingInteractionParts(manifest) : []),
[manifest]
);
if (!bundle || !model) {
return (
<OpenClawSupportingArtifacts
artifacts={artifacts}
chatId={chatId}
messageId={messageId}
/>
);
}
const isPackagingView = viewMode === "packaging" && Boolean(packagingModel);
const activeModelIndex = isPackagingView
? bundle.packagingModelIndex
: bundle.modelIndex;
const activePreviewIndex = isPackagingView
? bundle.packagingPreviewIndex
: bundle.previewIndex;
const interactionParts = isPackagingView
? packagingInteractionParts
: manifest?.parts || [];
const selectedInteractionPart = isPackagingView
? selectedPackagingPart
: selectedPart;
const activeExplodeAmount = isPackagingView
? packagingExplodeAmount
: explodeAmount;
const modelUrl = buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: activeModelIndex,
});
const downloadUrl = buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: activeModelIndex,
download: true,
});
const previewUrl =
activePreviewIndex >= 0
? buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: activePreviewIndex,
})
: null;
const isAssembledModel =
model.artifactRole.toLowerCase() === "assembled_3d" ||
model.viewer?.variant === "assembled";
const artifactVariant = getOpenClaw3DArtifactVariant(model);
const sourceVariant = artifactVariant === "packaging" ? "exploded" : artifactVariant;
const supportsNodeInteraction = Boolean(
manifest &&
interactionParts.length > 0 &&
(isPackagingView || isAssembledModel || sourceVariant === "exploded")
);
const displayedModelName =
isPackagingView
? model.displayName || "3D包装预览"
: supportsNodeInteraction && sourceVariant === "exploded"
? "可旋转3D整装成品模型"
: model.displayName || model.filename || "3D 模型";
const canDragParts = Boolean(
!isPackagingView &&
supportsNodeInteraction &&
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable))
);
const canExplode = Boolean(
supportsNodeInteraction &&
(isPackagingView
? manifest?.capabilities.oneClickPackagingExplode
: model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
);
const canAssemble = Boolean(
supportsNodeInteraction &&
(isPackagingView
? manifest?.capabilities.oneClickPackagingPack
: model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
);
const showExplodeSlider = Boolean(
!isPackagingView &&
supportsNodeInteraction &&
(model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider)
);
const canResetParts = Boolean(
!isPackagingView &&
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));
if (isPackagingView) {
setPackagingExplodeAmount(normalized);
} else {
setExplodeAmount(normalized);
}
const action = manifest?.actions.find((item) => item.id === actionId);
setTransformRequest((current) => ({
actionId,
amount: normalized,
animate,
durationMs:
action?.durationMs || model.viewer?.transitionDurationMs || 700,
nonce: current.nonce + 1,
}));
};
const switchViewMode = (nextMode: OpenClaw3DViewMode) => {
if (nextMode === viewMode) return;
const nextAmount =
nextMode === "packaging" ? packagingExplodeAmount : explodeAmount;
setViewMode(nextMode);
setMatchState({ matched: 0, total: 0 });
setTransformRequest((current) => ({
actionId: null,
amount: nextAmount,
animate: false,
durationMs: 700,
nonce: current.nonce + 1,
}));
};
const selectedDimensions = selectedInteractionPart
? formatDimensions(selectedInteractionPart)
: null;
const packagingInfo = isPackagingView ? manifest?.packaging : null;
const packagingDimensions = packagingInfo?.packageDimensions.length
? packagingInfo.packageDimensions
.map(
(item) =>
`第${item.packageIndex}箱 ${item.dimensionsMm.x} x ${item.dimensionsMm.y} x ${item.dimensionsMm.z} mm`
)
.join(";")
: packagingInfo?.packageDimensionsMm
? `${packagingInfo.packageDimensionsMm.x} x ${packagingInfo.packageDimensionsMm.y} x ${packagingInfo.packageDimensionsMm.z} mm`
: null;
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 min-h-11 items-center justify-between gap-2 border-b border-black/8 px-3 py-1.5 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">
{displayedModelName}
</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={() => {
delete cameraStatesRef.current[viewMode];
setResetViewToken((value) => value + 1);
}}
>
<RotateCcw className="h-4 w-4" />
</button>
<button
type="button"
className="inline-flex h-8 items-center justify-center gap-1 rounded-md px-2 text-[12px] font-medium text-[#535d6d] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white"
aria-label="退出3D预览"
title="退出3D预览,恢复页面滚动"
onClick={closeViewer}
>
<X className="h-4 w-4" />
<span className="hidden sm:inline">退出预览</span>
</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>
{productModel && packagingModel ? (
<div className="flex items-center gap-1 border-b border-black/8 bg-black/[0.015] px-3 py-2 dark:border-white/10 dark:bg-white/[0.025]">
<button
type="button"
aria-pressed={viewMode === "product"}
className={
viewMode === "product"
? "inline-flex h-8 items-center gap-1.5 rounded-md bg-[#202631] px-3 text-xs font-medium text-white dark:bg-[#3ecf8e] dark:text-[#07120d]"
: "inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-[#687386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-white"
}
onClick={() => switchViewMode("product")}
>
<Box className="h-3.5 w-3.5" />
产品
</button>
<button
type="button"
aria-pressed={viewMode === "packaging"}
className={
viewMode === "packaging"
? "inline-flex h-8 items-center gap-1.5 rounded-md bg-[#202631] px-3 text-xs font-medium text-white dark:bg-[#3ecf8e] dark:text-[#07120d]"
: "inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium text-[#687386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/8 dark:hover:text-white"
}
onClick={() => switchViewMode("packaging")}
>
<Package className="h-3.5 w-3.5" />
包装预览
</button>
</div>
) : null}
<div
className="relative h-[clamp(300px,44vw,460px)] w-full overflow-hidden"
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
>
{isActive ? (
manifest === undefined && bundle.manifestIndex >= 0 ? (
<ViewerLoading />
) : supportsNodeInteraction && manifest ? (
<>
<OpenClawInteractiveModelViewer
allowDragging={canDragParts}
background={model.viewer?.background || "#f7f9fc"}
fitExplodedSource={isPackagingView}
initialCameraState={cameraStatesRef.current[viewMode] || null}
interactionParts={interactionParts}
manifest={manifest}
modelUrl={modelUrl}
onCameraStateChange={(state) => {
cameraStatesRef.current[viewMode] = state;
}}
onMatchChange={handleMatchChange}
onSelectedPartChange={(part) => {
if (isPackagingView) {
setSelectedPackagingPart(part);
} else {
setSelectedPart(part);
}
}}
previewImageUrl={previewUrl}
resetAllToken={resetAllToken}
resetSelectedToken={resetSelectedToken}
resetViewToken={resetViewToken}
selectedNodeName={selectedInteractionPart?.nodeName || null}
sourceVariant={sourceVariant}
transformRequest={transformRequest}
/>
{selectedInteractionPart ? (
<div className="pointer-events-none absolute left-3 top-3 max-w-[calc(100%-24px)] rounded-md bg-black/72 px-3 py-2 text-white shadow-sm backdrop-blur-sm">
<p className="truncate text-xs font-medium">
{selectedInteractionPart.partId} · {selectedInteractionPart.partName}
</p>
{selectedDimensions ? (
<p className="mt-0.5 text-[11px] text-white/65">
{selectedDimensions}
</p>
) : null}
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 border-t border-black/10 bg-white/94 px-3 py-2.5 backdrop-blur-md dark:border-white/10 dark:bg-zinc-950/94">
<div className="flex flex-wrap items-center gap-2">
{canExplode || canAssemble ? (
<div
aria-label={isPackagingView ? "3D包装状态" : "3D模型状态"}
className="inline-flex h-8 items-center rounded-md border border-black/10 bg-black/[0.025] p-0.5 dark:border-white/15 dark:bg-white/[0.06]"
role="group"
>
{canAssemble ? (
<button
type="button"
aria-pressed={activeExplodeAmount < 0.5}
className={
activeExplodeAmount < 0.5
? "inline-flex h-7 items-center gap-1.5 rounded-[4px] bg-white px-2.5 text-xs font-medium text-[#202631] shadow-sm dark:bg-[#3ecf8e] dark:text-[#07120d]"
: "inline-flex h-7 items-center gap-1.5 rounded-[4px] px-2.5 text-xs font-medium text-[#687386] hover:text-[#202631] dark:text-zinc-400 dark:hover:text-zinc-100"
}
onClick={() =>
updateExplodeAmount(
0,
true,
isPackagingView
? "packaging_pack"
: model.viewer?.assembleActionId || "assemble_all"
)
}
>
<Minimize2 className="h-3.5 w-3.5" />
{isPackagingView ? "合上包装" : "组合状态"}
</button>
) : null}
{canExplode ? (
<button
type="button"
aria-pressed={activeExplodeAmount >= 0.5}
className={
activeExplodeAmount >= 0.5
? "inline-flex h-7 items-center gap-1.5 rounded-[4px] bg-white px-2.5 text-xs font-medium text-[#202631] shadow-sm dark:bg-[#3ecf8e] dark:text-[#07120d]"
: "inline-flex h-7 items-center gap-1.5 rounded-[4px] px-2.5 text-xs font-medium text-[#687386] hover:text-[#202631] dark:text-zinc-400 dark:hover:text-zinc-100"
}
onClick={() =>
updateExplodeAmount(
1,
true,
isPackagingView
? "packaging_explode"
: model.viewer?.explodeActionId || "explode_all"
)
}
>
<Maximize2 className="h-3.5 w-3.5" />
{isPackagingView ? "展开包装" : "爆炸状态"}
</button>
) : null}
</div>
) : null}
{selectedInteractionPart && canResetParts ? (
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
aria-label="归位当前配件"
title="归位当前配件"
onClick={() => setResetSelectedToken((value) => value + 1)}
>
<Undo2 className="h-4 w-4" />
</button>
) : null}
{canResetParts ? (
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
aria-label="全部配件归位"
title="全部配件归位"
onClick={() => setResetAllToken((value) => value + 1)}
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
{matchState.total > 0 && matchState.matched < matchState.total ? (
<span className="text-[11px] text-amber-700 dark:text-amber-300">
已识别 {matchState.matched}/{matchState.total} 个
{isPackagingView ? "包装层" : "配件"}
</span>
) : null}
</div>
{showExplodeSlider ? (
<label className="mt-2 flex items-center gap-3 text-[11px] text-[#697386] dark:text-zinc-400">
<span className="shrink-0">爆炸程度</span>
<input
type="range"
min="0"
max="100"
value={Math.round(activeExplodeAmount * 100)}
className="h-1.5 min-w-0 flex-1 accent-[#202631] dark:accent-[#3ecf8e]"
onChange={(event) =>
updateExplodeAmount(Number(event.target.value) / 100, false)
}
/>
<span className="w-8 text-right tabular-nums">
{Math.round(activeExplodeAmount * 100)}%
</span>
</label>
) : null}
</div>
</>
) : (
<Model3DViewer
autoFitModel
localStudioEnvironment
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 && failedPreviewUrl !== previewUrl ? (
<img
alt={`${model.displayName || "3D 模型"}静态预览`}
className="absolute inset-0 h-full w-full object-contain"
loading="lazy"
src={previewRetry ? `${previewUrl}&previewRetry=${previewRetry}` : previewUrl}
onError={() => setFailedPreviewUrl(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-[#3ecf8e] dark:text-[#07120d] dark:hover:bg-[#55dda0]"
onClick={activateViewer}
>
<Box className="h-4 w-4" />
查看 3D
</button>
{previewUrl && failedPreviewUrl === previewUrl ? (
<div className="absolute inset-x-0 bottom-3 text-center text-xs text-[#687386]">
<p>静态预览加载失败,可以直接查看 3D 或重试预览。</p>
<button
type="button"
className="mt-1 underline underline-offset-2"
onClick={() => {
setPreviewRetry((value) => value + 1);
setFailedPreviewUrl(null);
}}
>
重试预览
</button>
</div>
) : null}
</div>
)}
</div>
{packagingInfo ? (
<div className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-black/8 bg-black/[0.015] px-3 py-3 text-xs dark:border-white/10 dark:bg-white/[0.025] sm:grid-cols-4">
<div className="min-w-0">
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">箱型</p>
<p className="mt-0.5 truncate font-medium text-[#303744] dark:text-zinc-200">
{packagingInfo.boxStyleLabel || "未标注"}
</p>
</div>
<div className="min-w-0">
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">包装箱数</p>
<p className="mt-0.5 font-medium text-[#303744] dark:text-zinc-200">
{packagingInfo.packageCount ?? "-"}
</p>
</div>
<div className="min-w-0">
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">总包装体积</p>
<p className="mt-0.5 font-medium text-[#303744] dark:text-zinc-200">
{packagingInfo.totalPackagingVolumeM3 != null
? `${packagingInfo.totalPackagingVolumeM3.toFixed(3)} m³`
: "-"}
</p>
</div>
<div className="min-w-0">
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">体积重</p>
<p className="mt-0.5 font-medium text-[#303744] dark:text-zinc-200">
{packagingInfo.volumeWeightKg != null
? `${packagingInfo.volumeWeightKg.toFixed(1)} kg`
: "-"}
</p>
</div>
{packagingDimensions ? (
<div className="col-span-2 min-w-0 sm:col-span-4">
<p className="text-[11px] text-[#929baa] dark:text-zinc-500">每箱尺寸</p>
<p className="mt-0.5 break-words font-medium text-[#303744] dark:text-zinc-200">
{packagingDimensions}
</p>
</div>
) : null}
</div>
) : null}
{webGlUnavailable || manifestError ? (
<p className="border-t border-black/8 px-3 py-2 text-xs text-amber-700 dark:border-white/10 dark:text-amber-300">
{webGlUnavailable
? "当前浏览器无法启动3D预览,已保留静态预览和模型下载。"
: `${manifestError},已切换为基础3D预览。`}
</p>
) : null}
</section>
<OpenClawSupportingArtifacts
artifacts={artifacts}
chatId={chatId}
messageId={messageId}
/>
</>
);
}