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.spreadsheetMarkdownTruncated || undefined,
|
||||||
...params.metadata,
|
...params.metadata,
|
||||||
},
|
},
|
||||||
stream_progress: false,
|
stream_progress: !params.sensitiveInput,
|
||||||
sensitive_input: params.sensitiveInput,
|
sensitive_input: params.sensitiveInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -683,15 +683,28 @@ function normalizeClawdComposerText(value: string) {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildClawdSkillTagPrompt(tag: Pick<CanvasSkillTag, "name" | "prompt"> | null) {
|
function buildClawdSkillTagPrompt(
|
||||||
|
tag: Pick<CanvasSkillTag, "id" | "name" | "prompt"> | null
|
||||||
|
) {
|
||||||
if (!tag) return "";
|
if (!tag) return "";
|
||||||
|
|
||||||
const tagName = String(tag.name || "").trim() || "快捷标签";
|
const tagName = String(tag.name || "").trim() || "快捷标签";
|
||||||
const tagPrompt = String(tag.prompt || "").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 [
|
return [
|
||||||
`【快捷标签:${tagName}】`,
|
`【快捷标签:${tagName}】`,
|
||||||
|
routePrompt,
|
||||||
tagPrompt || `用户选择了「${tagName}」快捷标签,请按该标签对应能力处理。`,
|
tagPrompt || `用户选择了「${tagName}」快捷标签,请按该标签对应能力处理。`,
|
||||||
].join("\n");
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6;
|
const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6;
|
||||||
@ -734,6 +747,8 @@ type ClawdProgressEvent = {
|
|||||||
completed?: number;
|
completed?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
progressPercent?: number;
|
progressPercent?: number;
|
||||||
|
heartbeat: boolean;
|
||||||
|
activeAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClawdDurableTaskStatus =
|
type ClawdDurableTaskStatus =
|
||||||
@ -870,9 +885,8 @@ async function waitForClawdDurableTask(params: {
|
|||||||
function normalizeClawdProgressEvent(
|
function normalizeClawdProgressEvent(
|
||||||
progress: Record<string, unknown>
|
progress: Record<string, unknown>
|
||||||
): ClawdProgressEvent | null {
|
): ClawdProgressEvent | null {
|
||||||
if (progress.heartbeat === true) return null;
|
|
||||||
const message = String(
|
const message = String(
|
||||||
progress.message || progress.display || ""
|
progress.display_text || progress.message || progress.display || ""
|
||||||
).trim();
|
).trim();
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
const eventId = Number(progress.event_id);
|
const eventId = Number(progress.event_id);
|
||||||
@ -883,6 +897,9 @@ function normalizeClawdProgressEvent(
|
|||||||
const completed = Number(progress.completed);
|
const completed = Number(progress.completed);
|
||||||
const total = Number(progress.total);
|
const total = Number(progress.total);
|
||||||
const progressPercent = Number(progress.progress_percent);
|
const progressPercent = Number(progress.progress_percent);
|
||||||
|
const activeAt = Date.parse(
|
||||||
|
String(progress.created_at || progress.updated_at || "")
|
||||||
|
);
|
||||||
const normalizedEventId = Number.isFinite(eventId) && eventId > 0
|
const normalizedEventId = Number.isFinite(eventId) && eventId > 0
|
||||||
? eventId
|
? eventId
|
||||||
: undefined;
|
: undefined;
|
||||||
@ -914,9 +931,31 @@ function normalizeClawdProgressEvent(
|
|||||||
progressPercent <= 100
|
progressPercent <= 100
|
||||||
? Math.round(progressPercent)
|
? Math.round(progressPercent)
|
||||||
: undefined,
|
: 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(
|
function isClawdSubmittedComposerText(
|
||||||
snapshot: ClawdComposerSubmissionSnapshot,
|
snapshot: ClawdComposerSubmissionSnapshot,
|
||||||
value: string
|
value: string
|
||||||
@ -2003,6 +2042,10 @@ function normalizeOpenClawActionText(value: unknown, maxLength: number) {
|
|||||||
.slice(0, maxLength);
|
.slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOpenClawActionMessage(value: unknown, maxLength = 2000) {
|
||||||
|
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
|
||||||
|
}
|
||||||
|
|
||||||
function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | null {
|
function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | null {
|
||||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||||
return null;
|
return null;
|
||||||
@ -2025,7 +2068,7 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n
|
|||||||
}
|
}
|
||||||
const action = item as Record<string, unknown>;
|
const action = item as Record<string, unknown>;
|
||||||
const label = normalizeOpenClawActionText(action.label, 24);
|
const label = normalizeOpenClawActionText(action.label, 24);
|
||||||
const message = normalizeOpenClawActionText(action.message, 300);
|
const message = normalizeOpenClawActionMessage(action.message);
|
||||||
if (!label || !message) return null;
|
if (!label || !message) return null;
|
||||||
|
|
||||||
const intent = normalizeOpenClawActionText(action.intent, 80);
|
const intent = normalizeOpenClawActionText(action.intent, 80);
|
||||||
@ -2039,7 +2082,6 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((item): item is OpenClawQuickAction => Boolean(item))
|
.filter((item): item is OpenClawQuickAction => Boolean(item))
|
||||||
.slice(0, 10)
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (actions.length === 0) return null;
|
if (actions.length === 0) return null;
|
||||||
@ -2586,9 +2628,17 @@ function CanvasAssistantTypingPlaceholder({
|
|||||||
const isFailed = progressEvent.stepState === "failed";
|
const isFailed = progressEvent.stepState === "failed";
|
||||||
const isActive =
|
const isActive =
|
||||||
isLatest &&
|
isLatest &&
|
||||||
["accepted", "queued", "started", "waiting"].includes(
|
[
|
||||||
|
"accepted",
|
||||||
|
"queued",
|
||||||
|
"started",
|
||||||
|
"waiting",
|
||||||
|
"processing",
|
||||||
|
"running",
|
||||||
|
].includes(
|
||||||
progressEvent.stepState
|
progressEvent.stepState
|
||||||
);
|
);
|
||||||
|
const progressMeta = getClawdProgressMeta(progressEvent);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={progressEvent.key}
|
key={progressEvent.key}
|
||||||
@ -2603,21 +2653,9 @@ function CanvasAssistantTypingPlaceholder({
|
|||||||
)}
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="break-words">{progressEvent.message}</div>
|
<div className="break-words">{progressEvent.message}</div>
|
||||||
{progressEvent.currentOrderId || progressEvent.total ? (
|
{progressMeta ? (
|
||||||
<div className="text-[11px] text-[#929baa] dark:text-zinc-500">
|
<div className="text-[11px] text-[#929baa] dark:text-zinc-500">
|
||||||
{progressEvent.currentOrderId
|
{progressMeta}
|
||||||
? `订单 ${progressEvent.currentOrderId}`
|
|
||||||
: ""}
|
|
||||||
{progressEvent.currentOrderId && progressEvent.total
|
|
||||||
? " · "
|
|
||||||
: ""}
|
|
||||||
{progressEvent.total
|
|
||||||
? `${progressEvent.completed || 0}/${progressEvent.total}${
|
|
||||||
progressEvent.progressPercent != null
|
|
||||||
? ` · ${progressEvent.progressPercent}%`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
: ""}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -13139,7 +13177,7 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
const handleOpenClawQuickAction = useCallback(
|
const handleOpenClawQuickAction = useCallback(
|
||||||
(message: string) => {
|
(message: string) => {
|
||||||
const quickMessage = normalizeOpenClawActionText(message, 300);
|
const quickMessage = normalizeOpenClawActionMessage(message);
|
||||||
if (!quickMessage) {
|
if (!quickMessage) {
|
||||||
toast.error("快捷标签内容为空");
|
toast.error("快捷标签内容为空");
|
||||||
return;
|
return;
|
||||||
@ -15024,6 +15062,18 @@ export function CanvasChatPanel({
|
|||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
}, [visibleMessages]);
|
}, [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 =
|
const isDetailEmptyState =
|
||||||
isDetailGenerationMode &&
|
isDetailGenerationMode &&
|
||||||
@ -16167,6 +16217,8 @@ export function CanvasChatPanel({
|
|||||||
const isClawAssistantMessage =
|
const isClawAssistantMessage =
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
(isClawdMode || isClawdChatMessage(message));
|
(isClawdMode || isClawdChatMessage(message));
|
||||||
|
const isLatestClawAssistantMessage =
|
||||||
|
message.id === latestClawAssistantMessageId;
|
||||||
const openClawArtifacts = isClawAssistantMessage
|
const openClawArtifacts = isClawAssistantMessage
|
||||||
? getOpenClawMessageArtifacts(message)
|
? getOpenClawMessageArtifacts(message)
|
||||||
: [];
|
: [];
|
||||||
@ -16433,8 +16485,14 @@ export function CanvasChatPanel({
|
|||||||
openClawActionsResult.markdown
|
openClawActionsResult.markdown
|
||||||
)
|
)
|
||||||
: openClawActionsResult.markdown;
|
: openClawActionsResult.markdown;
|
||||||
const openClawActionBlocks = openClawActionsResult.blocks;
|
const openClawActionBlocks =
|
||||||
const openClawSecureForms = openClawActionsResult.forms;
|
isLatestClawAssistantMessage
|
||||||
|
? openClawActionsResult.blocks
|
||||||
|
: [];
|
||||||
|
const openClawSecureForms =
|
||||||
|
isLatestClawAssistantMessage
|
||||||
|
? openClawActionsResult.forms
|
||||||
|
: [];
|
||||||
const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions);
|
const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions);
|
||||||
const plainText = normalizeCanvasAgentText(textWithoutActions);
|
const plainText = normalizeCanvasAgentText(textWithoutActions);
|
||||||
const analysisToggleKey = `${message.id}-${i}-analysis`;
|
const analysisToggleKey = `${message.id}-${i}-analysis`;
|
||||||
|
|||||||
@ -26,6 +26,11 @@ export type OpenClawModelTransformRequest = {
|
|||||||
nonce: number;
|
nonce: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpenClawModelCameraState = {
|
||||||
|
position: [number, number, number];
|
||||||
|
target: [number, number, number];
|
||||||
|
};
|
||||||
|
|
||||||
type InteractiveEntry = {
|
type InteractiveEntry = {
|
||||||
node: Object3D;
|
node: Object3D;
|
||||||
part: OpenClawModelInteractionPart;
|
part: OpenClawModelInteractionPart;
|
||||||
@ -144,9 +149,13 @@ function getPartTargetPosition(
|
|||||||
|
|
||||||
function ModelInteractionScene({
|
function ModelInteractionScene({
|
||||||
allowDragging,
|
allowDragging,
|
||||||
|
fitExplodedSource,
|
||||||
|
initialCameraState,
|
||||||
|
interactionParts,
|
||||||
manifest,
|
manifest,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
onMatchChange,
|
onMatchChange,
|
||||||
|
onCameraStateChange,
|
||||||
onSelectedPartChange,
|
onSelectedPartChange,
|
||||||
resetAllToken,
|
resetAllToken,
|
||||||
resetSelectedToken,
|
resetSelectedToken,
|
||||||
@ -155,9 +164,13 @@ function ModelInteractionScene({
|
|||||||
transformRequest,
|
transformRequest,
|
||||||
}: {
|
}: {
|
||||||
allowDragging: boolean;
|
allowDragging: boolean;
|
||||||
|
fitExplodedSource: boolean;
|
||||||
|
initialCameraState?: OpenClawModelCameraState | null;
|
||||||
|
interactionParts: OpenClawModelInteractionPart[];
|
||||||
manifest: OpenClawModelInteractionManifest;
|
manifest: OpenClawModelInteractionManifest;
|
||||||
modelUrl: string;
|
modelUrl: string;
|
||||||
onMatchChange: (matched: number, total: number) => void;
|
onMatchChange: (matched: number, total: number) => void;
|
||||||
|
onCameraStateChange?: (state: OpenClawModelCameraState) => void;
|
||||||
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
||||||
resetAllToken: number;
|
resetAllToken: number;
|
||||||
resetSelectedToken: number;
|
resetSelectedToken: number;
|
||||||
@ -172,7 +185,7 @@ function ModelInteractionScene({
|
|||||||
const cloned = cloneScene(gltf.scene);
|
const cloned = cloneScene(gltf.scene);
|
||||||
if (sourceVariant !== "exploded") return cloned;
|
if (sourceVariant !== "exploded") return cloned;
|
||||||
const partsByNodeName = new Map(
|
const partsByNodeName = new Map(
|
||||||
manifest.parts.map((part) => [part.nodeName, part])
|
interactionParts.map((part) => [part.nodeName, part])
|
||||||
);
|
);
|
||||||
cloned.traverse((node) => {
|
cloned.traverse((node) => {
|
||||||
const part = partsByNodeName.get(node.name);
|
const part = partsByNodeName.get(node.name);
|
||||||
@ -186,8 +199,11 @@ function ModelInteractionScene({
|
|||||||
});
|
});
|
||||||
cloned.updateMatrixWorld(true);
|
cloned.updateMatrixWorld(true);
|
||||||
return cloned;
|
return cloned;
|
||||||
}, [gltf.scene, manifest, sourceVariant]);
|
}, [gltf.scene, interactionParts, manifest.scaleMmToGlb, sourceVariant]);
|
||||||
const fitTransform = useMemo(() => getFitTransform(scene), [scene]);
|
const fitTransform = useMemo(
|
||||||
|
() => getFitTransform(fitExplodedSource ? gltf.scene : scene),
|
||||||
|
[fitExplodedSource, gltf.scene, scene]
|
||||||
|
);
|
||||||
const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map());
|
const entriesRef = useRef<Map<string, InteractiveEntry>>(new Map());
|
||||||
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map());
|
const objectEntriesRef = useRef<Map<Object3D, InteractiveEntry>>(new Map());
|
||||||
const animationRef = useRef<AnimationState | null>(null);
|
const animationRef = useRef<AnimationState | null>(null);
|
||||||
@ -197,7 +213,7 @@ function ModelInteractionScene({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const partsByNodeName = new Map(
|
const partsByNodeName = new Map(
|
||||||
manifest.parts.map((part) => [part.nodeName, part])
|
interactionParts.map((part) => [part.nodeName, part])
|
||||||
);
|
);
|
||||||
const entries = new Map<string, InteractiveEntry>();
|
const entries = new Map<string, InteractiveEntry>();
|
||||||
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
const objectEntries = new Map<Object3D, InteractiveEntry>();
|
||||||
@ -214,14 +230,22 @@ function ModelInteractionScene({
|
|||||||
});
|
});
|
||||||
entriesRef.current = entries;
|
entriesRef.current = entries;
|
||||||
objectEntriesRef.current = objectEntries;
|
objectEntriesRef.current = objectEntries;
|
||||||
onMatchChange(entries.size, manifest.parts.length);
|
onMatchChange(entries.size, interactionParts.length);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
entriesRef.current.clear();
|
entriesRef.current.clear();
|
||||||
objectEntriesRef.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(() => {
|
useEffect(() => {
|
||||||
const previous = selectionHelperRef.current;
|
const previous = selectionHelperRef.current;
|
||||||
@ -469,6 +493,14 @@ function ModelInteractionScene({
|
|||||||
maxPolarAngle={Math.PI / 1.65}
|
maxPolarAngle={Math.PI / 1.65}
|
||||||
minDistance={1.2}
|
minDistance={1.2}
|
||||||
minPolarAngle={0}
|
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}
|
ref={controlsRef}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@ -478,9 +510,13 @@ function ModelInteractionScene({
|
|||||||
export function OpenClawInteractiveModelViewer({
|
export function OpenClawInteractiveModelViewer({
|
||||||
allowDragging,
|
allowDragging,
|
||||||
background,
|
background,
|
||||||
|
fitExplodedSource = false,
|
||||||
|
initialCameraState,
|
||||||
|
interactionParts,
|
||||||
manifest,
|
manifest,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
onMatchChange,
|
onMatchChange,
|
||||||
|
onCameraStateChange,
|
||||||
onSelectedPartChange,
|
onSelectedPartChange,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
resetAllToken,
|
resetAllToken,
|
||||||
@ -492,9 +528,13 @@ export function OpenClawInteractiveModelViewer({
|
|||||||
}: {
|
}: {
|
||||||
allowDragging: boolean;
|
allowDragging: boolean;
|
||||||
background: string;
|
background: string;
|
||||||
|
fitExplodedSource?: boolean;
|
||||||
|
initialCameraState?: OpenClawModelCameraState | null;
|
||||||
|
interactionParts?: OpenClawModelInteractionPart[];
|
||||||
manifest: OpenClawModelInteractionManifest;
|
manifest: OpenClawModelInteractionManifest;
|
||||||
modelUrl: string;
|
modelUrl: string;
|
||||||
onMatchChange: (matched: number, total: number) => void;
|
onMatchChange: (matched: number, total: number) => void;
|
||||||
|
onCameraStateChange?: (state: OpenClawModelCameraState) => void;
|
||||||
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void;
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
resetAllToken: number;
|
resetAllToken: number;
|
||||||
@ -527,16 +567,20 @@ export function OpenClawInteractiveModelViewer({
|
|||||||
camera={{ fov: 42, position: [2.4, 1.8, 2.4] }}
|
camera={{ fov: 42, position: [2.4, 1.8, 2.4] }}
|
||||||
dpr={[1, 1.75]}
|
dpr={[1, 1.75]}
|
||||||
gl={{ antialias: true, powerPreference: "high-performance" }}
|
gl={{ antialias: true, powerPreference: "high-performance" }}
|
||||||
key={resetViewToken}
|
key={`${modelUrl}:${resetViewToken}`}
|
||||||
shadows
|
shadows
|
||||||
style={{ background }}
|
style={{ background }}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ModelInteractionScene
|
<ModelInteractionScene
|
||||||
allowDragging={allowDragging}
|
allowDragging={allowDragging}
|
||||||
|
fitExplodedSource={fitExplodedSource}
|
||||||
|
initialCameraState={initialCameraState}
|
||||||
|
interactionParts={interactionParts || manifest.parts}
|
||||||
manifest={manifest}
|
manifest={manifest}
|
||||||
modelUrl={modelUrl}
|
modelUrl={modelUrl}
|
||||||
onMatchChange={onMatchChange}
|
onMatchChange={onMatchChange}
|
||||||
|
onCameraStateChange={onCameraStateChange}
|
||||||
onSelectedPartChange={onSelectedPartChange}
|
onSelectedPartChange={onSelectedPartChange}
|
||||||
resetAllToken={resetAllToken}
|
resetAllToken={resetAllToken}
|
||||||
resetSelectedToken={resetSelectedToken}
|
resetSelectedToken={resetSelectedToken}
|
||||||
|
|||||||
@ -4,11 +4,17 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
CheckCircle2,
|
||||||
Download,
|
Download,
|
||||||
|
File,
|
||||||
|
FileSpreadsheet,
|
||||||
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
Package,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ShieldAlert,
|
||||||
Undo2,
|
Undo2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -27,15 +33,22 @@ import {
|
|||||||
type OpenClawArtifact,
|
type OpenClawArtifact,
|
||||||
} from "@/lib/openclaw-chat/artifacts";
|
} from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
|
getOpenClawPackagingInteractionParts,
|
||||||
normalizeOpenClawModelInteractionManifest,
|
normalizeOpenClawModelInteractionManifest,
|
||||||
type OpenClawModelInteractionManifest,
|
type OpenClawModelInteractionManifest,
|
||||||
type OpenClawModelInteractionPart,
|
type OpenClawModelInteractionPart,
|
||||||
} from "@/lib/openclaw-chat/model-interaction";
|
} from "@/lib/openclaw-chat/model-interaction";
|
||||||
|
import {
|
||||||
|
isOpenClawProductionDrawingReleased,
|
||||||
|
normalizeOpenClawProductionDrawingManifest,
|
||||||
|
type OpenClawProductionDrawingManifest,
|
||||||
|
} from "@/lib/openclaw-chat/production-drawing";
|
||||||
import {
|
import {
|
||||||
clearOpenClaw3DViewerSession,
|
clearOpenClaw3DViewerSession,
|
||||||
readOpenClaw3DViewerSession,
|
readOpenClaw3DViewerSession,
|
||||||
writeOpenClaw3DViewerSession,
|
writeOpenClaw3DViewerSession,
|
||||||
} from "@/lib/openclaw-chat/viewer-session";
|
} from "@/lib/openclaw-chat/viewer-session";
|
||||||
|
import type { OpenClawModelCameraState } from "@/components/canvas/chat-panel/OpenClawInteractiveModelViewer";
|
||||||
|
|
||||||
const Model3DViewer = dynamic(
|
const Model3DViewer = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@ -60,6 +73,7 @@ const OpenClawInteractiveModelViewer = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
|
const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer";
|
||||||
|
type OpenClaw3DViewMode = "product" | "packaging";
|
||||||
|
|
||||||
function ViewerLoading() {
|
function ViewerLoading() {
|
||||||
return (
|
return (
|
||||||
@ -96,6 +110,338 @@ function formatDimensions(part: OpenClawModelInteractionPart) {
|
|||||||
return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`;
|
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({
|
export function OpenClawModel3DPreview({
|
||||||
artifacts,
|
artifacts,
|
||||||
chatId,
|
chatId,
|
||||||
@ -110,7 +456,21 @@ export function OpenClawModel3DPreview({
|
|||||||
() => getOpenClaw3DArtifactBundle(artifacts),
|
() => getOpenClaw3DArtifactBundle(artifacts),
|
||||||
[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 [isActive, setIsActive] = useState(false);
|
||||||
const [webGlUnavailable, setWebGlUnavailable] = useState(false);
|
const [webGlUnavailable, setWebGlUnavailable] = useState(false);
|
||||||
const [resetViewToken, setResetViewToken] = useState(0);
|
const [resetViewToken, setResetViewToken] = useState(0);
|
||||||
@ -121,21 +481,29 @@ export function OpenClawModel3DPreview({
|
|||||||
const [manifestError, setManifestError] = useState<string | null>(null);
|
const [manifestError, setManifestError] = useState<string | null>(null);
|
||||||
const [selectedPart, setSelectedPart] =
|
const [selectedPart, setSelectedPart] =
|
||||||
useState<OpenClawModelInteractionPart | null>(null);
|
useState<OpenClawModelInteractionPart | null>(null);
|
||||||
|
const [selectedPackagingPart, setSelectedPackagingPart] =
|
||||||
|
useState<OpenClawModelInteractionPart | null>(null);
|
||||||
const [matchState, setMatchState] = useState({ matched: 0, total: 0 });
|
const [matchState, setMatchState] = useState({ matched: 0, total: 0 });
|
||||||
const [explodeAmount, setExplodeAmount] = useState(0);
|
const [explodeAmount, setExplodeAmount] = useState(0);
|
||||||
|
const [packagingExplodeAmount, setPackagingExplodeAmount] = useState(1);
|
||||||
const [transformRequest, setTransformRequest] = useState({
|
const [transformRequest, setTransformRequest] = useState({
|
||||||
actionId: null as string | null,
|
actionId: null as string | null,
|
||||||
amount: 0,
|
amount: productModel ? 0 : 1,
|
||||||
animate: false,
|
animate: false,
|
||||||
durationMs: 700,
|
durationMs: 700,
|
||||||
nonce: 0,
|
nonce: 0,
|
||||||
});
|
});
|
||||||
const hasRestoredSessionRef = useRef(false);
|
const hasRestoredSessionRef = useRef(false);
|
||||||
const restoredSelectedNodeNameRef = useRef<string | null>(null);
|
const restoredSelectedNodeNameRef = useRef<string | null>(null);
|
||||||
|
const restoredSelectedPackagingNodeNameRef = useRef<string | null>(null);
|
||||||
|
const cameraStatesRef = useRef<
|
||||||
|
Partial<Record<OpenClaw3DViewMode, OpenClawModelCameraState>>
|
||||||
|
>({});
|
||||||
|
|
||||||
const closeViewer = useCallback(() => {
|
const closeViewer = useCallback(() => {
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
setSelectedPart(null);
|
setSelectedPart(null);
|
||||||
|
setSelectedPackagingPart(null);
|
||||||
setMatchState({ matched: 0, total: 0 });
|
setMatchState({ matched: 0, total: 0 });
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const storage = getViewerSessionStorage();
|
const storage = getViewerSessionStorage();
|
||||||
@ -156,7 +524,7 @@ export function OpenClawModel3DPreview({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasRestoredSessionRef.current || !model) return;
|
if (hasRestoredSessionRef.current || !primaryModel) return;
|
||||||
hasRestoredSessionRef.current = true;
|
hasRestoredSessionRef.current = true;
|
||||||
const storage = getViewerSessionStorage();
|
const storage = getViewerSessionStorage();
|
||||||
if (!storage) return;
|
if (!storage) return;
|
||||||
@ -175,20 +543,36 @@ export function OpenClawModel3DPreview({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoredViewMode =
|
||||||
|
session.viewMode === "packaging" && packagingModel
|
||||||
|
? "packaging"
|
||||||
|
: productModel
|
||||||
|
? "product"
|
||||||
|
: "packaging";
|
||||||
restoredSelectedNodeNameRef.current = session.selectedNodeName;
|
restoredSelectedNodeNameRef.current = session.selectedNodeName;
|
||||||
|
restoredSelectedPackagingNodeNameRef.current =
|
||||||
|
session.selectedPackagingNodeName;
|
||||||
|
setViewMode(restoredViewMode);
|
||||||
setExplodeAmount(session.explodeAmount);
|
setExplodeAmount(session.explodeAmount);
|
||||||
|
setPackagingExplodeAmount(session.packagingExplodeAmount);
|
||||||
setTransformRequest((current) => ({
|
setTransformRequest((current) => ({
|
||||||
actionId: null,
|
actionId: null,
|
||||||
amount: session.explodeAmount,
|
amount:
|
||||||
|
restoredViewMode === "packaging"
|
||||||
|
? session.packagingExplodeAmount
|
||||||
|
: session.explodeAmount,
|
||||||
animate: false,
|
animate: false,
|
||||||
durationMs: model.viewer?.transitionDurationMs || 700,
|
durationMs:
|
||||||
|
(restoredViewMode === "packaging"
|
||||||
|
? packagingModel?.viewer?.transitionDurationMs
|
||||||
|
: productModel?.viewer?.transitionDurationMs) || 700,
|
||||||
nonce: current.nonce + 1,
|
nonce: current.nonce + 1,
|
||||||
}));
|
}));
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId })
|
new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId })
|
||||||
);
|
);
|
||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
}, [chatId, messageId, model, viewerId]);
|
}, [chatId, messageId, packagingModel, primaryModel, productModel, viewerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) return;
|
if (!isActive) return;
|
||||||
@ -198,10 +582,22 @@ export function OpenClawModel3DPreview({
|
|||||||
storage,
|
storage,
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
|
viewMode,
|
||||||
explodeAmount,
|
explodeAmount,
|
||||||
|
packagingExplodeAmount,
|
||||||
selectedNodeName: selectedPart?.nodeName || null,
|
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(() => {
|
useEffect(() => {
|
||||||
const nodeName = restoredSelectedNodeNameRef.current;
|
const nodeName = restoredSelectedNodeNameRef.current;
|
||||||
@ -212,6 +608,17 @@ export function OpenClawModel3DPreview({
|
|||||||
);
|
);
|
||||||
}, [manifest]);
|
}, [manifest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nodeName = restoredSelectedPackagingNodeNameRef.current;
|
||||||
|
if (!manifest || !nodeName) return;
|
||||||
|
restoredSelectedPackagingNodeNameRef.current = null;
|
||||||
|
setSelectedPackagingPart(
|
||||||
|
getOpenClawPackagingInteractionParts(manifest).find(
|
||||||
|
(part) => part.nodeName === nodeName
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}, [manifest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOtherViewerActivated = (event: Event) => {
|
const handleOtherViewerActivated = (event: Event) => {
|
||||||
const activeId = (event as CustomEvent<string>).detail;
|
const activeId = (event as CustomEvent<string>).detail;
|
||||||
@ -268,56 +675,106 @@ export function OpenClawModel3DPreview({
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [bundle, chatId, isActive, manifest, messageId]);
|
}, [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({
|
const modelUrl = buildOpenClawArtifactProxyUrl({
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
artifactIndex: bundle.modelIndex,
|
artifactIndex: activeModelIndex,
|
||||||
});
|
});
|
||||||
const downloadUrl = buildOpenClawArtifactProxyUrl({
|
const downloadUrl = buildOpenClawArtifactProxyUrl({
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
artifactIndex: bundle.modelIndex,
|
artifactIndex: activeModelIndex,
|
||||||
download: true,
|
download: true,
|
||||||
});
|
});
|
||||||
const previewUrl =
|
const previewUrl =
|
||||||
bundle.previewIndex >= 0
|
activePreviewIndex >= 0
|
||||||
? buildOpenClawArtifactProxyUrl({
|
? buildOpenClawArtifactProxyUrl({
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
artifactIndex: bundle.previewIndex,
|
artifactIndex: activePreviewIndex,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const isAssembledModel =
|
const isAssembledModel =
|
||||||
model.artifactRole.toLowerCase() === "assembled_3d" ||
|
model.artifactRole.toLowerCase() === "assembled_3d" ||
|
||||||
model.viewer?.variant === "assembled";
|
model.viewer?.variant === "assembled";
|
||||||
const sourceVariant = getOpenClaw3DArtifactVariant(model);
|
const artifactVariant = getOpenClaw3DArtifactVariant(model);
|
||||||
|
const sourceVariant = artifactVariant === "packaging" ? "exploded" : artifactVariant;
|
||||||
const supportsNodeInteraction = Boolean(
|
const supportsNodeInteraction = Boolean(
|
||||||
manifest && (isAssembledModel || sourceVariant === "exploded")
|
manifest &&
|
||||||
|
interactionParts.length > 0 &&
|
||||||
|
(isPackagingView || isAssembledModel || sourceVariant === "exploded")
|
||||||
);
|
);
|
||||||
const displayedModelName =
|
const displayedModelName =
|
||||||
supportsNodeInteraction && sourceVariant === "exploded"
|
isPackagingView
|
||||||
|
? model.displayName || "3D包装预览"
|
||||||
|
: supportsNodeInteraction && sourceVariant === "exploded"
|
||||||
? "可旋转3D整装成品模型"
|
? "可旋转3D整装成品模型"
|
||||||
: model.displayName || model.filename || "3D 模型";
|
: model.displayName || model.filename || "3D 模型";
|
||||||
const canDragParts = Boolean(
|
const canDragParts = Boolean(
|
||||||
supportsNodeInteraction &&
|
!isPackagingView &&
|
||||||
|
supportsNodeInteraction &&
|
||||||
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable))
|
(model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable))
|
||||||
);
|
);
|
||||||
const canExplode = Boolean(
|
const canExplode = Boolean(
|
||||||
supportsNodeInteraction &&
|
supportsNodeInteraction &&
|
||||||
(model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
|
(isPackagingView
|
||||||
|
? manifest?.capabilities.oneClickPackagingExplode
|
||||||
|
: model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode)
|
||||||
);
|
);
|
||||||
const canAssemble = Boolean(
|
const canAssemble = Boolean(
|
||||||
supportsNodeInteraction &&
|
supportsNodeInteraction &&
|
||||||
(model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
|
(isPackagingView
|
||||||
|
? manifest?.capabilities.oneClickPackagingPack
|
||||||
|
: model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble)
|
||||||
);
|
);
|
||||||
const showExplodeSlider = Boolean(
|
const showExplodeSlider = Boolean(
|
||||||
supportsNodeInteraction &&
|
!isPackagingView &&
|
||||||
|
supportsNodeInteraction &&
|
||||||
(model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider)
|
(model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider)
|
||||||
);
|
);
|
||||||
const canResetParts = Boolean(
|
const canResetParts = Boolean(
|
||||||
supportsNodeInteraction &&
|
!isPackagingView &&
|
||||||
|
supportsNodeInteraction &&
|
||||||
(model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable))
|
(model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable))
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -336,21 +793,53 @@ export function OpenClawModel3DPreview({
|
|||||||
actionId: string | null = null
|
actionId: string | null = null
|
||||||
) => {
|
) => {
|
||||||
const normalized = Math.max(0, Math.min(1, amount));
|
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) => ({
|
setTransformRequest((current) => ({
|
||||||
actionId,
|
actionId,
|
||||||
amount: normalized,
|
amount: normalized,
|
||||||
animate,
|
animate,
|
||||||
durationMs: model.viewer?.transitionDurationMs || 700,
|
durationMs:
|
||||||
|
action?.durationMs || model.viewer?.transitionDurationMs || 700,
|
||||||
nonce: current.nonce + 1,
|
nonce: current.nonce + 1,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
const selectedDimensions = selectedPart
|
const switchViewMode = (nextMode: OpenClaw3DViewMode) => {
|
||||||
? formatDimensions(selectedPart)
|
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;
|
: 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 (
|
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">
|
<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">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Box className="h-4 w-4 shrink-0 text-[#687386] dark:text-zinc-400" />
|
<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"
|
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视角"
|
aria-label="复位3D视角"
|
||||||
title="复位3D视角"
|
title="复位3D视角"
|
||||||
onClick={() => setResetViewToken((value) => value + 1)}
|
onClick={() => {
|
||||||
|
delete cameraStatesRef.current[viewMode];
|
||||||
|
setResetViewToken((value) => value + 1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -396,6 +888,37 @@ export function OpenClawModel3DPreview({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
<div
|
||||||
className="relative h-[clamp(300px,44vw,460px)] w-full overflow-hidden"
|
className="relative h-[clamp(300px,44vw,460px)] w-full overflow-hidden"
|
||||||
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
|
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
|
||||||
@ -408,23 +931,35 @@ export function OpenClawModel3DPreview({
|
|||||||
<OpenClawInteractiveModelViewer
|
<OpenClawInteractiveModelViewer
|
||||||
allowDragging={canDragParts}
|
allowDragging={canDragParts}
|
||||||
background={model.viewer?.background || "#f7f9fc"}
|
background={model.viewer?.background || "#f7f9fc"}
|
||||||
|
fitExplodedSource={isPackagingView}
|
||||||
|
initialCameraState={cameraStatesRef.current[viewMode] || null}
|
||||||
|
interactionParts={interactionParts}
|
||||||
manifest={manifest}
|
manifest={manifest}
|
||||||
modelUrl={modelUrl}
|
modelUrl={modelUrl}
|
||||||
|
onCameraStateChange={(state) => {
|
||||||
|
cameraStatesRef.current[viewMode] = state;
|
||||||
|
}}
|
||||||
onMatchChange={handleMatchChange}
|
onMatchChange={handleMatchChange}
|
||||||
onSelectedPartChange={setSelectedPart}
|
onSelectedPartChange={(part) => {
|
||||||
|
if (isPackagingView) {
|
||||||
|
setSelectedPackagingPart(part);
|
||||||
|
} else {
|
||||||
|
setSelectedPart(part);
|
||||||
|
}
|
||||||
|
}}
|
||||||
previewImageUrl={previewUrl}
|
previewImageUrl={previewUrl}
|
||||||
resetAllToken={resetAllToken}
|
resetAllToken={resetAllToken}
|
||||||
resetSelectedToken={resetSelectedToken}
|
resetSelectedToken={resetSelectedToken}
|
||||||
resetViewToken={resetViewToken}
|
resetViewToken={resetViewToken}
|
||||||
selectedNodeName={selectedPart?.nodeName || null}
|
selectedNodeName={selectedInteractionPart?.nodeName || null}
|
||||||
sourceVariant={sourceVariant}
|
sourceVariant={sourceVariant}
|
||||||
transformRequest={transformRequest}
|
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">
|
<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">
|
<p className="truncate text-xs font-medium">
|
||||||
{selectedPart.partId} · {selectedPart.partName}
|
{selectedInteractionPart.partId} · {selectedInteractionPart.partName}
|
||||||
</p>
|
</p>
|
||||||
{selectedDimensions ? (
|
{selectedDimensions ? (
|
||||||
<p className="mt-0.5 text-[11px] text-white/65">
|
<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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{canExplode || canAssemble ? (
|
{canExplode || canAssemble ? (
|
||||||
<div
|
<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]"
|
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"
|
role="group"
|
||||||
>
|
>
|
||||||
{canAssemble ? (
|
{canAssemble ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={explodeAmount < 0.5}
|
aria-pressed={activeExplodeAmount < 0.5}
|
||||||
className={
|
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] 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"
|
: "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(
|
updateExplodeAmount(
|
||||||
0,
|
0,
|
||||||
true,
|
true,
|
||||||
model.viewer?.assembleActionId || "assemble_all"
|
isPackagingView
|
||||||
|
? "packaging_pack"
|
||||||
|
: model.viewer?.assembleActionId || "assemble_all"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Minimize2 className="h-3.5 w-3.5" />
|
<Minimize2 className="h-3.5 w-3.5" />
|
||||||
组合状态
|
{isPackagingView ? "合上包装" : "组合状态"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{canExplode ? (
|
{canExplode ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={explodeAmount >= 0.5}
|
aria-pressed={activeExplodeAmount >= 0.5}
|
||||||
className={
|
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] 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"
|
: "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(
|
updateExplodeAmount(
|
||||||
1,
|
1,
|
||||||
true,
|
true,
|
||||||
model.viewer?.explodeActionId || "explode_all"
|
isPackagingView
|
||||||
|
? "packaging_explode"
|
||||||
|
: model.viewer?.explodeActionId || "explode_all"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-3.5 w-3.5" />
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
爆炸状态
|
{isPackagingView ? "展开包装" : "爆炸状态"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedPart && canResetParts ? (
|
{selectedInteractionPart && canResetParts ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
) : null}
|
||||||
{matchState.total > 0 && matchState.matched < matchState.total ? (
|
{matchState.total > 0 && matchState.matched < matchState.total ? (
|
||||||
<span className="text-[11px] text-amber-700 dark:text-amber-300">
|
<span className="text-[11px] text-amber-700 dark:text-amber-300">
|
||||||
已识别 {matchState.matched}/{matchState.total} 个配件
|
已识别 {matchState.matched}/{matchState.total} 个
|
||||||
|
{isPackagingView ? "包装层" : "配件"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -521,14 +1061,14 @@ export function OpenClawModel3DPreview({
|
|||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
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]"
|
className="h-1.5 min-w-0 flex-1 accent-[#202631] dark:accent-[#3ecf8e]"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateExplodeAmount(Number(event.target.value) / 100, false)
|
updateExplodeAmount(Number(event.target.value) / 100, false)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right tabular-nums">
|
<span className="w-8 text-right tabular-nums">
|
||||||
{Math.round(explodeAmount * 100)}%
|
{Math.round(activeExplodeAmount * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
@ -574,6 +1114,46 @@ export function OpenClawModel3DPreview({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 ? (
|
{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">
|
<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
|
{webGlUnavailable
|
||||||
@ -581,6 +1161,12 @@ export function OpenClawModel3DPreview({
|
|||||||
: `${manifestError},已切换为基础3D预览。`}
|
: `${manifestError},已切换为基础3D预览。`}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
<OpenClawSupportingArtifacts
|
||||||
|
artifacts={artifacts}
|
||||||
|
chatId={chatId}
|
||||||
|
messageId={messageId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export type OpenClawArtifactViewer = {
|
export type OpenClawArtifactViewer = {
|
||||||
type: "model_3d";
|
type: "model_3d";
|
||||||
format: "glb" | "gltf";
|
format: "glb" | "gltf";
|
||||||
variant: "assembled" | "exploded" | null;
|
variant: "assembled" | "exploded" | "packaging" | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
cameraControls: boolean;
|
cameraControls: boolean;
|
||||||
autoRotate: boolean;
|
autoRotate: boolean;
|
||||||
@ -124,7 +124,11 @@ function normalizeViewer(
|
|||||||
value: raw.format,
|
value: raw.format,
|
||||||
}),
|
}),
|
||||||
variant:
|
variant:
|
||||||
variant === "assembled" || variant === "exploded" ? variant : null,
|
variant === "assembled" ||
|
||||||
|
variant === "exploded" ||
|
||||||
|
variant === "packaging"
|
||||||
|
? variant
|
||||||
|
: null,
|
||||||
isDefault: readBoolean(raw.default ?? raw.isDefault, false),
|
isDefault: readBoolean(raw.default ?? raw.isDefault, false),
|
||||||
cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true),
|
cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true),
|
||||||
autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false),
|
autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false),
|
||||||
@ -259,6 +263,7 @@ export function isOpenClawInteractive3DArtifact(
|
|||||||
return (
|
return (
|
||||||
role === "assembled_3d" ||
|
role === "assembled_3d" ||
|
||||||
role === "interactive_3d" ||
|
role === "interactive_3d" ||
|
||||||
|
role === "packaging_3d" ||
|
||||||
mimeType === "model/gltf-binary" ||
|
mimeType === "model/gltf-binary" ||
|
||||||
mimeType === "model/gltf+json" ||
|
mimeType === "model/gltf+json" ||
|
||||||
filename.endsWith(".glb") ||
|
filename.endsWith(".glb") ||
|
||||||
@ -270,16 +275,21 @@ export type OpenClaw3DArtifactBundle = {
|
|||||||
modelIndex: number;
|
modelIndex: number;
|
||||||
model: OpenClawArtifact;
|
model: OpenClawArtifact;
|
||||||
alternateModelIndex: number;
|
alternateModelIndex: number;
|
||||||
|
packagingModelIndex: number;
|
||||||
manifestIndex: number;
|
manifestIndex: number;
|
||||||
previewIndex: number;
|
previewIndex: number;
|
||||||
|
packagingPreviewIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getOpenClaw3DArtifactVariant(
|
export function getOpenClaw3DArtifactVariant(
|
||||||
artifact: OpenClawArtifact
|
artifact: OpenClawArtifact
|
||||||
): "assembled" | "exploded" {
|
): "assembled" | "exploded" | "packaging" {
|
||||||
if (artifact.viewer?.variant) return artifact.viewer.variant;
|
if (artifact.viewer?.variant) return artifact.viewer.variant;
|
||||||
const role = artifact.artifactRole.toLowerCase();
|
const role = artifact.artifactRole.toLowerCase();
|
||||||
const filename = artifact.filename.toLowerCase();
|
const filename = artifact.filename.toLowerCase();
|
||||||
|
if (role === "packaging_3d" || filename.includes("packaging")) {
|
||||||
|
return "packaging";
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
role === "interactive_3d" ||
|
role === "interactive_3d" ||
|
||||||
filename.includes("exploded") ||
|
filename.includes("exploded") ||
|
||||||
@ -300,12 +310,20 @@ export function getOpenClaw3DArtifactBundle(
|
|||||||
.filter((index) => index >= 0);
|
.filter((index) => index >= 0);
|
||||||
if (modelIndexes.length === 0) return null;
|
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 =
|
const modelIndex =
|
||||||
modelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ??
|
productModelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ??
|
||||||
modelIndexes.find(
|
productModelIndexes.find(
|
||||||
(index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d"
|
(index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d"
|
||||||
) ??
|
) ??
|
||||||
modelIndexes[0];
|
productModelIndexes[0] ??
|
||||||
|
packagingModelIndex;
|
||||||
const model = artifacts[modelIndex];
|
const model = artifacts[modelIndex];
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
|
||||||
@ -317,7 +335,7 @@ export function getOpenClaw3DArtifactBundle(
|
|||||||
artifact.artifactRole.toLowerCase() === alternateRole &&
|
artifact.artifactRole.toLowerCase() === alternateRole &&
|
||||||
isOpenClawInteractive3DArtifact(artifact)
|
isOpenClawInteractive3DArtifact(artifact)
|
||||||
)
|
)
|
||||||
: modelIndexes.find((index) => index !== modelIndex) ?? -1;
|
: productModelIndexes.find((index) => index !== modelIndex) ?? -1;
|
||||||
const manifestRole =
|
const manifestRole =
|
||||||
model.viewer?.interactionManifestArtifactRole?.toLowerCase() ||
|
model.viewer?.interactionManifestArtifactRole?.toLowerCase() ||
|
||||||
"model_interaction_manifest";
|
"model_interaction_manifest";
|
||||||
@ -336,13 +354,26 @@ export function getOpenClaw3DArtifactBundle(
|
|||||||
return role === "assembled_preview" || role === "cad_preview";
|
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 {
|
return {
|
||||||
modelIndex,
|
modelIndex,
|
||||||
model,
|
model,
|
||||||
alternateModelIndex,
|
alternateModelIndex,
|
||||||
|
packagingModelIndex,
|
||||||
manifestIndex,
|
manifestIndex,
|
||||||
previewIndex,
|
previewIndex,
|
||||||
|
packagingPreviewIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,10 +17,32 @@ export type OpenClawModelInteractionPart = {
|
|||||||
draggable: boolean;
|
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 = {
|
export type OpenClawModelInteractionAction = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
toState: "assembled" | "exploded" | "custom";
|
toState: OpenClawModelInteractionState;
|
||||||
|
target: string | null;
|
||||||
|
nodesField: string | null;
|
||||||
offsetField: string | null;
|
offsetField: string | null;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
easing: string;
|
easing: string;
|
||||||
@ -43,7 +65,23 @@ export type OpenClawModelInteractionManifest = {
|
|||||||
oneClickAssemble: boolean;
|
oneClickAssemble: boolean;
|
||||||
animatedTransition: boolean;
|
animatedTransition: boolean;
|
||||||
explodeSlider: 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;
|
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(
|
function normalizeAction(
|
||||||
value: unknown
|
value: unknown
|
||||||
): OpenClawModelInteractionAction | null {
|
): OpenClawModelInteractionAction | null {
|
||||||
@ -133,13 +227,19 @@ function normalizeAction(
|
|||||||
const id = readText(raw.id, 160);
|
const id = readText(raw.id, 160);
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const toState = readText(raw.to_state ?? raw.toState, 32).toLowerCase();
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
label: readText(raw.label, 160) || id,
|
label: readText(raw.label, 160) || id,
|
||||||
toState:
|
toState: normalizedToState,
|
||||||
toState === "exploded" || toState === "custom"
|
target: readText(raw.target, 160) || null,
|
||||||
? toState
|
nodesField: readText(raw.nodes_field ?? raw.nodesField, 240) || null,
|
||||||
: "assembled",
|
|
||||||
offsetField: readText(raw.offset_field ?? raw.offsetField, 160) || null,
|
offsetField: readText(raw.offset_field ?? raw.offsetField, 160) || null,
|
||||||
durationMs: readFiniteNumber(
|
durationMs: readFiniteNumber(
|
||||||
raw.duration_ms ?? raw.durationMs,
|
raw.duration_ms ?? raw.durationMs,
|
||||||
@ -168,6 +268,8 @@ function defaultActions(): OpenClawModelInteractionAction[] {
|
|||||||
durationMs: 700,
|
durationMs: 700,
|
||||||
easing: "easeInOutCubic",
|
easing: "easeInOutCubic",
|
||||||
interruptible: true,
|
interruptible: true,
|
||||||
|
target: "all_parts",
|
||||||
|
nodesField: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "assemble_all",
|
id: "assemble_all",
|
||||||
@ -177,6 +279,8 @@ function defaultActions(): OpenClawModelInteractionAction[] {
|
|||||||
durationMs: 700,
|
durationMs: 700,
|
||||||
easing: "easeInOutCubic",
|
easing: "easeInOutCubic",
|
||||||
interruptible: true,
|
interruptible: true,
|
||||||
|
target: "all_parts",
|
||||||
|
nodesField: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -196,8 +300,6 @@ export function normalizeOpenClawModelInteractionManifest(
|
|||||||
seenNodeNames.add(part.nodeName);
|
seenNodeNames.add(part.nodeName);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
if (parts.length === 0) return null;
|
|
||||||
|
|
||||||
const parsedActions = (Array.isArray(raw.actions) ? raw.actions : [])
|
const parsedActions = (Array.isArray(raw.actions) ? raw.actions : [])
|
||||||
.slice(0, MAX_ACTIONS)
|
.slice(0, MAX_ACTIONS)
|
||||||
.map(normalizeAction)
|
.map(normalizeAction)
|
||||||
@ -212,6 +314,23 @@ export function normalizeOpenClawModelInteractionManifest(
|
|||||||
const coordinates = readRecord(raw.coordinates);
|
const coordinates = readRecord(raw.coordinates);
|
||||||
const drag = readRecord(raw.drag);
|
const drag = readRecord(raw.drag);
|
||||||
const capabilities = readRecord(raw.capabilities);
|
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(
|
const axes = (Array.isArray(drag.axes) ? drag.axes : ["x", "y", "z"]).filter(
|
||||||
(axis): axis is "x" | "y" | "z" =>
|
(axis): axis is "x" | "y" | "z" =>
|
||||||
axis === "x" || axis === "y" || axis === "z"
|
axis === "x" || axis === "y" || axis === "z"
|
||||||
@ -261,10 +380,89 @@ export function normalizeOpenClawModelInteractionManifest(
|
|||||||
capabilities.explode_slider ?? capabilities.explodeSlider,
|
capabilities.explode_slider ?? capabilities.explodeSlider,
|
||||||
supportsTransforms
|
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(
|
export function getOpenClawExplodedOffsetMm(
|
||||||
part: OpenClawModelInteractionPart
|
part: OpenClawModelInteractionPart
|
||||||
): OpenClawModelVector | null {
|
): 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.step_state || ""),
|
||||||
String(progress.message || progress.display || ""),
|
String(progress.message || progress.display || ""),
|
||||||
].join("|");
|
].join("|");
|
||||||
const next = current.some((item) => {
|
const existingIndex = current.findIndex((item) => {
|
||||||
const itemEventId = String(item.event_id || "").trim();
|
const itemEventId = String(item.event_id || "").trim();
|
||||||
const itemSequence = String(item.sequence || "").trim();
|
const itemSequence = String(item.sequence || "").trim();
|
||||||
const itemKey = itemEventId
|
const itemKey = itemEventId
|
||||||
@ -132,9 +132,13 @@ export function appendClawdTaskProgress(
|
|||||||
String(item.message || item.display || ""),
|
String(item.message || item.display || ""),
|
||||||
].join("|");
|
].join("|");
|
||||||
return itemKey === key;
|
return itemKey === key;
|
||||||
})
|
});
|
||||||
? current
|
const next =
|
||||||
: [...current, progress];
|
existingIndex >= 0
|
||||||
|
? current.map((item, index) =>
|
||||||
|
index === existingIndex ? { ...item, ...progress } : item
|
||||||
|
)
|
||||||
|
: [...current, progress];
|
||||||
|
|
||||||
return next.slice(-Math.max(1, maxItems));
|
return next.slice(-Math.max(1, maxItems));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import {
|
|||||||
} from "@/lib/openclaw-chat/artifacts";
|
} from "@/lib/openclaw-chat/artifacts";
|
||||||
import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
||||||
import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
|
import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
|
||||||
|
import {
|
||||||
|
getOpenClawTaskAnswer,
|
||||||
|
getOpenClawTaskStatus,
|
||||||
|
isOpenClawAwaitingUserInputTurnComplete,
|
||||||
|
} from "@/lib/openclaw-chat/task-protocol";
|
||||||
|
|
||||||
type OpenClawUpstreamConfig = {
|
type OpenClawUpstreamConfig = {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@ -77,10 +82,6 @@ function readEventId(value: unknown) {
|
|||||||
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0;
|
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) {
|
function getTaskEventsEndpoint(payload: unknown, taskId: string) {
|
||||||
const task = readRecord(readRecord(payload).task);
|
const task = readRecord(readRecord(payload).task);
|
||||||
return (
|
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) {
|
function getRequestId(payload: unknown) {
|
||||||
const root = readRecord(payload);
|
const root = readRecord(payload);
|
||||||
const output = readRecord(root.output);
|
const output = readRecord(root.output);
|
||||||
@ -239,6 +231,7 @@ function normalizeProgressEvent(
|
|||||||
"任务状态已更新";
|
"任务状态已更新";
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
|
heartbeat: data.heartbeat === true || raw.heartbeat === true,
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
event_type: eventType,
|
event_type: eventType,
|
||||||
@ -248,6 +241,7 @@ function normalizeProgressEvent(
|
|||||||
step_state: readText(data.step_state) || readText(raw.status) || "started",
|
step_state: readText(data.step_state) || readText(raw.status) || "started",
|
||||||
message,
|
message,
|
||||||
display: message,
|
display: message,
|
||||||
|
display_text: message,
|
||||||
created_at: raw.created_at,
|
created_at: raw.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -423,8 +417,14 @@ export async function refreshOpenClawDurableTask(params: {
|
|||||||
upstreamTask,
|
upstreamTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = getTaskStatus(eventsPayload);
|
const status = getOpenClawTaskStatus(eventsPayload);
|
||||||
if (!TERMINAL_STATUSES.has(status)) {
|
const eventTurnAwaitingInput =
|
||||||
|
isOpenClawAwaitingUserInputTurnComplete(eventsPayload);
|
||||||
|
if (
|
||||||
|
!TERMINAL_STATUSES.has(status) &&
|
||||||
|
!eventTurnAwaitingInput &&
|
||||||
|
status !== "waiting"
|
||||||
|
) {
|
||||||
return { status: "pending", upstreamTask };
|
return { status: "pending", upstreamTask };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -435,9 +435,16 @@ export async function refreshOpenClawDurableTask(params: {
|
|||||||
signal: params.signal,
|
signal: params.signal,
|
||||||
});
|
});
|
||||||
upstreamTask = mergeUpstreamTaskState(upstreamTask, terminalPayload);
|
upstreamTask = mergeUpstreamTaskState(upstreamTask, terminalPayload);
|
||||||
const terminalStatus = getTaskStatus(terminalPayload) || status;
|
const terminalStatus = getOpenClawTaskStatus(terminalPayload) || status;
|
||||||
if (terminalStatus === "succeeded" || terminalStatus === "completed") {
|
const turnAwaitingInput =
|
||||||
const answer = getAnswer(terminalPayload);
|
eventTurnAwaitingInput ||
|
||||||
|
isOpenClawAwaitingUserInputTurnComplete(terminalPayload);
|
||||||
|
if (
|
||||||
|
terminalStatus === "succeeded" ||
|
||||||
|
terminalStatus === "completed" ||
|
||||||
|
turnAwaitingInput
|
||||||
|
) {
|
||||||
|
const answer = getOpenClawTaskAnswer(terminalPayload);
|
||||||
if (!answer) {
|
if (!answer) {
|
||||||
return {
|
return {
|
||||||
status: "failed",
|
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") {
|
if (terminalStatus === "cancelled" || terminalStatus === "canceled") {
|
||||||
return { status: "canceled", upstreamTask, error: "任务已停止" };
|
return { status: "canceled", upstreamTask, error: "任务已停止" };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,11 @@ const OPENCLAW_3D_VIEWER_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|||||||
|
|
||||||
export type OpenClaw3DViewerSession = {
|
export type OpenClaw3DViewerSession = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
viewMode: "product" | "packaging";
|
||||||
explodeAmount: number;
|
explodeAmount: number;
|
||||||
|
packagingExplodeAmount: number;
|
||||||
selectedNodeName: string | null;
|
selectedNodeName: string | null;
|
||||||
|
selectedPackagingNodeName: string | null;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -53,12 +56,24 @@ export function readOpenClaw3DViewerSession(params: {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
messageId,
|
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:
|
selectedNodeName:
|
||||||
typeof value.selectedNodeName === "string" &&
|
typeof value.selectedNodeName === "string" &&
|
||||||
value.selectedNodeName.trim()
|
value.selectedNodeName.trim()
|
||||||
? value.selectedNodeName.trim()
|
? value.selectedNodeName.trim()
|
||||||
: null,
|
: null,
|
||||||
|
selectedPackagingNodeName:
|
||||||
|
typeof value.selectedPackagingNodeName === "string" &&
|
||||||
|
value.selectedPackagingNodeName.trim()
|
||||||
|
? value.selectedPackagingNodeName.trim()
|
||||||
|
: null,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@ -72,7 +87,10 @@ export function writeOpenClaw3DViewerSession(params: {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
explodeAmount: number;
|
explodeAmount: number;
|
||||||
|
viewMode?: "product" | "packaging";
|
||||||
|
packagingExplodeAmount?: number;
|
||||||
selectedNodeName?: string | null;
|
selectedNodeName?: string | null;
|
||||||
|
selectedPackagingNodeName?: string | null;
|
||||||
now?: number;
|
now?: number;
|
||||||
}) {
|
}) {
|
||||||
const messageId = params.messageId.trim();
|
const messageId = params.messageId.trim();
|
||||||
@ -82,8 +100,15 @@ export function writeOpenClaw3DViewerSession(params: {
|
|||||||
getViewerSessionKey(params.chatId),
|
getViewerSessionKey(params.chatId),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
messageId,
|
messageId,
|
||||||
|
viewMode: params.viewMode || "product",
|
||||||
explodeAmount: normalizeExplodeAmount(params.explodeAmount),
|
explodeAmount: normalizeExplodeAmount(params.explodeAmount),
|
||||||
|
packagingExplodeAmount:
|
||||||
|
params.packagingExplodeAmount === undefined
|
||||||
|
? 1
|
||||||
|
: normalizeExplodeAmount(params.packagingExplodeAmount),
|
||||||
selectedNodeName: params.selectedNodeName?.trim() || null,
|
selectedNodeName: params.selectedNodeName?.trim() || null,
|
||||||
|
selectedPackagingNodeName:
|
||||||
|
params.selectedPackagingNodeName?.trim() || null,
|
||||||
updatedAt: params.now ?? Date.now(),
|
updatedAt: params.now ?? Date.now(),
|
||||||
} satisfies OpenClaw3DViewerSession)
|
} satisfies OpenClaw3DViewerSession)
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,10 +9,15 @@ import {
|
|||||||
} from "@/lib/openclaw-chat/artifacts";
|
} from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
getOpenClawExplodedOffsetMm,
|
getOpenClawExplodedOffsetMm,
|
||||||
|
getOpenClawPackagingInteractionParts,
|
||||||
getOpenClawSourceNodeOffset,
|
getOpenClawSourceNodeOffset,
|
||||||
mapOpenClawCadOffsetToGlb,
|
mapOpenClawCadOffsetToGlb,
|
||||||
normalizeOpenClawModelInteractionManifest,
|
normalizeOpenClawModelInteractionManifest,
|
||||||
} from "@/lib/openclaw-chat/model-interaction";
|
} from "@/lib/openclaw-chat/model-interaction";
|
||||||
|
import {
|
||||||
|
isOpenClawProductionDrawingReleased,
|
||||||
|
normalizeOpenClawProductionDrawingManifest,
|
||||||
|
} from "@/lib/openclaw-chat/production-drawing";
|
||||||
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
||||||
import {
|
import {
|
||||||
clearOpenClaw3DViewerSession,
|
clearOpenClaw3DViewerSession,
|
||||||
@ -89,7 +94,56 @@ const manifestFile = {
|
|||||||
download_url: "https://files.example.com/wood-demo-model-interaction.json",
|
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.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", () => {
|
test("normalizes the documented GLB viewer contract", () => {
|
||||||
const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], {
|
const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], {
|
||||||
baseUrl: "https://claw.example.com",
|
baseUrl: "https://claw.example.com",
|
||||||
@ -121,7 +175,9 @@ test.describe("OpenClaw 3D artifacts", () => {
|
|||||||
modelFile,
|
modelFile,
|
||||||
manifestFile,
|
manifestFile,
|
||||||
assembledModelFile,
|
assembledModelFile,
|
||||||
|
packagingModelFile,
|
||||||
{ ...previewFile, artifact_role: "assembled_preview" },
|
{ ...previewFile, artifact_role: "assembled_preview" },
|
||||||
|
{ ...previewFile, artifact_role: "packaging_preview" },
|
||||||
],
|
],
|
||||||
{ baseUrl: "https://claw.example.com" }
|
{ baseUrl: "https://claw.example.com" }
|
||||||
);
|
);
|
||||||
@ -145,7 +201,89 @@ test.describe("OpenClaw 3D artifacts", () => {
|
|||||||
});
|
});
|
||||||
expect(bundle?.manifestIndex).toBe(1);
|
expect(bundle?.manifestIndex).toBe(1);
|
||||||
expect(bundle?.alternateModelIndex).toBe(0);
|
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", () => {
|
test("treats legacy interactive-only GLB as exploded source and can restore assembly", () => {
|
||||||
@ -332,12 +470,43 @@ test.describe("OpenClaw 3D artifacts", () => {
|
|||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
messageId: "message-id",
|
messageId: "message-id",
|
||||||
|
viewMode: "product",
|
||||||
explodeAmount: 1,
|
explodeAmount: 1,
|
||||||
|
packagingExplodeAmount: 1,
|
||||||
selectedNodeName: "DOOR-001:1",
|
selectedNodeName: "DOOR-001:1",
|
||||||
|
selectedPackagingNodeName: null,
|
||||||
updatedAt: 1_000,
|
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", () => {
|
test("only clears the viewer state owned by the matching message", () => {
|
||||||
const storage = createMemoryStorage();
|
const storage = createMemoryStorage();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@ -4,6 +4,11 @@ import {
|
|||||||
isPendingClawdTaskStatus,
|
isPendingClawdTaskStatus,
|
||||||
normalizeClawdTaskMetadata,
|
normalizeClawdTaskMetadata,
|
||||||
} from "@/lib/openclaw-chat/task-state";
|
} from "@/lib/openclaw-chat/task-state";
|
||||||
|
import {
|
||||||
|
getOpenClawTaskAnswer,
|
||||||
|
getOpenClawTaskStatus,
|
||||||
|
isOpenClawAwaitingUserInputTurnComplete,
|
||||||
|
} from "@/lib/openclaw-chat/task-protocol";
|
||||||
|
|
||||||
test.describe("OpenClaw durable task state", () => {
|
test.describe("OpenClaw durable task state", () => {
|
||||||
test("restores a persisted running task after the page remounts", () => {
|
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]);
|
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", () => {
|
test("does not classify completed tasks as pending", () => {
|
||||||
expect(isPendingClawdTaskStatus("completed")).toBe(false);
|
expect(isPendingClawdTaskStatus("completed")).toBe(false);
|
||||||
expect(isPendingClawdTaskStatus("failed")).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