feat: support updated OpenClaw 3D task protocol

This commit is contained in:
134 2026-09-06 12:42:35 +08:00 committed by root
parent 272592488b
commit 740fc90dfd
13 changed files with 1453 additions and 115 deletions

View File

@ -299,7 +299,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
params.spreadsheetMarkdownTruncated || undefined,
...params.metadata,
},
stream_progress: false,
stream_progress: !params.sensitiveInput,
sensitive_input: params.sensitiveInput,
};

View File

@ -683,15 +683,28 @@ function normalizeClawdComposerText(value: string) {
.trim();
}
function buildClawdSkillTagPrompt(tag: Pick<CanvasSkillTag, "name" | "prompt"> | null) {
function buildClawdSkillTagPrompt(
tag: Pick<CanvasSkillTag, "id" | "name" | "prompt"> | null
) {
if (!tag) return "";
const tagName = String(tag.name || "").trim() || "快捷标签";
const tagPrompt = String(tag.prompt || "").trim();
const requiresCostRoute =
tag.id === "ai-solid-wood-cost" ||
tagName === "产品成本评估" ||
tagName === "ai实木家具成本预估";
const routePrompt =
requiresCostRoute && !tagPrompt.includes("【技能路由:ai-solid-wood-cost】")
? "【技能路由:ai-solid-wood-cost】"
: "";
return [
`【快捷标签:${tagName}】`,
routePrompt,
tagPrompt || `用户选择了「${tagName}」快捷标签,请按该标签对应能力处理。`,
].join("\n");
]
.filter(Boolean)
.join("\n");
}
const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6;
@ -734,6 +747,8 @@ type ClawdProgressEvent = {
completed?: number;
total?: number;
progressPercent?: number;
heartbeat: boolean;
activeAt?: number;
};
type ClawdDurableTaskStatus =
@ -870,9 +885,8 @@ async function waitForClawdDurableTask(params: {
function normalizeClawdProgressEvent(
progress: Record<string, unknown>
): ClawdProgressEvent | null {
if (progress.heartbeat === true) return null;
const message = String(
progress.message || progress.display || ""
progress.display_text || progress.message || progress.display || ""
).trim();
if (!message) return null;
const eventId = Number(progress.event_id);
@ -883,6 +897,9 @@ function normalizeClawdProgressEvent(
const completed = Number(progress.completed);
const total = Number(progress.total);
const progressPercent = Number(progress.progress_percent);
const activeAt = Date.parse(
String(progress.created_at || progress.updated_at || "")
);
const normalizedEventId = Number.isFinite(eventId) && eventId > 0
? eventId
: undefined;
@ -914,9 +931,31 @@ function normalizeClawdProgressEvent(
progressPercent <= 100
? Math.round(progressPercent)
: undefined,
heartbeat: progress.heartbeat === true,
activeAt: Number.isFinite(activeAt) ? activeAt : undefined,
};
}
function getClawdProgressMeta(progress: ClawdProgressEvent) {
const items: string[] = [];
if (progress.currentOrderId) items.push(`订单 ${progress.currentOrderId}`);
if (progress.total) {
items.push(
`${progress.completed || 0}/${progress.total}${
progress.progressPercent != null ? ` · ${progress.progressPercent}%` : ""
}`
);
}
if (progress.heartbeat && progress.activeAt) {
items.push(
`最近活跃 ${new Date(progress.activeAt).toLocaleTimeString("zh-CN", {
hour12: false,
})}`
);
}
return items.join(" · ");
}
function isClawdSubmittedComposerText(
snapshot: ClawdComposerSubmissionSnapshot,
value: string
@ -2003,6 +2042,10 @@ function normalizeOpenClawActionText(value: unknown, maxLength: number) {
.slice(0, maxLength);
}
function normalizeOpenClawActionMessage(value: unknown, maxLength = 2000) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
@ -2025,7 +2068,7 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n
}
const action = item as Record<string, unknown>;
const label = normalizeOpenClawActionText(action.label, 24);
const message = normalizeOpenClawActionText(action.message, 300);
const message = normalizeOpenClawActionMessage(action.message);
if (!label || !message) return null;
const intent = normalizeOpenClawActionText(action.intent, 80);
@ -2039,7 +2082,6 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n
};
})
.filter((item): item is OpenClawQuickAction => Boolean(item))
.slice(0, 10)
: [];
if (actions.length === 0) return null;
@ -2586,9 +2628,17 @@ function CanvasAssistantTypingPlaceholder({
const isFailed = progressEvent.stepState === "failed";
const isActive =
isLatest &&
["accepted", "queued", "started", "waiting"].includes(
[
"accepted",
"queued",
"started",
"waiting",
"processing",
"running",
].includes(
progressEvent.stepState
);
const progressMeta = getClawdProgressMeta(progressEvent);
return (
<div
key={progressEvent.key}
@ -2603,21 +2653,9 @@ function CanvasAssistantTypingPlaceholder({
)}
<div className="min-w-0">
<div className="break-words">{progressEvent.message}</div>
{progressEvent.currentOrderId || progressEvent.total ? (
{progressMeta ? (
<div className="text-[11px] text-[#929baa] dark:text-zinc-500">
{progressEvent.currentOrderId
? `订单 ${progressEvent.currentOrderId}`
: ""}
{progressEvent.currentOrderId && progressEvent.total
? " · "
: ""}
{progressEvent.total
? `${progressEvent.completed || 0}/${progressEvent.total}${
progressEvent.progressPercent != null
? ` · ${progressEvent.progressPercent}%`
: ""
}`
: ""}
{progressMeta}
</div>
) : null}
</div>
@ -13139,7 +13177,7 @@ export function CanvasChatPanel({
const handleOpenClawQuickAction = useCallback(
(message: string) => {
const quickMessage = normalizeOpenClawActionText(message, 300);
const quickMessage = normalizeOpenClawActionMessage(message);
if (!quickMessage) {
toast.error("快捷标签内容为空");
return;
@ -15024,6 +15062,18 @@ export function CanvasChatPanel({
return acc;
}, []);
}, [visibleMessages]);
const latestClawAssistantMessageId = useMemo(() => {
for (let index = renderableVisibleMessages.length - 1; index >= 0; index -= 1) {
const message = renderableVisibleMessages[index];
if (
message?.role === "assistant" &&
(isClawdMode || isClawdChatMessage(message))
) {
return message.id;
}
}
return null;
}, [isClawdMode, renderableVisibleMessages]);
const isDetailEmptyState =
isDetailGenerationMode &&
@ -16167,6 +16217,8 @@ export function CanvasChatPanel({
const isClawAssistantMessage =
message.role === "assistant" &&
(isClawdMode || isClawdChatMessage(message));
const isLatestClawAssistantMessage =
message.id === latestClawAssistantMessageId;
const openClawArtifacts = isClawAssistantMessage
? getOpenClawMessageArtifacts(message)
: [];
@ -16433,8 +16485,14 @@ export function CanvasChatPanel({
openClawActionsResult.markdown
)
: openClawActionsResult.markdown;
const openClawActionBlocks = openClawActionsResult.blocks;
const openClawSecureForms = openClawActionsResult.forms;
const openClawActionBlocks =
isLatestClawAssistantMessage
? openClawActionsResult.blocks
: [];
const openClawSecureForms =
isLatestClawAssistantMessage
? openClawActionsResult.forms
: [];
const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions);
const plainText = normalizeCanvasAgentText(textWithoutActions);
const analysisToggleKey = `${message.id}-${i}-analysis`;

View File

@ -26,6 +26,11 @@ export type OpenClawModelTransformRequest = {
nonce: number;
};
export type OpenClawModelCameraState = {
position: [number, number, number];
target: [number, number, number];
};
type InteractiveEntry = {
node: Object3D;
part: OpenClawModelInteractionPart;
@ -144,9 +149,13 @@ function getPartTargetPosition(
function ModelInteractionScene({
allowDragging,
fitExplodedSource,
initialCameraState,
interactionParts,
manifest,
modelUrl,
onMatchChange,
onCameraStateChange,
onSelectedPartChange,
resetAllToken,
resetSelectedToken,
@ -155,9 +164,13 @@ function ModelInteractionScene({
transformRequest,
}: {
allowDragging: boolean;
fitExplodedSource: boolean;
initialCameraState?: OpenClawModelCameraState | null;
interactionParts: OpenClawModelInteractionPart[];
manifest: OpenClawModelInteractionManifest;
modelUrl: string;
onMatchChange: (matched: number, total: number) => void;
onCameraStateChange?: (state: OpenClawModelCameraState) => void;
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
resetAllToken: number;
resetSelectedToken: number;
@ -172,7 +185,7 @@ function ModelInteractionScene({
const cloned = cloneScene(gltf.scene);
if (sourceVariant !== "exploded") return cloned;
const partsByNodeName = new Map(
manifest.parts.map((part) => [part.nodeName, part])
interactionParts.map((part) => [part.nodeName, part])
);
cloned.traverse((node) => {
const part = partsByNodeName.get(node.name);
@ -186,8 +199,11 @@ function ModelInteractionScene({
});
cloned.updateMatrixWorld(true);
return cloned;
}, [gltf.scene, manifest, sourceVariant]);
const fitTransform = useMemo(() => getFitTransform(scene), [scene]);
}, [gltf.scene, interactionParts, manifest.scaleMmToGlb, sourceVariant]);
const fitTransform = useMemo(
() => getFitTransform(fitExplodedSource ? gltf.scene : scene),
[fitExplodedSource, gltf.scene, scene]
);
const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map());
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map());
const animationRef = useRef<AnimationState | null>(null);
@ -197,7 +213,7 @@ function ModelInteractionScene({
useEffect(() => {
const partsByNodeName = new Map(
manifest.parts.map((part) => [part.nodeName, part])
interactionParts.map((part) => [part.nodeName, part])
);
const entries = new Map<string, InteractiveEntry>();
const objectEntries = new Map<Object3D, InteractiveEntry>();
@ -214,14 +230,22 @@ function ModelInteractionScene({
});
entriesRef.current = entries;
objectEntriesRef.current = objectEntries;
onMatchChange(entries.size, manifest.parts.length);
onMatchChange(entries.size, interactionParts.length);
return () => {
entriesRef.current.clear();
objectEntriesRef.current.clear();
onMatchChange(0, manifest.parts.length);
onMatchChange(0, interactionParts.length);
};
}, [manifest, onMatchChange, scene]);
}, [interactionParts, onMatchChange, scene]);
useEffect(() => {
if (!initialCameraState || !controlsRef.current) return;
camera.position.set(...initialCameraState.position);
controlsRef.current.target.set(...initialCameraState.target);
camera.updateProjectionMatrix();
controlsRef.current.update();
}, [camera, initialCameraState]);
useEffect(() => {
const previous = selectionHelperRef.current;
@ -469,6 +493,14 @@ function ModelInteractionScene({
maxPolarAngle={Math.PI / 1.65}
minDistance={1.2}
minPolarAngle={0}
onChange={() => {
const controls = controlsRef.current;
if (!controls || !onCameraStateChange) return;
onCameraStateChange({
position: camera.position.toArray() as [number, number, number],
target: controls.target.toArray() as [number, number, number],
});
}}
ref={controlsRef}
/>
</>
@ -478,9 +510,13 @@ function ModelInteractionScene({
export function OpenClawInteractiveModelViewer({
allowDragging,
background,
fitExplodedSource = false,
initialCameraState,
interactionParts,
manifest,
modelUrl,
onMatchChange,
onCameraStateChange,
onSelectedPartChange,
previewImageUrl,
resetAllToken,
@ -492,9 +528,13 @@ export function OpenClawInteractiveModelViewer({
}: {
allowDragging: boolean;
background: string;
fitExplodedSource?: boolean;
initialCameraState?: OpenClawModelCameraState | null;
interactionParts?: OpenClawModelInteractionPart[];
manifest: OpenClawModelInteractionManifest;
modelUrl: string;
onMatchChange: (matched: number, total: number) => void;
onCameraStateChange?: (state: OpenClawModelCameraState) => void;
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
previewImageUrl?: string | null;
resetAllToken: number;
@ -527,16 +567,20 @@ export function OpenClawInteractiveModelViewer({
camera={{ fov: 42, position: [2.4, 1.8, 2.4] }}
dpr={[1, 1.75]}
gl={{ antialias: true, powerPreference: "high-performance" }}
key={resetViewToken}
key={`${modelUrl}:${resetViewToken}`}
shadows
style={{ background }}
>
<Suspense fallback={null}>
<ModelInteractionScene
allowDragging={allowDragging}
fitExplodedSource={fitExplodedSource}
initialCameraState={initialCameraState}
interactionParts={interactionParts || manifest.parts}
manifest={manifest}
modelUrl={modelUrl}
onMatchChange={onMatchChange}
onCameraStateChange={onCameraStateChange}
onSelectedPartChange={onSelectedPartChange}
resetAllToken={resetAllToken}
resetSelectedToken={resetSelectedToken}

View File

@ -4,11 +4,17 @@
import dynamic from "next/dynamic";
import {
Box,
CheckCircle2,
Download,
File,
FileSpreadsheet,
ImageIcon,
Loader2,
Maximize2,
Minimize2,
Package,
RotateCcw,
ShieldAlert,
Undo2,
X,
} from "lucide-react";
@ -27,15 +33,22 @@ import {
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(
() =>
@ -60,6 +73,7 @@ const OpenClawInteractiveModelViewer = dynamic(
);
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
type OpenClaw3DViewMode = "product" | "packaging";
function ViewerLoading() {
return (
@ -96,6 +110,338 @@ function formatDimensions(part: OpenClawModelInteractionPart) {
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);
const proxyUrl = useCallback(
(index: number, download = false) =>
buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: index,
download,
}),
[chatId, messageId]
);
useEffect(() => {
if (!manifestArtifact) {
setManifest(null);
return;
}
const controller = new AbortController();
setManifestError(false);
fetch(proxyUrl(manifestArtifact.index), {
cache: "no-store",
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();
}, [manifestArtifact, 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,
@ -110,7 +456,21 @@ export function OpenClawModel3DPreview({
() => getOpenClaw3DArtifactBundle(artifacts),
[artifacts]
);
const model = bundle?.model || null;
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);
@ -121,21 +481,29 @@ export function OpenClawModel3DPreview({
const [manifestError, setManifestError] = useState<string | null>(null);
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: 0,
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();
@ -156,7 +524,7 @@ export function OpenClawModel3DPreview({
}, []);
useEffect(() => {
if (hasRestoredSessionRef.current || !model) return;
if (hasRestoredSessionRef.current || !primaryModel) return;
hasRestoredSessionRef.current = true;
const storage = getViewerSessionStorage();
if (!storage) return;
@ -175,20 +543,36 @@ export function OpenClawModel3DPreview({
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: session.explodeAmount,
amount:
restoredViewMode === "packaging"
? session.packagingExplodeAmount
: session.explodeAmount,
animate: false,
durationMs: model.viewer?.transitionDurationMs || 700,
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, model, viewerId]);
}, [chatId, messageId, packagingModel, primaryModel, productModel, viewerId]);
useEffect(() => {
if (!isActive) return;
@ -198,10 +582,22 @@ export function OpenClawModel3DPreview({
storage,
chatId,
messageId,
viewMode,
explodeAmount,
packagingExplodeAmount,
selectedNodeName: selectedPart?.nodeName || null,
selectedPackagingNodeName: selectedPackagingPart?.nodeName || null,
});
}, [chatId, explodeAmount, isActive, messageId, selectedPart?.nodeName]);
}, [
chatId,
explodeAmount,
isActive,
messageId,
packagingExplodeAmount,
selectedPackagingPart?.nodeName,
selectedPart?.nodeName,
viewMode,
]);
useEffect(() => {
const nodeName = restoredSelectedNodeNameRef.current;
@ -212,6 +608,17 @@ export function OpenClawModel3DPreview({
);
}, [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;
@ -268,56 +675,106 @@ export function OpenClawModel3DPreview({
return () => controller.abort();
}, [bundle, chatId, isActive, manifest, messageId]);
if (!bundle || !model) return null;
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: bundle.modelIndex,
artifactIndex: activeModelIndex,
});
const downloadUrl = buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: bundle.modelIndex,
artifactIndex: activeModelIndex,
download: true,
});
const previewUrl =
bundle.previewIndex >= 0
activePreviewIndex >= 0
? buildOpenClawArtifactProxyUrl({
chatId,
messageId,
artifactIndex: bundle.previewIndex,
artifactIndex: activePreviewIndex,
})
: null;
const isAssembledModel =
model.artifactRole.toLowerCase() === "assembled_3d" ||
model.viewer?.variant === "assembled";
const sourceVariant = getOpenClaw3DArtifactVariant(model);
const artifactVariant = getOpenClaw3DArtifactVariant(model);
const sourceVariant = artifactVariant === "packaging" ? "exploded" : artifactVariant;
const supportsNodeInteraction = Boolean(
manifest && (isAssembledModel || sourceVariant === "exploded")
manifest &&
interactionParts.length > 0 &&
(isPackagingView || isAssembledModel || sourceVariant === "exploded")
);
const displayedModelName =
supportsNodeInteraction && sourceVariant === "exploded"
isPackagingView
? model.displayName || "3D包装预览"
: supportsNodeInteraction && sourceVariant === "exploded"
? "可旋转3D整装成品模型"
: model.displayName || model.filename || "3D 模型";
const canDragParts = Boolean(
supportsNodeInteraction &&
!isPackagingView &&
supportsNodeInteraction &&
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable))
);
const canExplode = Boolean(
supportsNodeInteraction &&
(model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
(isPackagingView
? manifest?.capabilities.oneClickPackagingExplode
: model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
);
const canAssemble = Boolean(
supportsNodeInteraction &&
(model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
(isPackagingView
? manifest?.capabilities.oneClickPackagingPack
: model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
);
const showExplodeSlider = Boolean(
supportsNodeInteraction &&
!isPackagingView &&
supportsNodeInteraction &&
(model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider)
);
const canResetParts = Boolean(
supportsNodeInteraction &&
!isPackagingView &&
supportsNodeInteraction &&
(model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable))
);
@ -336,21 +793,53 @@ export function OpenClawModel3DPreview({
actionId: string | null = null
) => {
const normalized = Math.max(0, Math.min(1, amount));
setExplodeAmount(normalized);
if (isPackagingView) {
setPackagingExplodeAmount(normalized);
} else {
setExplodeAmount(normalized);
}
const action = manifest?.actions.find((item) => item.id === actionId);
setTransformRequest((current) => ({
actionId,
amount: normalized,
animate,
durationMs: model.viewer?.transitionDurationMs || 700,
durationMs:
action?.durationMs || model.viewer?.transitionDurationMs || 700,
nonce: current.nonce + 1,
}));
};
const selectedDimensions = selectedPart
? formatDimensions(selectedPart)
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">
<>
<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" />
@ -369,7 +858,10 @@ export function OpenClawModel3DPreview({
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)}
onClick={() => {
delete cameraStatesRef.current[viewMode];
setResetViewToken((value) => value + 1);
}}
>
<RotateCcw className="h-4 w-4" />
</button>
@ -396,6 +888,37 @@ export function OpenClawModel3DPreview({
</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" }}
@ -408,23 +931,35 @@ export function OpenClawModel3DPreview({
<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={setSelectedPart}
onSelectedPartChange={(part) => {
if (isPackagingView) {
setSelectedPackagingPart(part);
} else {
setSelectedPart(part);
}
}}
previewImageUrl={previewUrl}
resetAllToken={resetAllToken}
resetSelectedToken={resetSelectedToken}
resetViewToken={resetViewToken}
selectedNodeName={selectedPart?.nodeName || null}
selectedNodeName={selectedInteractionPart?.nodeName || null}
sourceVariant={sourceVariant}
transformRequest={transformRequest}
/>
{selectedPart ? (
{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">
{selectedPart.partId} · {selectedPart.partName}
{selectedInteractionPart.partId} · {selectedInteractionPart.partName}
</p>
{selectedDimensions ? (
<p className="mt-0.5 text-[11px] text-white/65">
@ -438,16 +973,16 @@ export function OpenClawModel3DPreview({
<div className="flex flex-wrap items-center gap-2">
{canExplode || canAssemble ? (
<div
aria-label="3D模型状态"
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={explodeAmount < 0.5}
aria-pressed={activeExplodeAmount < 0.5}
className={
explodeAmount < 0.5
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"
}
@ -455,20 +990,22 @@ export function OpenClawModel3DPreview({
updateExplodeAmount(
0,
true,
model.viewer?.assembleActionId || "assemble_all"
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={explodeAmount >= 0.5}
aria-pressed={activeExplodeAmount >= 0.5}
className={
explodeAmount >= 0.5
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"
}
@ -476,17 +1013,19 @@ export function OpenClawModel3DPreview({
updateExplodeAmount(
1,
true,
model.viewer?.explodeActionId || "explode_all"
isPackagingView
? "packaging_explode"
: model.viewer?.explodeActionId || "explode_all"
)
}
>
<Maximize2 className="h-3.5 w-3.5" />
爆炸状态
{isPackagingView ? "展开包装" : "爆炸状态"}
</button>
) : null}
</div>
) : null}
{selectedPart && canResetParts ? (
{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"
@ -510,7 +1049,8 @@ export function OpenClawModel3DPreview({
) : null}
{matchState.total > 0 && matchState.matched < matchState.total ? (
<span className="text-[11px] text-amber-700 dark:text-amber-300">
已识别 {matchState.matched}/{matchState.total} 个配件
已识别 {matchState.matched}/{matchState.total} 个
{isPackagingView ? "包装层" : "配件"}
</span>
) : null}
</div>
@ -521,14 +1061,14 @@ export function OpenClawModel3DPreview({
type="range"
min="0"
max="100"
value={Math.round(explodeAmount * 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(explodeAmount * 100)}%
{Math.round(activeExplodeAmount * 100)}%
</span>
</label>
) : null}
@ -574,6 +1114,46 @@ export function OpenClawModel3DPreview({
</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
@ -581,6 +1161,12 @@ export function OpenClawModel3DPreview({
: `${manifestError},已切换为基础3D预览。`}
</p>
) : null}
</section>
</section>
<OpenClawSupportingArtifacts
artifacts={artifacts}
chatId={chatId}
messageId={messageId}
/>
</>
);
}

View File

@ -1,7 +1,7 @@
export type OpenClawArtifactViewer = {
type: "model_3d";
format: "glb" | "gltf";
variant: "assembled" | "exploded" | null;
variant: "assembled" | "exploded" | "packaging" | null;
isDefault: boolean;
cameraControls: boolean;
autoRotate: boolean;
@ -124,7 +124,11 @@ function normalizeViewer(
value: raw.format,
}),
variant:
variant === "assembled" || variant === "exploded" ? variant : null,
variant === "assembled" ||
variant === "exploded" ||
variant === "packaging"
? variant
: null,
isDefault: readBoolean(raw.default ?? raw.isDefault, false),
cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true),
autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false),
@ -259,6 +263,7 @@ export function isOpenClawInteractive3DArtifact(
return (
role === "assembled_3d" ||
role === "interactive_3d" ||
role === "packaging_3d" ||
mimeType === "model/gltf-binary" ||
mimeType === "model/gltf+json" ||
filename.endsWith(".glb") ||
@ -270,16 +275,21 @@ export type OpenClaw3DArtifactBundle = {
modelIndex: number;
model: OpenClawArtifact;
alternateModelIndex: number;
packagingModelIndex: number;
manifestIndex: number;
previewIndex: number;
packagingPreviewIndex: number;
};
export function getOpenClaw3DArtifactVariant(
artifact: OpenClawArtifact
): "assembled" | "exploded" {
): "assembled" | "exploded" | "packaging" {
if (artifact.viewer?.variant) return artifact.viewer.variant;
const role = artifact.artifactRole.toLowerCase();
const filename = artifact.filename.toLowerCase();
if (role === "packaging_3d" || filename.includes("packaging")) {
return "packaging";
}
if (
role === "interactive_3d" ||
filename.includes("exploded") ||
@ -300,12 +310,20 @@ export function getOpenClaw3DArtifactBundle(
.filter((index) => index >= 0);
if (modelIndexes.length === 0) return null;
const packagingModelIndex = modelIndexes.find(
(index) => getOpenClaw3DArtifactVariant(artifacts[index]!) === "packaging"
) ?? -1;
const productModelIndexes = modelIndexes.filter(
(index) => index !== packagingModelIndex
);
const modelIndex =
modelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ??
modelIndexes.find(
productModelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ??
productModelIndexes.find(
(index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d"
) ??
modelIndexes[0];
productModelIndexes[0] ??
packagingModelIndex;
const model = artifacts[modelIndex];
if (!model) return null;
@ -317,7 +335,7 @@ export function getOpenClaw3DArtifactBundle(
artifact.artifactRole.toLowerCase() === alternateRole &&
isOpenClawInteractive3DArtifact(artifact)
)
: modelIndexes.find((index) => index !== modelIndex) ?? -1;
: productModelIndexes.find((index) => index !== modelIndex) ?? -1;
const manifestRole =
model.viewer?.interactionManifestArtifactRole?.toLowerCase() ||
"model_interaction_manifest";
@ -336,13 +354,26 @@ export function getOpenClaw3DArtifactBundle(
return role === "assembled_preview" || role === "cad_preview";
});
}
let packagingPreviewIndex = -1;
if (packagingModelIndex >= 0) {
const packagingModel = artifacts[packagingModelIndex];
const packagingFallbackRole =
packagingModel?.viewer?.fallbackArtifactRole?.toLowerCase() ||
"packaging_preview";
packagingPreviewIndex = artifacts.findIndex(
(artifact) =>
artifact.artifactRole.toLowerCase() === packagingFallbackRole
);
}
return {
modelIndex,
model,
alternateModelIndex,
packagingModelIndex,
manifestIndex,
previewIndex,
packagingPreviewIndex,
};
}

View File

@ -17,10 +17,32 @@ export type OpenClawModelInteractionPart = {
draggable: boolean;
};
export type OpenClawPackagingLayer = {
nodeName: string;
nodeRole: string;
name: string;
packageIndex: number | null;
dimensionsMm: OpenClawModelVector | null;
packedPositionMm: OpenClawModelVector | null;
explodedPositionMm: OpenClawModelVector | null;
explodedOffsetMm: OpenClawModelVector | null;
packedOffsetFromExplodedMm: OpenClawModelVector | null;
color: string | null;
};
export type OpenClawModelInteractionState =
| "assembled"
| "exploded"
| "packaging_exploded"
| "packaging_packed"
| "custom";
export type OpenClawModelInteractionAction = {
id: string;
label: string;
toState: "assembled" | "exploded" | "custom";
toState: OpenClawModelInteractionState;
target: string | null;
nodesField: string | null;
offsetField: string | null;
durationMs: number;
easing: string;
@ -43,7 +65,23 @@ export type OpenClawModelInteractionManifest = {
oneClickAssemble: boolean;
animatedTransition: boolean;
explodeSlider: boolean;
selectPackagingLayer: boolean;
oneClickPackagingExplode: boolean;
oneClickPackagingPack: boolean;
};
packaging: {
shipmentMode: string | null;
boxStyleLabel: string | null;
packageCount: number | null;
packageDimensionsMm: OpenClawModelVector | null;
packageDimensions: Array<{
packageIndex: number;
dimensionsMm: OpenClawModelVector;
}>;
totalPackagingVolumeM3: number | null;
volumeWeightKg: number | null;
layers: OpenClawPackagingLayer[];
} | null;
};
const MAX_PARTS = 5000;
@ -126,6 +164,62 @@ function normalizePart(value: unknown): OpenClawModelInteractionPart | null {
};
}
function normalizePackagingLayer(value: unknown): OpenClawPackagingLayer | null {
const raw = readRecord(value);
const nodeName = readText(raw.node_name ?? raw.nodeName);
if (!nodeName) return null;
const packageIndex = Number(raw.package_index ?? raw.packageIndex);
const color = readText(raw.color, 32);
return {
nodeName,
nodeRole: readText(raw.node_role ?? raw.nodeRole) || "packaging_layer",
name: readText(raw.name) || "包装层",
packageIndex:
Number.isInteger(packageIndex) && packageIndex > 0 ? packageIndex : null,
dimensionsMm: normalizeVector(raw.dimensions_mm ?? raw.dimensionsMm),
packedPositionMm: normalizeVector(
raw.packed_position_mm ?? raw.packedPositionMm
),
explodedPositionMm: normalizeVector(
raw.exploded_position_mm ?? raw.explodedPositionMm
),
explodedOffsetMm: normalizeVector(
raw.exploded_offset_mm ?? raw.explodedOffsetMm
),
packedOffsetFromExplodedMm: normalizeVector(
raw.packed_offset_from_exploded_mm ?? raw.packedOffsetFromExplodedMm
),
color: color || null,
};
}
function readOptionalFiniteNumber(value: unknown, min = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= min ? parsed : null;
}
function normalizePackageDimensions(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
const raw = readRecord(item);
const packageIndex = Number(raw.package_index ?? raw.packageIndex);
const dimensionsMm = normalizeVector(
raw.dimensions_mm ?? raw.dimensionsMm ?? raw
);
if (!Number.isInteger(packageIndex) || packageIndex <= 0 || !dimensionsMm) {
return null;
}
return { packageIndex, dimensionsMm };
})
.filter(
(
item
): item is { packageIndex: number; dimensionsMm: OpenClawModelVector } =>
Boolean(item)
);
}
function normalizeAction(
value: unknown
): OpenClawModelInteractionAction | null {
@ -133,13 +227,19 @@ function normalizeAction(
const id = readText(raw.id, 160);
if (!id) return null;
const toState = readText(raw.to_state ?? raw.toState, 32).toLowerCase();
const normalizedToState: OpenClawModelInteractionState =
toState === "exploded" ||
toState === "custom" ||
toState === "packaging_exploded" ||
toState === "packaging_packed"
? toState
: "assembled";
return {
id,
label: readText(raw.label, 160) || id,
toState:
toState === "exploded" || toState === "custom"
? toState
: "assembled",
toState: normalizedToState,
target: readText(raw.target, 160) || null,
nodesField: readText(raw.nodes_field ?? raw.nodesField, 240) || null,
offsetField: readText(raw.offset_field ?? raw.offsetField, 160) || null,
durationMs: readFiniteNumber(
raw.duration_ms ?? raw.durationMs,
@ -168,6 +268,8 @@ function defaultActions(): OpenClawModelInteractionAction[] {
durationMs: 700,
easing: "easeInOutCubic",
interruptible: true,
target: "all_parts",
nodesField: null,
},
{
id: "assemble_all",
@ -177,6 +279,8 @@ function defaultActions(): OpenClawModelInteractionAction[] {
durationMs: 700,
easing: "easeInOutCubic",
interruptible: true,
target: "all_parts",
nodesField: null,
},
];
}
@ -196,8 +300,6 @@ export function normalizeOpenClawModelInteractionManifest(
seenNodeNames.add(part.nodeName);
return true;
});
if (parts.length === 0) return null;
const parsedActions = (Array.isArray(raw.actions) ? raw.actions : [])
.slice(0, MAX_ACTIONS)
.map(normalizeAction)
@ -212,6 +314,23 @@ export function normalizeOpenClawModelInteractionManifest(
const coordinates = readRecord(raw.coordinates);
const drag = readRecord(raw.drag);
const capabilities = readRecord(raw.capabilities);
const packaging = readRecord(raw.packaging);
const seenPackagingNodeNames = new Set<string>();
const packagingLayers = (
Array.isArray(packaging.packaging_layers)
? packaging.packaging_layers
: Array.isArray(packaging.packagingLayers)
? packaging.packagingLayers
: []
)
.slice(0, MAX_PARTS)
.map(normalizePackagingLayer)
.filter((layer): layer is OpenClawPackagingLayer => {
if (!layer || seenPackagingNodeNames.has(layer.nodeName)) return false;
seenPackagingNodeNames.add(layer.nodeName);
return true;
});
if (parts.length === 0 && packagingLayers.length === 0) return null;
const axes = (Array.isArray(drag.axes) ? drag.axes : ["x", "y", "z"]).filter(
(axis): axis is "x" | "y" | "z" =>
axis === "x" || axis === "y" || axis === "z"
@ -261,10 +380,89 @@ export function normalizeOpenClawModelInteractionManifest(
capabilities.explode_slider ?? capabilities.explodeSlider,
supportsTransforms
),
selectPackagingLayer: readBoolean(
capabilities.select_packaging_layer ??
capabilities.selectPackagingLayer,
packagingLayers.length > 0
),
oneClickPackagingExplode: readBoolean(
capabilities.one_click_packaging_explode ??
capabilities.oneClickPackagingExplode,
packagingLayers.length > 0
),
oneClickPackagingPack: readBoolean(
capabilities.one_click_packaging_pack ??
capabilities.oneClickPackagingPack,
packagingLayers.length > 0
),
},
packaging:
Object.keys(packaging).length > 0
? {
shipmentMode:
readText(packaging.shipment_mode ?? packaging.shipmentMode) ||
null,
boxStyleLabel:
readText(
packaging.packaging_box_style_label ?? packaging.boxStyleLabel
) || null,
packageCount: readOptionalFiniteNumber(
packaging.package_count ?? packaging.packageCount,
1
),
packageDimensionsMm: normalizeVector(
packaging.package_dimensions_mm ?? packaging.packageDimensionsMm
),
packageDimensions: normalizePackageDimensions(
packaging.package_dimensions ?? packaging.packageDimensions
),
totalPackagingVolumeM3: readOptionalFiniteNumber(
packaging.total_packaging_volume_m3 ??
packaging.totalPackagingVolumeM3
),
volumeWeightKg: readOptionalFiniteNumber(
packaging.volume_weight_kg ?? packaging.volumeWeightKg
),
layers: packagingLayers,
}
: null,
};
}
export function getOpenClawPackagingInteractionParts(
manifest: OpenClawModelInteractionManifest
): OpenClawModelInteractionPart[] {
return (manifest.packaging?.layers || []).map((layer) => {
const explodedOffsetMm =
layer.explodedOffsetMm ||
(layer.packedOffsetFromExplodedMm
? {
x: -layer.packedOffsetFromExplodedMm.x || 0,
y: -layer.packedOffsetFromExplodedMm.y || 0,
z: -layer.packedOffsetFromExplodedMm.z || 0,
}
: layer.packedPositionMm && layer.explodedPositionMm
? {
x: layer.explodedPositionMm.x - layer.packedPositionMm.x,
y: layer.explodedPositionMm.y - layer.packedPositionMm.y,
z: layer.explodedPositionMm.z - layer.packedPositionMm.z,
}
: null);
return {
nodeName: layer.nodeName,
partId: layer.nodeRole,
partName: layer.name,
instanceIndex: layer.packageIndex,
dimensionsMm: layer.dimensionsMm,
assembledPositionMm: layer.packedPositionMm,
explodedPositionMm: layer.explodedPositionMm,
explodedOffsetMm,
color: layer.color,
draggable: false,
};
});
}
export function getOpenClawExplodedOffsetMm(
part: OpenClawModelInteractionPart
): OpenClawModelVector | null {

View File

@ -0,0 +1,108 @@
export type OpenClawProductionDrawingManifest = {
releaseStatus: string;
automatedValidationStatus: string;
approvalStatus: string;
releaseBlockers: string[];
version: string | null;
approvedBy: string | null;
fileSha256: string | null;
};
const MAX_TEXT_LENGTH = 500;
const MAX_BLOCKERS = 50;
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readText(value: unknown, maxLength = MAX_TEXT_LENGTH) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function readFirstText(...values: unknown[]) {
for (const value of values) {
const text = readText(value);
if (text) return text;
}
return null;
}
export function normalizeOpenClawProductionDrawingManifest(
value: unknown
): OpenClawProductionDrawingManifest | null {
const raw = readRecord(value);
if (Object.keys(raw).length === 0) return null;
const release = readRecord(raw.release);
const approval = readRecord(raw.approval);
const releaseStatus = readText(
raw.release_status ?? raw.releaseStatus ?? release.status
).toLowerCase();
const automatedValidationStatus = readText(
raw.automated_validation_status ??
raw.automatedValidationStatus ??
raw.validation_status ??
raw.validationStatus
).toLowerCase();
const approvalStatus = readText(
raw.approval_status ?? raw.approvalStatus ?? approval.status
).toLowerCase();
if (!releaseStatus && !automatedValidationStatus && !approvalStatus) {
return null;
}
const rawBlockers =
raw.release_blockers ?? raw.releaseBlockers ?? release.blockers;
const releaseBlockers = (Array.isArray(rawBlockers) ? rawBlockers : [])
.slice(0, MAX_BLOCKERS)
.map((blocker) => {
if (typeof blocker === "string") return readText(blocker);
const record = readRecord(blocker);
return readText(
record.message ?? record.reason ?? record.label ?? record.code
);
})
.filter(Boolean);
return {
releaseStatus,
automatedValidationStatus,
approvalStatus,
releaseBlockers,
version: readFirstText(
raw.version,
raw.release_version,
raw.releaseVersion,
raw.revision,
release.version
),
approvedBy: readFirstText(
raw.approved_by,
raw.approvedBy,
raw.approver,
raw.released_by,
raw.releasedBy,
approval.approved_by,
approval.approvedBy,
approval.approver,
release.approved_by,
release.approvedBy
),
fileSha256: readFirstText(
raw.file_sha256,
raw.fileSha256,
raw.sha256,
raw.release_sha256,
raw.releaseSha256,
release.file_sha256,
release.fileSha256,
release.sha256
),
};
}
export function isOpenClawProductionDrawingReleased(
manifest: OpenClawProductionDrawingManifest | null
) {
return manifest?.releaseStatus === "production_released";
}

View File

@ -0,0 +1,59 @@
function readRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function getTaskPayloadRecords(payload: unknown) {
const root = readRecord(payload);
return {
root,
records: [
root,
readRecord(root.task),
readRecord(root.output),
readRecord(root.result),
],
};
}
export function getOpenClawTaskStatus(payload: unknown) {
const { root } = getTaskPayloadRecords(payload);
return (
readText(readRecord(root.task).status) || readText(root.status)
).toLowerCase();
}
export function isOpenClawAwaitingUserInputTurnComplete(payload: unknown) {
const { records } = getTaskPayloadRecords(payload);
const turnComplete = records.some(
(record) => record.turn_complete === true || record.turnComplete === true
);
const awaitingUserInput = records.some(
(record) =>
record.awaiting_user_input === true ||
record.awaitingUserInput === true ||
readText(record.business_status ?? record.businessStatus).toLowerCase() ===
"awaiting_user_input"
);
return turnComplete && awaitingUserInput;
}
export function getOpenClawTaskAnswer(payload: unknown) {
const { root } = getTaskPayloadRecords(payload);
const output = readRecord(root.output);
const result = readRecord(root.result);
return (
readText(root.answer) ||
readText(root.output) ||
readText(output.answer) ||
readText(output.output) ||
readText(root.result) ||
readText(result.answer) ||
readText(result.output)
);
}

View File

@ -119,7 +119,7 @@ export function appendClawdTaskProgress(
String(progress.step_state || ""),
String(progress.message || progress.display || ""),
].join("|");
const next = current.some((item) => {
const existingIndex = current.findIndex((item) => {
const itemEventId = String(item.event_id || "").trim();
const itemSequence = String(item.sequence || "").trim();
const itemKey = itemEventId
@ -132,9 +132,13 @@ export function appendClawdTaskProgress(
String(item.message || item.display || ""),
].join("|");
return itemKey === key;
})
? current
: [...current, progress];
});
const next =
existingIndex >= 0
? current.map((item, index) =>
index === existingIndex ? { ...item, ...progress } : item
)
: [...current, progress];
return next.slice(-Math.max(1, maxItems));
}

View File

@ -12,6 +12,11 @@ import {
} from "@/lib/openclaw-chat/artifacts";
import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
import {
getOpenClawTaskAnswer,
getOpenClawTaskStatus,
isOpenClawAwaitingUserInputTurnComplete,
} from "@/lib/openclaw-chat/task-protocol";
type OpenClawUpstreamConfig = {
baseUrl: string;
@ -77,10 +82,6 @@ function readEventId(value: unknown) {
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0;
}
function getTaskStatus(payload: unknown) {
return readText(readRecord(readRecord(payload).task).status).toLowerCase();
}
function getTaskEventsEndpoint(payload: unknown, taskId: string) {
const task = readRecord(readRecord(payload).task);
return (
@ -90,15 +91,6 @@ function getTaskEventsEndpoint(payload: unknown, taskId: string) {
);
}
function getAnswer(payload: unknown) {
const root = readRecord(payload);
const output = readRecord(root.output);
const result = readRecord(root.result);
return (
readText(root.answer) || readText(output.answer) || readText(result.answer)
);
}
function getRequestId(payload: unknown) {
const root = readRecord(payload);
const output = readRecord(root.output);
@ -239,6 +231,7 @@ function normalizeProgressEvent(
"任务状态已更新";
return {
...data,
heartbeat: data.heartbeat === true || raw.heartbeat === true,
event_id: eventId,
task_id: taskId,
event_type: eventType,
@ -248,6 +241,7 @@ function normalizeProgressEvent(
step_state: readText(data.step_state) || readText(raw.status) || "started",
message,
display: message,
display_text: message,
created_at: raw.created_at,
};
}
@ -423,8 +417,14 @@ export async function refreshOpenClawDurableTask(params: {
upstreamTask,
});
const status = getTaskStatus(eventsPayload);
if (!TERMINAL_STATUSES.has(status)) {
const status = getOpenClawTaskStatus(eventsPayload);
const eventTurnAwaitingInput =
isOpenClawAwaitingUserInputTurnComplete(eventsPayload);
if (
!TERMINAL_STATUSES.has(status) &&
!eventTurnAwaitingInput &&
status !== "waiting"
) {
return { status: "pending", upstreamTask };
}
@ -435,9 +435,16 @@ export async function refreshOpenClawDurableTask(params: {
signal: params.signal,
});
upstreamTask = mergeUpstreamTaskState(upstreamTask, terminalPayload);
const terminalStatus = getTaskStatus(terminalPayload) || status;
if (terminalStatus === "succeeded" || terminalStatus === "completed") {
const answer = getAnswer(terminalPayload);
const terminalStatus = getOpenClawTaskStatus(terminalPayload) || status;
const turnAwaitingInput =
eventTurnAwaitingInput ||
isOpenClawAwaitingUserInputTurnComplete(terminalPayload);
if (
terminalStatus === "succeeded" ||
terminalStatus === "completed" ||
turnAwaitingInput
) {
const answer = getOpenClawTaskAnswer(terminalPayload);
if (!answer) {
return {
status: "failed",
@ -455,6 +462,9 @@ export async function refreshOpenClawDurableTask(params: {
}),
};
}
if (!TERMINAL_STATUSES.has(terminalStatus)) {
return { status: "pending", upstreamTask };
}
if (terminalStatus === "cancelled" || terminalStatus === "canceled") {
return { status: "canceled", upstreamTask, error: "任务已停止" };
}

View File

@ -3,8 +3,11 @@ const OPENCLAW_3D_VIEWER_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export type OpenClaw3DViewerSession = {
messageId: string;
viewMode: "product" | "packaging";
explodeAmount: number;
packagingExplodeAmount: number;
selectedNodeName: string | null;
selectedPackagingNodeName: string | null;
updatedAt: number;
};
@ -53,12 +56,24 @@ export function readOpenClaw3DViewerSession(params: {
}
return {
messageId,
explodeAmount: normalizeExplodeAmount(value.explodeAmount),
viewMode: value.viewMode === "packaging" ? "packaging" : "product",
explodeAmount: normalizeExplodeAmount(
value.productExplodeAmount ?? value.explodeAmount
),
packagingExplodeAmount:
value.packagingExplodeAmount === undefined
? 1
: normalizeExplodeAmount(value.packagingExplodeAmount),
selectedNodeName:
typeof value.selectedNodeName === "string" &&
value.selectedNodeName.trim()
? value.selectedNodeName.trim()
: null,
selectedPackagingNodeName:
typeof value.selectedPackagingNodeName === "string" &&
value.selectedPackagingNodeName.trim()
? value.selectedPackagingNodeName.trim()
: null,
updatedAt,
};
} catch {
@ -72,7 +87,10 @@ export function writeOpenClaw3DViewerSession(params: {
chatId: string;
messageId: string;
explodeAmount: number;
viewMode?: "product" | "packaging";
packagingExplodeAmount?: number;
selectedNodeName?: string | null;
selectedPackagingNodeName?: string | null;
now?: number;
}) {
const messageId = params.messageId.trim();
@ -82,8 +100,15 @@ export function writeOpenClaw3DViewerSession(params: {
getViewerSessionKey(params.chatId),
JSON.stringify({
messageId,
viewMode: params.viewMode || "product",
explodeAmount: normalizeExplodeAmount(params.explodeAmount),
packagingExplodeAmount:
params.packagingExplodeAmount === undefined
? 1
: normalizeExplodeAmount(params.packagingExplodeAmount),
selectedNodeName: params.selectedNodeName?.trim() || null,
selectedPackagingNodeName:
params.selectedPackagingNodeName?.trim() || null,
updatedAt: params.now ?? Date.now(),
} satisfies OpenClaw3DViewerSession)
);

View File

@ -9,10 +9,15 @@ import {
} from "@/lib/openclaw-chat/artifacts";
import {
getOpenClawExplodedOffsetMm,
getOpenClawPackagingInteractionParts,
getOpenClawSourceNodeOffset,
mapOpenClawCadOffsetToGlb,
normalizeOpenClawModelInteractionManifest,
} from "@/lib/openclaw-chat/model-interaction";
import {
isOpenClawProductionDrawingReleased,
normalizeOpenClawProductionDrawingManifest,
} from "@/lib/openclaw-chat/production-drawing";
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
import {
clearOpenClaw3DViewerSession,
@ -89,7 +94,56 @@ const manifestFile = {
download_url: "https://files.example.com/wood-demo-model-interaction.json",
};
const packagingModelFile = {
...modelFile,
filename: "wood-demo-packaging.glb",
display_name: "可旋转3D包装叠放模型",
artifact_role: "packaging_3d",
viewer: {
...modelFile.viewer,
variant: "packaging",
default: false,
fallback_artifact_role: "packaging_preview",
interaction_manifest_artifact_role: "model_interaction_manifest",
},
};
test.describe("OpenClaw 3D artifacts", () => {
test("normalizes production drawing release safeguards", () => {
const pending = normalizeOpenClawProductionDrawingManifest({
release_status: "draft",
automated_validation_status: "production_ready",
approval_status: "awaiting_engineering_release",
release_blockers: [
"待结构工程师签批",
{ code: "HOLE_CHECK", message: "孔位仍需复核" },
],
});
expect(pending).toMatchObject({
releaseStatus: "draft",
automatedValidationStatus: "production_ready",
approvalStatus: "awaiting_engineering_release",
releaseBlockers: ["待结构工程师签批", "孔位仍需复核"],
});
expect(isOpenClawProductionDrawingReleased(pending)).toBe(false);
const released = normalizeOpenClawProductionDrawingManifest({
release: {
status: "production_released",
version: "V3",
approved_by: "结构工程师A",
sha256: "abc123",
},
});
expect(released).toMatchObject({
releaseStatus: "production_released",
version: "V3",
approvedBy: "结构工程师A",
fileSha256: "abc123",
});
expect(isOpenClawProductionDrawingReleased(released)).toBe(true);
});
test("normalizes the documented GLB viewer contract", () => {
const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], {
baseUrl: "https://claw.example.com",
@ -121,7 +175,9 @@ test.describe("OpenClaw 3D artifacts", () => {
modelFile,
manifestFile,
assembledModelFile,
packagingModelFile,
{ ...previewFile, artifact_role: "assembled_preview" },
{ ...previewFile, artifact_role: "packaging_preview" },
],
{ baseUrl: "https://claw.example.com" }
);
@ -145,7 +201,89 @@ test.describe("OpenClaw 3D artifacts", () => {
});
expect(bundle?.manifestIndex).toBe(1);
expect(bundle?.alternateModelIndex).toBe(0);
expect(bundle?.previewIndex).toBe(3);
expect(bundle?.previewIndex).toBe(4);
expect(bundle?.packagingModelIndex).toBe(3);
expect(bundle?.packagingPreviewIndex).toBe(5);
expect(getOpenClaw3DArtifactVariant(artifacts[3])).toBe("packaging");
});
test("normalizes v3 packaging models, actions, layers, and carton metrics", () => {
const manifest = normalizeOpenClawModelInteractionManifest({
schema: "dalongxia.furniture-model-interaction.v3",
coordinates: { scale_mm_to_glb: 0.001 },
capabilities: {
one_click_packaging_explode: true,
one_click_packaging_pack: true,
select_packaging_layer: true,
},
actions: [
{
id: "packaging_pack",
label: "合上包装",
to_state: "packaging_packed",
target: "packaging_layers",
nodes_field: "packaging.packaging_layers",
offset_field: "packed_offset_from_exploded_mm",
duration_ms: 850,
},
{
id: "packaging_explode",
label: "展开包装",
to_state: "packaging_exploded",
target: "packaging_layers",
nodes_field: "packaging.packaging_layers",
},
],
packaging: {
shipment_mode: "拆装",
packaging_box_style_label: "中开箱",
package_count: 1,
package_dimensions_mm: { x: 1260, y: 638, z: 162 },
package_dimensions: [
{ package_index: 1, x: 1260, y: 638, z: 162 },
],
total_packaging_volume_m3: 0.13,
volume_weight_kg: 21.7,
packaging_layers: [
{
package_index: 1,
node_name: "PKG-01-top-001",
node_role: "top_cover",
name: "上盖",
dimensions_mm: { x: 1250, y: 628, z: 8 },
packed_position_mm: { x: 5, y: 5, z: 154 },
exploded_position_mm: { x: 5, y: 5, z: 420 },
packed_offset_from_exploded_mm: { x: 0, y: 0, z: -266 },
},
],
},
});
expect(manifest?.schema).toBe("dalongxia.furniture-model-interaction.v3");
expect(manifest?.actions[0]).toMatchObject({
id: "packaging_pack",
toState: "packaging_packed",
target: "packaging_layers",
nodesField: "packaging.packaging_layers",
});
expect(manifest?.packaging).toMatchObject({
shipmentMode: "拆装",
boxStyleLabel: "中开箱",
packageCount: 1,
packageDimensionsMm: { x: 1260, y: 638, z: 162 },
totalPackagingVolumeM3: 0.13,
volumeWeightKg: 21.7,
});
expect(manifest?.capabilities.oneClickPackagingPack).toBe(true);
expect(getOpenClawPackagingInteractionParts(manifest!)[0]).toMatchObject({
nodeName: "PKG-01-top-001",
partId: "top_cover",
partName: "上盖",
assembledPositionMm: { x: 5, y: 5, z: 154 },
explodedPositionMm: { x: 5, y: 5, z: 420 },
explodedOffsetMm: { x: 0, y: 0, z: 266 },
draggable: false,
});
});
test("treats legacy interactive-only GLB as exploded source and can restore assembly", () => {
@ -332,12 +470,43 @@ test.describe("OpenClaw 3D artifacts", () => {
})
).toEqual({
messageId: "message-id",
viewMode: "product",
explodeAmount: 1,
packagingExplodeAmount: 1,
selectedNodeName: "DOOR-001:1",
selectedPackagingNodeName: null,
updatedAt: 1_000,
});
});
test("restores the packaging tab and packed state after a refresh", () => {
const storage = createMemoryStorage();
writeOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "message-id",
viewMode: "packaging",
explodeAmount: 0.25,
packagingExplodeAmount: 0,
selectedPackagingNodeName: "PKG-01-top-001",
now: 1_000,
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: 2_000,
})
).toMatchObject({
messageId: "message-id",
viewMode: "packaging",
explodeAmount: 0.25,
packagingExplodeAmount: 0,
selectedPackagingNodeName: "PKG-01-top-001",
});
});
test("only clears the viewer state owned by the matching message", () => {
const storage = createMemoryStorage();
const now = Date.now();

View File

@ -4,6 +4,11 @@ import {
isPendingClawdTaskStatus,
normalizeClawdTaskMetadata,
} from "@/lib/openclaw-chat/task-state";
import {
getOpenClawTaskAnswer,
getOpenClawTaskStatus,
isOpenClawAwaitingUserInputTurnComplete,
} from "@/lib/openclaw-chat/task-protocol";
test.describe("OpenClaw durable task state", () => {
test("restores a persisted running task after the page remounts", () => {
@ -44,8 +49,49 @@ test.describe("OpenClaw durable task state", () => {
expect(events).toEqual([first, second]);
});
test("refreshes a heartbeat even when it reuses the current event id", () => {
const first = {
event_id: 8,
message: "正在识别产品结构",
created_at: "2026-09-06T01:00:00Z",
};
const heartbeat = {
event_id: 8,
heartbeat: true,
display_text: "正在生成包装排布",
created_at: "2026-09-06T01:00:05Z",
};
let events = appendClawdTaskProgress([], first);
events = appendClawdTaskProgress(events, heartbeat);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
event_id: 8,
heartbeat: true,
message: "正在识别产品结构",
display_text: "正在生成包装排布",
created_at: "2026-09-06T01:00:05Z",
});
});
test("does not classify completed tasks as pending", () => {
expect(isPendingClawdTaskStatus("completed")).toBe(false);
expect(isPendingClawdTaskStatus("failed")).toBe(false);
});
test("recognizes a completed turn that is waiting for the next upload", () => {
const response = {
task: { status: "waiting" },
output: {
answer: "请上传正视图、侧视图、开门图和尺寸图",
turn_complete: true,
awaiting_user_input: true,
business_status: "awaiting_user_input",
},
};
expect(getOpenClawTaskStatus(response)).toBe("waiting");
expect(isOpenClawAwaitingUserInputTurnComplete(response)).toBe(true);
expect(getOpenClawTaskAnswer(response)).toContain("请上传正视图");
});
});