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

472 lines
18 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,
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: () => <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";
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" />
</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 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<OpenClawModelInteractionManifest | null>();
const [manifestError, setManifestError] = useState<string | null>(null);
const [selectedPart, setSelectedPart] =
useState<OpenClawModelInteractionPart | null>(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<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 || !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 (
<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">
{model.displayName || model.filename || "3D 模型"}
</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={() => 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>
<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"}
manifest={manifest}
modelUrl={modelUrl}
onMatchChange={handleMatchChange}
onSelectedPartChange={setSelectedPart}
previewImageUrl={previewUrl}
resetAllToken={resetAllToken}
resetSelectedToken={resetSelectedToken}
resetViewToken={resetViewToken}
selectedNodeName={selectedPart?.nodeName || null}
transformRequest={transformRequest}
/>
{selectedPart ? (
<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">
{selectedPart.partId} · {selectedPart.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 ? (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 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]"
onClick={() =>
updateExplodeAmount(
1,
true,
model.viewer?.explodeActionId || "explode_all"
)
}
>
<Maximize2 className="h-3.5 w-3.5" />
一键炸开
</button>
) : null}
{canAssemble ? (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/10 px-3 text-xs font-medium text-[#3e4652] hover:bg-black/5 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10"
onClick={() =>
updateExplodeAmount(
0,
true,
model.viewer?.assembleActionId || "assemble_all"
)
}
>
<Minimize2 className="h-3.5 w-3.5" />
一键还原
</button>
) : null}
{selectedPart && 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} 个配件
</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(explodeAmount * 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(explodeAmount * 100)}%
</span>
</label>
) : null}
</div>
</>
) : (
<Model3DViewer
autoFitModel
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 ? (
<img
alt={`${model.displayName || "3D 模型"}静态预览`}
className="absolute inset-0 h-full w-full object-contain"
loading="lazy"
src={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>
</div>
)}
</div>
{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>
);
}