"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: () => ,
}
);
const OpenClawInteractiveModelViewer = dynamic(
() =>
import(
"@/components/canvas/chat-panel/OpenClawInteractiveModelViewer"
).then((module) => module.OpenClawInteractiveModelViewer),
{
ssr: false,
loading: () => ,
}
);
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
type OpenClaw3DViewMode = "product" | "packaging";
function ViewerLoading() {
return (
正在准备 3D 预览…
);
}
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();
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 (
{isReleased ? (
) : (
)}
{statusLabel}
{manifestError ? (
发布清单读取失败,已按未发布状态保护。
) : null}
{!manifestArtifact ? (
缺少生产图发布清单,不能确认签批状态。
) : null}
{manifest?.releaseBlockers.length ? (
{manifest.releaseBlockers.map((blocker, index) => (
- · {blocker}
))}
) : null}
{isReleased ? (
{manifest?.version ? 版本:{manifest.version} : null}
{manifest?.approvedBy ? (
签批人:{manifest.approvedBy}
) : null}
{manifest?.fileSha256 ? (
SHA-256:{manifest.fileSha256}
) : null}
) : null}
{pdfs.length > 0 || dxfBundles.length > 0 ? (
{pdfs.map(({ artifact, index }) => (
预览/下载生产工艺图
))}
{dxfBundles.map(({ artifact, index }) => (
下载DXF图包
))}
) : null}
);
}
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 (
{overview ? (
{overview.artifact.displayName || "配件图总览"}
) : null}
{thumbnails.length > 0 ? (
配件图对照
{thumbnails.map(({ artifact, index }) => {
const dimensions = formatArtifactDimensions(artifact);
return (
{[artifact.partId, artifact.partName]
.filter(Boolean)
.join(" · ") || artifact.displayName}
{dimensions ? (
{dimensions}
) : null}
);
})}
) : null}
{workbooks.length > 0 || downloads.length > 0 ? (
) : null}
{advanced.length > 0 ? (
高级工程文件({advanced.length})
) : null}
);
}
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(
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();
const [manifestError, setManifestError] = useState(null);
const interactionManifestIndex = bundle?.manifestIndex ?? -1;
const [failedPreviewUrl, setFailedPreviewUrl] = useState(null);
const [previewRetry, setPreviewRetry] = useState(0);
const [selectedPart, setSelectedPart] =
useState(null);
const [selectedPackagingPart, setSelectedPackagingPart] =
useState(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(null);
const restoredSelectedPackagingNodeNameRef = useRef(null);
const cameraStatesRef = useRef<
Partial>
>({});
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).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 (
);
}
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 (
<>
{productModel && packagingModel ? (
) : null}
{isActive ? (
manifest === undefined && bundle.manifestIndex >= 0 ? (
) : supportsNodeInteraction && manifest ? (
<>
{
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 ? (
{selectedInteractionPart.partId} · {selectedInteractionPart.partName}
{selectedDimensions ? (
{selectedDimensions}
) : null}
) : null}
{canExplode || canAssemble ? (
{canAssemble ? (
) : null}
{canExplode ? (
) : null}
) : null}
{selectedInteractionPart && canResetParts ? (
) : null}
{canResetParts ? (
) : null}
{matchState.total > 0 && matchState.matched < matchState.total ? (
已识别 {matchState.matched}/{matchState.total} 个
{isPackagingView ? "包装层" : "配件"}
) : null}
{showExplodeSlider ? (
) : null}
>
) : (
)
) : (
{previewUrl && failedPreviewUrl !== previewUrl ? (

setFailedPreviewUrl(previewUrl)}
/>
) : (
)}
{previewUrl && failedPreviewUrl === previewUrl ? (
静态预览加载失败,可以直接查看 3D 或重试预览。
) : null}
)}
{packagingInfo ? (
箱型
{packagingInfo.boxStyleLabel || "未标注"}
包装箱数
{packagingInfo.packageCount ?? "-"}
总包装体积
{packagingInfo.totalPackagingVolumeM3 != null
? `${packagingInfo.totalPackagingVolumeM3.toFixed(3)} m³`
: "-"}
体积重
{packagingInfo.volumeWeightKg != null
? `${packagingInfo.volumeWeightKg.toFixed(1)} kg`
: "-"}
{packagingDimensions ? (
每箱尺寸
{packagingDimensions}
) : null}
) : null}
{webGlUnavailable || manifestError ? (
{webGlUnavailable
? "当前浏览器无法启动3D预览,已保留静态预览和模型下载。"
: `${manifestError},已切换为基础3D预览。`}
) : null}
>
);
}