"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
{overview.artifact.displayName || "配件图总览"}
) : null} {thumbnails.length > 0 ? (
配件图对照
{thumbnails.map(({ artifact, index }) => { const dimensions = formatArtifactDimensions(artifact); return (
{artifact.displayName

{[artifact.partId, artifact.partName] .filter(Boolean) .join(" · ") || artifact.displayName}

{dimensions ? (

{dimensions}

) : null}
); })}
) : null} {workbooks.length > 0 || downloads.length > 0 ? (
{workbooks.map(({ artifact, index }) => ( {artifact.displayName || "下载带配件图BOM与成本报价"} ))} {downloads.map(({ artifact, index }) => ( {artifact.displayName || artifact.filename} ))}
) : null} {advanced.length > 0 ? (
高级工程文件({advanced.length})
{advanced.map(({ artifact, index }) => ( {artifact.displayName || artifact.filename} ))}
) : 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 ( <>
{displayedModelName} {model.viewer?.format || "glb"}
{isActive ? ( <> ) : null}
{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 ? ( {`${model.displayName 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}
); }