472 lines
18 KiB
TypeScript
472 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|