feat: support updated OpenClaw 3D task protocol
This commit is contained in:
parent
272592488b
commit
740fc90dfd
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
108
lib/openclaw-chat/production-drawing.ts
Normal file
108
lib/openclaw-chat/production-drawing.ts
Normal 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";
|
||||
}
|
||||
59
lib/openclaw-chat/task-protocol.ts
Normal file
59
lib/openclaw-chat/task-protocol.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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: "任务已停止" };
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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("请上传正视图");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user