diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index d650daef1..d8908b61c 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -299,7 +299,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { params.spreadsheetMarkdownTruncated || undefined, ...params.metadata, }, - stream_progress: false, + stream_progress: !params.sensitiveInput, sensitive_input: params.sensitiveInput, }; diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 51f7259ee..5335e7695 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -683,15 +683,28 @@ function normalizeClawdComposerText(value: string) { .trim(); } -function buildClawdSkillTagPrompt(tag: Pick | null) { +function buildClawdSkillTagPrompt( + tag: Pick | null +) { if (!tag) return ""; const tagName = String(tag.name || "").trim() || "快捷标签"; const tagPrompt = String(tag.prompt || "").trim(); + const requiresCostRoute = + tag.id === "ai-solid-wood-cost" || + tagName === "产品成本评估" || + tagName === "ai实木家具成本预估"; + const routePrompt = + requiresCostRoute && !tagPrompt.includes("【技能路由:ai-solid-wood-cost】") + ? "【技能路由:ai-solid-wood-cost】" + : ""; return [ `【快捷标签:${tagName}】`, + routePrompt, tagPrompt || `用户选择了「${tagName}」快捷标签,请按该标签对应能力处理。`, - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); } const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6; @@ -734,6 +747,8 @@ type ClawdProgressEvent = { completed?: number; total?: number; progressPercent?: number; + heartbeat: boolean; + activeAt?: number; }; type ClawdDurableTaskStatus = @@ -870,9 +885,8 @@ async function waitForClawdDurableTask(params: { function normalizeClawdProgressEvent( progress: Record ): ClawdProgressEvent | null { - if (progress.heartbeat === true) return null; const message = String( - progress.message || progress.display || "" + progress.display_text || progress.message || progress.display || "" ).trim(); if (!message) return null; const eventId = Number(progress.event_id); @@ -883,6 +897,9 @@ function normalizeClawdProgressEvent( const completed = Number(progress.completed); const total = Number(progress.total); const progressPercent = Number(progress.progress_percent); + const activeAt = Date.parse( + String(progress.created_at || progress.updated_at || "") + ); const normalizedEventId = Number.isFinite(eventId) && eventId > 0 ? eventId : undefined; @@ -914,9 +931,31 @@ function normalizeClawdProgressEvent( progressPercent <= 100 ? Math.round(progressPercent) : undefined, + heartbeat: progress.heartbeat === true, + activeAt: Number.isFinite(activeAt) ? activeAt : undefined, }; } +function getClawdProgressMeta(progress: ClawdProgressEvent) { + const items: string[] = []; + if (progress.currentOrderId) items.push(`订单 ${progress.currentOrderId}`); + if (progress.total) { + items.push( + `${progress.completed || 0}/${progress.total}${ + progress.progressPercent != null ? ` · ${progress.progressPercent}%` : "" + }` + ); + } + if (progress.heartbeat && progress.activeAt) { + items.push( + `最近活跃 ${new Date(progress.activeAt).toLocaleTimeString("zh-CN", { + hour12: false, + })}` + ); + } + return items.join(" · "); +} + function isClawdSubmittedComposerText( snapshot: ClawdComposerSubmissionSnapshot, value: string @@ -2003,6 +2042,10 @@ function normalizeOpenClawActionText(value: unknown, maxLength: number) { .slice(0, maxLength); } +function normalizeOpenClawActionMessage(value: unknown, maxLength = 2000) { + return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; +} + function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | null { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return null; @@ -2025,7 +2068,7 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n } const action = item as Record; const label = normalizeOpenClawActionText(action.label, 24); - const message = normalizeOpenClawActionText(action.message, 300); + const message = normalizeOpenClawActionMessage(action.message); if (!label || !message) return null; const intent = normalizeOpenClawActionText(action.intent, 80); @@ -2039,7 +2082,6 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n }; }) .filter((item): item is OpenClawQuickAction => Boolean(item)) - .slice(0, 10) : []; if (actions.length === 0) return null; @@ -2586,9 +2628,17 @@ function CanvasAssistantTypingPlaceholder({ const isFailed = progressEvent.stepState === "failed"; const isActive = isLatest && - ["accepted", "queued", "started", "waiting"].includes( + [ + "accepted", + "queued", + "started", + "waiting", + "processing", + "running", + ].includes( progressEvent.stepState ); + const progressMeta = getClawdProgressMeta(progressEvent); return (
{progressEvent.message}
- {progressEvent.currentOrderId || progressEvent.total ? ( + {progressMeta ? (
- {progressEvent.currentOrderId - ? `订单 ${progressEvent.currentOrderId}` - : ""} - {progressEvent.currentOrderId && progressEvent.total - ? " · " - : ""} - {progressEvent.total - ? `${progressEvent.completed || 0}/${progressEvent.total}${ - progressEvent.progressPercent != null - ? ` · ${progressEvent.progressPercent}%` - : "" - }` - : ""} + {progressMeta}
) : null}
@@ -13139,7 +13177,7 @@ export function CanvasChatPanel({ const handleOpenClawQuickAction = useCallback( (message: string) => { - const quickMessage = normalizeOpenClawActionText(message, 300); + const quickMessage = normalizeOpenClawActionMessage(message); if (!quickMessage) { toast.error("快捷标签内容为空"); return; @@ -15024,6 +15062,18 @@ export function CanvasChatPanel({ return acc; }, []); }, [visibleMessages]); + const latestClawAssistantMessageId = useMemo(() => { + for (let index = renderableVisibleMessages.length - 1; index >= 0; index -= 1) { + const message = renderableVisibleMessages[index]; + if ( + message?.role === "assistant" && + (isClawdMode || isClawdChatMessage(message)) + ) { + return message.id; + } + } + return null; + }, [isClawdMode, renderableVisibleMessages]); const isDetailEmptyState = isDetailGenerationMode && @@ -16167,6 +16217,8 @@ export function CanvasChatPanel({ const isClawAssistantMessage = message.role === "assistant" && (isClawdMode || isClawdChatMessage(message)); + const isLatestClawAssistantMessage = + message.id === latestClawAssistantMessageId; const openClawArtifacts = isClawAssistantMessage ? getOpenClawMessageArtifacts(message) : []; @@ -16433,8 +16485,14 @@ export function CanvasChatPanel({ openClawActionsResult.markdown ) : openClawActionsResult.markdown; - const openClawActionBlocks = openClawActionsResult.blocks; - const openClawSecureForms = openClawActionsResult.forms; + const openClawActionBlocks = + isLatestClawAssistantMessage + ? openClawActionsResult.blocks + : []; + const openClawSecureForms = + isLatestClawAssistantMessage + ? openClawActionsResult.forms + : []; const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions); const plainText = normalizeCanvasAgentText(textWithoutActions); const analysisToggleKey = `${message.id}-${i}-analysis`; diff --git a/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx b/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx index a1fcea2ed..1036a1cb9 100644 --- a/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx +++ b/components/canvas/chat-panel/OpenClawInteractiveModelViewer.tsx @@ -26,6 +26,11 @@ export type OpenClawModelTransformRequest = { nonce: number; }; +export type OpenClawModelCameraState = { + position: [number, number, number]; + target: [number, number, number]; +}; + type InteractiveEntry = { node: Object3D; part: OpenClawModelInteractionPart; @@ -144,9 +149,13 @@ function getPartTargetPosition( function ModelInteractionScene({ allowDragging, + fitExplodedSource, + initialCameraState, + interactionParts, manifest, modelUrl, onMatchChange, + onCameraStateChange, onSelectedPartChange, resetAllToken, resetSelectedToken, @@ -155,9 +164,13 @@ function ModelInteractionScene({ transformRequest, }: { allowDragging: boolean; + fitExplodedSource: boolean; + initialCameraState?: OpenClawModelCameraState | null; + interactionParts: OpenClawModelInteractionPart[]; manifest: OpenClawModelInteractionManifest; modelUrl: string; onMatchChange: (matched: number, total: number) => void; + onCameraStateChange?: (state: OpenClawModelCameraState) => void; onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void; resetAllToken: number; resetSelectedToken: number; @@ -172,7 +185,7 @@ function ModelInteractionScene({ const cloned = cloneScene(gltf.scene); if (sourceVariant !== "exploded") return cloned; const partsByNodeName = new Map( - manifest.parts.map((part) => [part.nodeName, part]) + interactionParts.map((part) => [part.nodeName, part]) ); cloned.traverse((node) => { const part = partsByNodeName.get(node.name); @@ -186,8 +199,11 @@ function ModelInteractionScene({ }); cloned.updateMatrixWorld(true); return cloned; - }, [gltf.scene, manifest, sourceVariant]); - const fitTransform = useMemo(() => getFitTransform(scene), [scene]); + }, [gltf.scene, interactionParts, manifest.scaleMmToGlb, sourceVariant]); + const fitTransform = useMemo( + () => getFitTransform(fitExplodedSource ? gltf.scene : scene), + [fitExplodedSource, gltf.scene, scene] + ); const entriesRef = useRef>(new Map()); const objectEntriesRef = useRef>(new Map()); const animationRef = useRef(null); @@ -197,7 +213,7 @@ function ModelInteractionScene({ useEffect(() => { const partsByNodeName = new Map( - manifest.parts.map((part) => [part.nodeName, part]) + interactionParts.map((part) => [part.nodeName, part]) ); const entries = new Map(); const objectEntries = new Map(); @@ -214,14 +230,22 @@ function ModelInteractionScene({ }); entriesRef.current = entries; objectEntriesRef.current = objectEntries; - onMatchChange(entries.size, manifest.parts.length); + onMatchChange(entries.size, interactionParts.length); return () => { entriesRef.current.clear(); objectEntriesRef.current.clear(); - onMatchChange(0, manifest.parts.length); + onMatchChange(0, interactionParts.length); }; - }, [manifest, onMatchChange, scene]); + }, [interactionParts, onMatchChange, scene]); + + useEffect(() => { + if (!initialCameraState || !controlsRef.current) return; + camera.position.set(...initialCameraState.position); + controlsRef.current.target.set(...initialCameraState.target); + camera.updateProjectionMatrix(); + controlsRef.current.update(); + }, [camera, initialCameraState]); useEffect(() => { const previous = selectionHelperRef.current; @@ -469,6 +493,14 @@ function ModelInteractionScene({ maxPolarAngle={Math.PI / 1.65} minDistance={1.2} minPolarAngle={0} + onChange={() => { + const controls = controlsRef.current; + if (!controls || !onCameraStateChange) return; + onCameraStateChange({ + position: camera.position.toArray() as [number, number, number], + target: controls.target.toArray() as [number, number, number], + }); + }} ref={controlsRef} /> @@ -478,9 +510,13 @@ function ModelInteractionScene({ export function OpenClawInteractiveModelViewer({ allowDragging, background, + fitExplodedSource = false, + initialCameraState, + interactionParts, manifest, modelUrl, onMatchChange, + onCameraStateChange, onSelectedPartChange, previewImageUrl, resetAllToken, @@ -492,9 +528,13 @@ export function OpenClawInteractiveModelViewer({ }: { allowDragging: boolean; background: string; + fitExplodedSource?: boolean; + initialCameraState?: OpenClawModelCameraState | null; + interactionParts?: OpenClawModelInteractionPart[]; manifest: OpenClawModelInteractionManifest; modelUrl: string; onMatchChange: (matched: number, total: number) => void; + onCameraStateChange?: (state: OpenClawModelCameraState) => void; onSelectedPartChange: (part: OpenClawModelInteractionPart | null) => void; previewImageUrl?: string | null; resetAllToken: number; @@ -527,16 +567,20 @@ export function OpenClawInteractiveModelViewer({ camera={{ fov: 42, position: [2.4, 1.8, 2.4] }} dpr={[1, 1.75]} gl={{ antialias: true, powerPreference: "high-performance" }} - key={resetViewToken} + key={`${modelUrl}:${resetViewToken}`} shadows style={{ background }} > @@ -60,6 +73,7 @@ const OpenClawInteractiveModelViewer = dynamic( ); const ACTIVE_VIEWER_EVENT = "openclaw-active-3d-viewer"; +type OpenClaw3DViewMode = "product" | "packaging"; function ViewerLoading() { return ( @@ -96,6 +110,338 @@ function formatDimensions(part: OpenClawModelInteractionPart) { return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`; } +function formatArtifactDimensions(artifact: OpenClawArtifact) { + const dimensions = artifact.dimensionsMm; + if (!dimensions) return null; + return `${dimensions.x} x ${dimensions.y} x ${dimensions.z} mm`; +} + +function OpenClawProductionDrawingPanel({ + artifacts, + chatId, + messageId, +}: { + artifacts: Array<{ artifact: OpenClawArtifact; index: number }>; + chatId: string; + messageId: string; +}) { + const manifestArtifact = artifacts.find( + ({ artifact }) => + artifact.artifactRole.toLowerCase() === "production_drawing_manifest" + ); + const pdfs = artifacts.filter( + ({ artifact }) => + artifact.artifactRole.toLowerCase() === "production_drawing_pdf" + ); + const dxfBundles = artifacts.filter( + ({ artifact }) => + artifact.artifactRole.toLowerCase() === "production_drawing_dxf_bundle" + ); + const [manifest, setManifest] = + useState(); + 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 ( +
+
+ {isReleased ? ( + + ) : ( + + )} +
+

{statusLabel}

+ {manifestError ? ( +

+ 发布清单读取失败,已按未发布状态保护。 +

+ ) : null} + {!manifestArtifact ? ( +

+ 缺少生产图发布清单,不能确认签批状态。 +

+ ) : null} + {manifest?.releaseBlockers.length ? ( +
    + {manifest.releaseBlockers.map((blocker, index) => ( +
  • · {blocker}
  • + ))} +
+ ) : null} + {isReleased ? ( +
+ {manifest?.version ? 版本:{manifest.version} : null} + {manifest?.approvedBy ? ( + 签批人:{manifest.approvedBy} + ) : null} + {manifest?.fileSha256 ? ( + SHA-256:{manifest.fileSha256} + ) : null} +
+ ) : null} +
+
+ {pdfs.length > 0 || dxfBundles.length > 0 ? ( +
+ {pdfs.map(({ artifact, index }) => ( + + + 预览/下载生产工艺图 + + ))} + {dxfBundles.map(({ artifact, index }) => ( + + + 下载DXF图包 + + ))} +
+ ) : null} +
+ ); +} + +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 ( +
+ {overview ? ( +
+ {overview.artifact.displayName +
+ {overview.artifact.displayName || "配件图总览"} +
+
+ ) : null} + + {thumbnails.length > 0 ? ( +
+
+ + 配件图对照 +
+
+ {thumbnails.map(({ artifact, index }) => { + const dimensions = formatArtifactDimensions(artifact); + return ( +
+ {artifact.displayName +
+

+ {[artifact.partId, artifact.partName] + .filter(Boolean) + .join(" · ") || artifact.displayName} +

+ {dimensions ? ( +

+ {dimensions} +

+ ) : null} +
+
+ ); + })} +
+
+ ) : null} + + {workbooks.length > 0 || downloads.length > 0 ? ( +
+ {workbooks.map(({ artifact, index }) => ( + + + {artifact.displayName || "下载带配件图BOM与成本报价"} + + ))} + {downloads.map(({ artifact, index }) => ( + + + {artifact.displayName || artifact.filename} + + ))} +
+ ) : null} + + + + {advanced.length > 0 ? ( +
+ + 高级工程文件({advanced.length}) + +
+ {advanced.map(({ artifact, index }) => ( + + + {artifact.displayName || artifact.filename} + + ))} +
+
+ ) : null} +
+ ); +} + export function OpenClawModel3DPreview({ artifacts, chatId, @@ -110,7 +456,21 @@ export function OpenClawModel3DPreview({ () => getOpenClaw3DArtifactBundle(artifacts), [artifacts] ); - const model = bundle?.model || null; + const primaryModel = bundle?.model || null; + const packagingModel = + bundle && bundle.packagingModelIndex >= 0 + ? artifacts[bundle.packagingModelIndex] || null + : null; + const primaryIsPackaging = + primaryModel && getOpenClaw3DArtifactVariant(primaryModel) === "packaging"; + const productModel = primaryIsPackaging ? null : primaryModel; + const [viewMode, setViewMode] = useState( + productModel ? "product" : "packaging" + ); + const model = + viewMode === "packaging" && packagingModel + ? packagingModel + : productModel || packagingModel; const [isActive, setIsActive] = useState(false); const [webGlUnavailable, setWebGlUnavailable] = useState(false); const [resetViewToken, setResetViewToken] = useState(0); @@ -121,21 +481,29 @@ export function OpenClawModel3DPreview({ const [manifestError, setManifestError] = useState(null); const [selectedPart, setSelectedPart] = useState(null); + const [selectedPackagingPart, setSelectedPackagingPart] = + useState(null); const [matchState, setMatchState] = useState({ matched: 0, total: 0 }); const [explodeAmount, setExplodeAmount] = useState(0); + const [packagingExplodeAmount, setPackagingExplodeAmount] = useState(1); const [transformRequest, setTransformRequest] = useState({ actionId: null as string | null, - amount: 0, + amount: productModel ? 0 : 1, animate: false, durationMs: 700, nonce: 0, }); const hasRestoredSessionRef = useRef(false); const restoredSelectedNodeNameRef = useRef(null); + const restoredSelectedPackagingNodeNameRef = useRef(null); + const cameraStatesRef = useRef< + Partial> + >({}); const closeViewer = useCallback(() => { setIsActive(false); setSelectedPart(null); + setSelectedPackagingPart(null); setMatchState({ matched: 0, total: 0 }); if (typeof window !== "undefined") { const storage = getViewerSessionStorage(); @@ -156,7 +524,7 @@ export function OpenClawModel3DPreview({ }, []); useEffect(() => { - if (hasRestoredSessionRef.current || !model) return; + if (hasRestoredSessionRef.current || !primaryModel) return; hasRestoredSessionRef.current = true; const storage = getViewerSessionStorage(); if (!storage) return; @@ -175,20 +543,36 @@ export function OpenClawModel3DPreview({ return; } + const restoredViewMode = + session.viewMode === "packaging" && packagingModel + ? "packaging" + : productModel + ? "product" + : "packaging"; restoredSelectedNodeNameRef.current = session.selectedNodeName; + restoredSelectedPackagingNodeNameRef.current = + session.selectedPackagingNodeName; + setViewMode(restoredViewMode); setExplodeAmount(session.explodeAmount); + setPackagingExplodeAmount(session.packagingExplodeAmount); setTransformRequest((current) => ({ actionId: null, - amount: session.explodeAmount, + amount: + restoredViewMode === "packaging" + ? session.packagingExplodeAmount + : session.explodeAmount, animate: false, - durationMs: model.viewer?.transitionDurationMs || 700, + durationMs: + (restoredViewMode === "packaging" + ? packagingModel?.viewer?.transitionDurationMs + : productModel?.viewer?.transitionDurationMs) || 700, nonce: current.nonce + 1, })); window.dispatchEvent( new CustomEvent(ACTIVE_VIEWER_EVENT, { detail: viewerId }) ); setIsActive(true); - }, [chatId, messageId, model, viewerId]); + }, [chatId, messageId, packagingModel, primaryModel, productModel, viewerId]); useEffect(() => { if (!isActive) return; @@ -198,10 +582,22 @@ export function OpenClawModel3DPreview({ storage, chatId, messageId, + viewMode, explodeAmount, + packagingExplodeAmount, selectedNodeName: selectedPart?.nodeName || null, + selectedPackagingNodeName: selectedPackagingPart?.nodeName || null, }); - }, [chatId, explodeAmount, isActive, messageId, selectedPart?.nodeName]); + }, [ + chatId, + explodeAmount, + isActive, + messageId, + packagingExplodeAmount, + selectedPackagingPart?.nodeName, + selectedPart?.nodeName, + viewMode, + ]); useEffect(() => { const nodeName = restoredSelectedNodeNameRef.current; @@ -212,6 +608,17 @@ export function OpenClawModel3DPreview({ ); }, [manifest]); + useEffect(() => { + const nodeName = restoredSelectedPackagingNodeNameRef.current; + if (!manifest || !nodeName) return; + restoredSelectedPackagingNodeNameRef.current = null; + setSelectedPackagingPart( + getOpenClawPackagingInteractionParts(manifest).find( + (part) => part.nodeName === nodeName + ) || null + ); + }, [manifest]); + useEffect(() => { const handleOtherViewerActivated = (event: Event) => { const activeId = (event as CustomEvent).detail; @@ -268,56 +675,106 @@ export function OpenClawModel3DPreview({ return () => controller.abort(); }, [bundle, chatId, isActive, manifest, messageId]); - if (!bundle || !model) return null; + useEffect(() => { + if (viewMode === "product" && !productModel && packagingModel) { + setViewMode("packaging"); + } else if (viewMode === "packaging" && !packagingModel && productModel) { + setViewMode("product"); + } + }, [packagingModel, productModel, viewMode]); + + const packagingInteractionParts = useMemo( + () => (manifest ? getOpenClawPackagingInteractionParts(manifest) : []), + [manifest] + ); + + if (!bundle || !model) { + return ( + + ); + } + + const isPackagingView = viewMode === "packaging" && Boolean(packagingModel); + const activeModelIndex = isPackagingView + ? bundle.packagingModelIndex + : bundle.modelIndex; + const activePreviewIndex = isPackagingView + ? bundle.packagingPreviewIndex + : bundle.previewIndex; + const interactionParts = isPackagingView + ? packagingInteractionParts + : manifest?.parts || []; + const selectedInteractionPart = isPackagingView + ? selectedPackagingPart + : selectedPart; + const activeExplodeAmount = isPackagingView + ? packagingExplodeAmount + : explodeAmount; const modelUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, - artifactIndex: bundle.modelIndex, + artifactIndex: activeModelIndex, }); const downloadUrl = buildOpenClawArtifactProxyUrl({ chatId, messageId, - artifactIndex: bundle.modelIndex, + artifactIndex: activeModelIndex, download: true, }); const previewUrl = - bundle.previewIndex >= 0 + activePreviewIndex >= 0 ? buildOpenClawArtifactProxyUrl({ chatId, messageId, - artifactIndex: bundle.previewIndex, + artifactIndex: activePreviewIndex, }) : null; const isAssembledModel = model.artifactRole.toLowerCase() === "assembled_3d" || model.viewer?.variant === "assembled"; - const sourceVariant = getOpenClaw3DArtifactVariant(model); + const artifactVariant = getOpenClaw3DArtifactVariant(model); + const sourceVariant = artifactVariant === "packaging" ? "exploded" : artifactVariant; const supportsNodeInteraction = Boolean( - manifest && (isAssembledModel || sourceVariant === "exploded") + manifest && + interactionParts.length > 0 && + (isPackagingView || isAssembledModel || sourceVariant === "exploded") ); const displayedModelName = - supportsNodeInteraction && sourceVariant === "exploded" + isPackagingView + ? model.displayName || "3D包装预览" + : supportsNodeInteraction && sourceVariant === "exploded" ? "可旋转3D整装成品模型" : model.displayName || model.filename || "3D 模型"; const canDragParts = Boolean( - supportsNodeInteraction && + !isPackagingView && + supportsNodeInteraction && (model.viewer?.partDragging || manifest?.parts.some((part) => part.draggable)) ); const canExplode = Boolean( supportsNodeInteraction && - (model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode) + (isPackagingView + ? manifest?.capabilities.oneClickPackagingExplode + : model.viewer?.oneClickExplode || manifest?.capabilities.oneClickExplode) ); const canAssemble = Boolean( supportsNodeInteraction && - (model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble) + (isPackagingView + ? manifest?.capabilities.oneClickPackagingPack + : model.viewer?.oneClickAssemble || manifest?.capabilities.oneClickAssemble) ); const showExplodeSlider = Boolean( - supportsNodeInteraction && + !isPackagingView && + supportsNodeInteraction && (model.viewer?.explodeSlider || manifest?.capabilities.explodeSlider) ); const canResetParts = Boolean( - supportsNodeInteraction && + !isPackagingView && + supportsNodeInteraction && (model.viewer?.resetParts || manifest?.parts.some((part) => part.draggable)) ); @@ -336,21 +793,53 @@ export function OpenClawModel3DPreview({ actionId: string | null = null ) => { const normalized = Math.max(0, Math.min(1, amount)); - setExplodeAmount(normalized); + if (isPackagingView) { + setPackagingExplodeAmount(normalized); + } else { + setExplodeAmount(normalized); + } + const action = manifest?.actions.find((item) => item.id === actionId); setTransformRequest((current) => ({ actionId, amount: normalized, animate, - durationMs: model.viewer?.transitionDurationMs || 700, + durationMs: + action?.durationMs || model.viewer?.transitionDurationMs || 700, nonce: current.nonce + 1, })); }; - const selectedDimensions = selectedPart - ? formatDimensions(selectedPart) + const switchViewMode = (nextMode: OpenClaw3DViewMode) => { + if (nextMode === viewMode) return; + const nextAmount = + nextMode === "packaging" ? packagingExplodeAmount : explodeAmount; + setViewMode(nextMode); + setMatchState({ matched: 0, total: 0 }); + setTransformRequest((current) => ({ + actionId: null, + amount: nextAmount, + animate: false, + durationMs: 700, + nonce: current.nonce + 1, + })); + }; + const selectedDimensions = selectedInteractionPart + ? formatDimensions(selectedInteractionPart) : null; + const packagingInfo = isPackagingView ? manifest?.packaging : null; + const packagingDimensions = packagingInfo?.packageDimensions.length + ? packagingInfo.packageDimensions + .map( + (item) => + `第${item.packageIndex}箱 ${item.dimensionsMm.x} x ${item.dimensionsMm.y} x ${item.dimensionsMm.z} mm` + ) + .join(";") + : packagingInfo?.packageDimensionsMm + ? `${packagingInfo.packageDimensionsMm.x} x ${packagingInfo.packageDimensionsMm.y} x ${packagingInfo.packageDimensionsMm.z} mm` + : null; return ( -
+ <> +
@@ -369,7 +858,10 @@ export function OpenClawModel3DPreview({ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[#697386] transition-colors hover:bg-black/5 hover:text-[#202631] dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-zinc-100" aria-label="复位3D视角" title="复位3D视角" - onClick={() => setResetViewToken((value) => value + 1)} + onClick={() => { + delete cameraStatesRef.current[viewMode]; + setResetViewToken((value) => value + 1); + }} > @@ -396,6 +888,37 @@ export function OpenClawModel3DPreview({
+ {productModel && packagingModel ? ( +
+ + +
+ ) : null} +
{ + cameraStatesRef.current[viewMode] = state; + }} onMatchChange={handleMatchChange} - onSelectedPartChange={setSelectedPart} + onSelectedPartChange={(part) => { + if (isPackagingView) { + setSelectedPackagingPart(part); + } else { + setSelectedPart(part); + } + }} previewImageUrl={previewUrl} resetAllToken={resetAllToken} resetSelectedToken={resetSelectedToken} resetViewToken={resetViewToken} - selectedNodeName={selectedPart?.nodeName || null} + selectedNodeName={selectedInteractionPart?.nodeName || null} sourceVariant={sourceVariant} transformRequest={transformRequest} /> - {selectedPart ? ( + {selectedInteractionPart ? (

- {selectedPart.partId} · {selectedPart.partName} + {selectedInteractionPart.partId} · {selectedInteractionPart.partName}

{selectedDimensions ? (

@@ -438,16 +973,16 @@ export function OpenClawModel3DPreview({

{canExplode || canAssemble ? (
{canAssemble ? ( ) : null} {canExplode ? ( ) : null}
) : null} - {selectedPart && canResetParts ? ( + {selectedInteractionPart && canResetParts ? (
@@ -521,14 +1061,14 @@ export function OpenClawModel3DPreview({ type="range" min="0" max="100" - value={Math.round(explodeAmount * 100)} + value={Math.round(activeExplodeAmount * 100)} className="h-1.5 min-w-0 flex-1 accent-[#202631] dark:accent-[#3ecf8e]" onChange={(event) => updateExplodeAmount(Number(event.target.value) / 100, false) } /> - {Math.round(explodeAmount * 100)}% + {Math.round(activeExplodeAmount * 100)}% ) : null} @@ -574,6 +1114,46 @@ export function OpenClawModel3DPreview({
)}
+ {packagingInfo ? ( +
+
+

箱型

+

+ {packagingInfo.boxStyleLabel || "未标注"} +

+
+
+

包装箱数

+

+ {packagingInfo.packageCount ?? "-"} +

+
+
+

总包装体积

+

+ {packagingInfo.totalPackagingVolumeM3 != null + ? `${packagingInfo.totalPackagingVolumeM3.toFixed(3)} m³` + : "-"} +

+
+
+

体积重

+

+ {packagingInfo.volumeWeightKg != null + ? `${packagingInfo.volumeWeightKg.toFixed(1)} kg` + : "-"} +

+
+ {packagingDimensions ? ( +
+

每箱尺寸

+

+ {packagingDimensions} +

+
+ ) : null} +
+ ) : null} {webGlUnavailable || manifestError ? (

{webGlUnavailable @@ -581,6 +1161,12 @@ export function OpenClawModel3DPreview({ : `${manifestError},已切换为基础3D预览。`}

) : null} -
+
+ + ); } diff --git a/lib/openclaw-chat/artifacts.ts b/lib/openclaw-chat/artifacts.ts index 0402ac704..c1c259d1d 100644 --- a/lib/openclaw-chat/artifacts.ts +++ b/lib/openclaw-chat/artifacts.ts @@ -1,7 +1,7 @@ export type OpenClawArtifactViewer = { type: "model_3d"; format: "glb" | "gltf"; - variant: "assembled" | "exploded" | null; + variant: "assembled" | "exploded" | "packaging" | null; isDefault: boolean; cameraControls: boolean; autoRotate: boolean; @@ -124,7 +124,11 @@ function normalizeViewer( value: raw.format, }), variant: - variant === "assembled" || variant === "exploded" ? variant : null, + variant === "assembled" || + variant === "exploded" || + variant === "packaging" + ? variant + : null, isDefault: readBoolean(raw.default ?? raw.isDefault, false), cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true), autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false), @@ -259,6 +263,7 @@ export function isOpenClawInteractive3DArtifact( return ( role === "assembled_3d" || role === "interactive_3d" || + role === "packaging_3d" || mimeType === "model/gltf-binary" || mimeType === "model/gltf+json" || filename.endsWith(".glb") || @@ -270,16 +275,21 @@ export type OpenClaw3DArtifactBundle = { modelIndex: number; model: OpenClawArtifact; alternateModelIndex: number; + packagingModelIndex: number; manifestIndex: number; previewIndex: number; + packagingPreviewIndex: number; }; export function getOpenClaw3DArtifactVariant( artifact: OpenClawArtifact -): "assembled" | "exploded" { +): "assembled" | "exploded" | "packaging" { if (artifact.viewer?.variant) return artifact.viewer.variant; const role = artifact.artifactRole.toLowerCase(); const filename = artifact.filename.toLowerCase(); + if (role === "packaging_3d" || filename.includes("packaging")) { + return "packaging"; + } if ( role === "interactive_3d" || filename.includes("exploded") || @@ -300,12 +310,20 @@ export function getOpenClaw3DArtifactBundle( .filter((index) => index >= 0); if (modelIndexes.length === 0) return null; + const packagingModelIndex = modelIndexes.find( + (index) => getOpenClaw3DArtifactVariant(artifacts[index]!) === "packaging" + ) ?? -1; + const productModelIndexes = modelIndexes.filter( + (index) => index !== packagingModelIndex + ); + const modelIndex = - modelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ?? - modelIndexes.find( + productModelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ?? + productModelIndexes.find( (index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d" ) ?? - modelIndexes[0]; + productModelIndexes[0] ?? + packagingModelIndex; const model = artifacts[modelIndex]; if (!model) return null; @@ -317,7 +335,7 @@ export function getOpenClaw3DArtifactBundle( artifact.artifactRole.toLowerCase() === alternateRole && isOpenClawInteractive3DArtifact(artifact) ) - : modelIndexes.find((index) => index !== modelIndex) ?? -1; + : productModelIndexes.find((index) => index !== modelIndex) ?? -1; const manifestRole = model.viewer?.interactionManifestArtifactRole?.toLowerCase() || "model_interaction_manifest"; @@ -336,13 +354,26 @@ export function getOpenClaw3DArtifactBundle( return role === "assembled_preview" || role === "cad_preview"; }); } + let packagingPreviewIndex = -1; + if (packagingModelIndex >= 0) { + const packagingModel = artifacts[packagingModelIndex]; + const packagingFallbackRole = + packagingModel?.viewer?.fallbackArtifactRole?.toLowerCase() || + "packaging_preview"; + packagingPreviewIndex = artifacts.findIndex( + (artifact) => + artifact.artifactRole.toLowerCase() === packagingFallbackRole + ); + } return { modelIndex, model, alternateModelIndex, + packagingModelIndex, manifestIndex, previewIndex, + packagingPreviewIndex, }; } diff --git a/lib/openclaw-chat/model-interaction.ts b/lib/openclaw-chat/model-interaction.ts index 18d1afe43..1b7c6d8dd 100644 --- a/lib/openclaw-chat/model-interaction.ts +++ b/lib/openclaw-chat/model-interaction.ts @@ -17,10 +17,32 @@ export type OpenClawModelInteractionPart = { draggable: boolean; }; +export type OpenClawPackagingLayer = { + nodeName: string; + nodeRole: string; + name: string; + packageIndex: number | null; + dimensionsMm: OpenClawModelVector | null; + packedPositionMm: OpenClawModelVector | null; + explodedPositionMm: OpenClawModelVector | null; + explodedOffsetMm: OpenClawModelVector | null; + packedOffsetFromExplodedMm: OpenClawModelVector | null; + color: string | null; +}; + +export type OpenClawModelInteractionState = + | "assembled" + | "exploded" + | "packaging_exploded" + | "packaging_packed" + | "custom"; + export type OpenClawModelInteractionAction = { id: string; label: string; - toState: "assembled" | "exploded" | "custom"; + toState: OpenClawModelInteractionState; + target: string | null; + nodesField: string | null; offsetField: string | null; durationMs: number; easing: string; @@ -43,7 +65,23 @@ export type OpenClawModelInteractionManifest = { oneClickAssemble: boolean; animatedTransition: boolean; explodeSlider: boolean; + selectPackagingLayer: boolean; + oneClickPackagingExplode: boolean; + oneClickPackagingPack: boolean; }; + packaging: { + shipmentMode: string | null; + boxStyleLabel: string | null; + packageCount: number | null; + packageDimensionsMm: OpenClawModelVector | null; + packageDimensions: Array<{ + packageIndex: number; + dimensionsMm: OpenClawModelVector; + }>; + totalPackagingVolumeM3: number | null; + volumeWeightKg: number | null; + layers: OpenClawPackagingLayer[]; + } | null; }; const MAX_PARTS = 5000; @@ -126,6 +164,62 @@ function normalizePart(value: unknown): OpenClawModelInteractionPart | null { }; } +function normalizePackagingLayer(value: unknown): OpenClawPackagingLayer | null { + const raw = readRecord(value); + const nodeName = readText(raw.node_name ?? raw.nodeName); + if (!nodeName) return null; + const packageIndex = Number(raw.package_index ?? raw.packageIndex); + const color = readText(raw.color, 32); + return { + nodeName, + nodeRole: readText(raw.node_role ?? raw.nodeRole) || "packaging_layer", + name: readText(raw.name) || "包装层", + packageIndex: + Number.isInteger(packageIndex) && packageIndex > 0 ? packageIndex : null, + dimensionsMm: normalizeVector(raw.dimensions_mm ?? raw.dimensionsMm), + packedPositionMm: normalizeVector( + raw.packed_position_mm ?? raw.packedPositionMm + ), + explodedPositionMm: normalizeVector( + raw.exploded_position_mm ?? raw.explodedPositionMm + ), + explodedOffsetMm: normalizeVector( + raw.exploded_offset_mm ?? raw.explodedOffsetMm + ), + packedOffsetFromExplodedMm: normalizeVector( + raw.packed_offset_from_exploded_mm ?? raw.packedOffsetFromExplodedMm + ), + color: color || null, + }; +} + +function readOptionalFiniteNumber(value: unknown, min = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= min ? parsed : null; +} + +function normalizePackageDimensions(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + const raw = readRecord(item); + const packageIndex = Number(raw.package_index ?? raw.packageIndex); + const dimensionsMm = normalizeVector( + raw.dimensions_mm ?? raw.dimensionsMm ?? raw + ); + if (!Number.isInteger(packageIndex) || packageIndex <= 0 || !dimensionsMm) { + return null; + } + return { packageIndex, dimensionsMm }; + }) + .filter( + ( + item + ): item is { packageIndex: number; dimensionsMm: OpenClawModelVector } => + Boolean(item) + ); +} + function normalizeAction( value: unknown ): OpenClawModelInteractionAction | null { @@ -133,13 +227,19 @@ function normalizeAction( const id = readText(raw.id, 160); if (!id) return null; const toState = readText(raw.to_state ?? raw.toState, 32).toLowerCase(); + const normalizedToState: OpenClawModelInteractionState = + toState === "exploded" || + toState === "custom" || + toState === "packaging_exploded" || + toState === "packaging_packed" + ? toState + : "assembled"; return { id, label: readText(raw.label, 160) || id, - toState: - toState === "exploded" || toState === "custom" - ? toState - : "assembled", + toState: normalizedToState, + target: readText(raw.target, 160) || null, + nodesField: readText(raw.nodes_field ?? raw.nodesField, 240) || null, offsetField: readText(raw.offset_field ?? raw.offsetField, 160) || null, durationMs: readFiniteNumber( raw.duration_ms ?? raw.durationMs, @@ -168,6 +268,8 @@ function defaultActions(): OpenClawModelInteractionAction[] { durationMs: 700, easing: "easeInOutCubic", interruptible: true, + target: "all_parts", + nodesField: null, }, { id: "assemble_all", @@ -177,6 +279,8 @@ function defaultActions(): OpenClawModelInteractionAction[] { durationMs: 700, easing: "easeInOutCubic", interruptible: true, + target: "all_parts", + nodesField: null, }, ]; } @@ -196,8 +300,6 @@ export function normalizeOpenClawModelInteractionManifest( seenNodeNames.add(part.nodeName); return true; }); - if (parts.length === 0) return null; - const parsedActions = (Array.isArray(raw.actions) ? raw.actions : []) .slice(0, MAX_ACTIONS) .map(normalizeAction) @@ -212,6 +314,23 @@ export function normalizeOpenClawModelInteractionManifest( const coordinates = readRecord(raw.coordinates); const drag = readRecord(raw.drag); const capabilities = readRecord(raw.capabilities); + const packaging = readRecord(raw.packaging); + const seenPackagingNodeNames = new Set(); + const packagingLayers = ( + Array.isArray(packaging.packaging_layers) + ? packaging.packaging_layers + : Array.isArray(packaging.packagingLayers) + ? packaging.packagingLayers + : [] + ) + .slice(0, MAX_PARTS) + .map(normalizePackagingLayer) + .filter((layer): layer is OpenClawPackagingLayer => { + if (!layer || seenPackagingNodeNames.has(layer.nodeName)) return false; + seenPackagingNodeNames.add(layer.nodeName); + return true; + }); + if (parts.length === 0 && packagingLayers.length === 0) return null; const axes = (Array.isArray(drag.axes) ? drag.axes : ["x", "y", "z"]).filter( (axis): axis is "x" | "y" | "z" => axis === "x" || axis === "y" || axis === "z" @@ -261,10 +380,89 @@ export function normalizeOpenClawModelInteractionManifest( capabilities.explode_slider ?? capabilities.explodeSlider, supportsTransforms ), + selectPackagingLayer: readBoolean( + capabilities.select_packaging_layer ?? + capabilities.selectPackagingLayer, + packagingLayers.length > 0 + ), + oneClickPackagingExplode: readBoolean( + capabilities.one_click_packaging_explode ?? + capabilities.oneClickPackagingExplode, + packagingLayers.length > 0 + ), + oneClickPackagingPack: readBoolean( + capabilities.one_click_packaging_pack ?? + capabilities.oneClickPackagingPack, + packagingLayers.length > 0 + ), }, + packaging: + Object.keys(packaging).length > 0 + ? { + shipmentMode: + readText(packaging.shipment_mode ?? packaging.shipmentMode) || + null, + boxStyleLabel: + readText( + packaging.packaging_box_style_label ?? packaging.boxStyleLabel + ) || null, + packageCount: readOptionalFiniteNumber( + packaging.package_count ?? packaging.packageCount, + 1 + ), + packageDimensionsMm: normalizeVector( + packaging.package_dimensions_mm ?? packaging.packageDimensionsMm + ), + packageDimensions: normalizePackageDimensions( + packaging.package_dimensions ?? packaging.packageDimensions + ), + totalPackagingVolumeM3: readOptionalFiniteNumber( + packaging.total_packaging_volume_m3 ?? + packaging.totalPackagingVolumeM3 + ), + volumeWeightKg: readOptionalFiniteNumber( + packaging.volume_weight_kg ?? packaging.volumeWeightKg + ), + layers: packagingLayers, + } + : null, }; } +export function getOpenClawPackagingInteractionParts( + manifest: OpenClawModelInteractionManifest +): OpenClawModelInteractionPart[] { + return (manifest.packaging?.layers || []).map((layer) => { + const explodedOffsetMm = + layer.explodedOffsetMm || + (layer.packedOffsetFromExplodedMm + ? { + x: -layer.packedOffsetFromExplodedMm.x || 0, + y: -layer.packedOffsetFromExplodedMm.y || 0, + z: -layer.packedOffsetFromExplodedMm.z || 0, + } + : layer.packedPositionMm && layer.explodedPositionMm + ? { + x: layer.explodedPositionMm.x - layer.packedPositionMm.x, + y: layer.explodedPositionMm.y - layer.packedPositionMm.y, + z: layer.explodedPositionMm.z - layer.packedPositionMm.z, + } + : null); + return { + nodeName: layer.nodeName, + partId: layer.nodeRole, + partName: layer.name, + instanceIndex: layer.packageIndex, + dimensionsMm: layer.dimensionsMm, + assembledPositionMm: layer.packedPositionMm, + explodedPositionMm: layer.explodedPositionMm, + explodedOffsetMm, + color: layer.color, + draggable: false, + }; + }); +} + export function getOpenClawExplodedOffsetMm( part: OpenClawModelInteractionPart ): OpenClawModelVector | null { diff --git a/lib/openclaw-chat/production-drawing.ts b/lib/openclaw-chat/production-drawing.ts new file mode 100644 index 000000000..52e290458 --- /dev/null +++ b/lib/openclaw-chat/production-drawing.ts @@ -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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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"; +} diff --git a/lib/openclaw-chat/task-protocol.ts b/lib/openclaw-chat/task-protocol.ts new file mode 100644 index 000000000..f3b7422cf --- /dev/null +++ b/lib/openclaw-chat/task-protocol.ts @@ -0,0 +1,59 @@ +function readRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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) + ); +} diff --git a/lib/openclaw-chat/task-state.ts b/lib/openclaw-chat/task-state.ts index d86e5ae98..e37117b77 100644 --- a/lib/openclaw-chat/task-state.ts +++ b/lib/openclaw-chat/task-state.ts @@ -119,7 +119,7 @@ export function appendClawdTaskProgress( String(progress.step_state || ""), String(progress.message || progress.display || ""), ].join("|"); - const next = current.some((item) => { + const existingIndex = current.findIndex((item) => { const itemEventId = String(item.event_id || "").trim(); const itemSequence = String(item.sequence || "").trim(); const itemKey = itemEventId @@ -132,9 +132,13 @@ export function appendClawdTaskProgress( String(item.message || item.display || ""), ].join("|"); return itemKey === key; - }) - ? current - : [...current, progress]; + }); + const next = + existingIndex >= 0 + ? current.map((item, index) => + index === existingIndex ? { ...item, ...progress } : item + ) + : [...current, progress]; return next.slice(-Math.max(1, maxItems)); } diff --git a/lib/openclaw-chat/upstream-task-client.ts b/lib/openclaw-chat/upstream-task-client.ts index abbd3e9b6..07880553c 100644 --- a/lib/openclaw-chat/upstream-task-client.ts +++ b/lib/openclaw-chat/upstream-task-client.ts @@ -12,6 +12,11 @@ import { } from "@/lib/openclaw-chat/artifacts"; import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret"; +import { + getOpenClawTaskAnswer, + getOpenClawTaskStatus, + isOpenClawAwaitingUserInputTurnComplete, +} from "@/lib/openclaw-chat/task-protocol"; type OpenClawUpstreamConfig = { baseUrl: string; @@ -77,10 +82,6 @@ function readEventId(value: unknown) { return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0; } -function getTaskStatus(payload: unknown) { - return readText(readRecord(readRecord(payload).task).status).toLowerCase(); -} - function getTaskEventsEndpoint(payload: unknown, taskId: string) { const task = readRecord(readRecord(payload).task); return ( @@ -90,15 +91,6 @@ function getTaskEventsEndpoint(payload: unknown, taskId: string) { ); } -function getAnswer(payload: unknown) { - const root = readRecord(payload); - const output = readRecord(root.output); - const result = readRecord(root.result); - return ( - readText(root.answer) || readText(output.answer) || readText(result.answer) - ); -} - function getRequestId(payload: unknown) { const root = readRecord(payload); const output = readRecord(root.output); @@ -239,6 +231,7 @@ function normalizeProgressEvent( "任务状态已更新"; return { ...data, + heartbeat: data.heartbeat === true || raw.heartbeat === true, event_id: eventId, task_id: taskId, event_type: eventType, @@ -248,6 +241,7 @@ function normalizeProgressEvent( step_state: readText(data.step_state) || readText(raw.status) || "started", message, display: message, + display_text: message, created_at: raw.created_at, }; } @@ -423,8 +417,14 @@ export async function refreshOpenClawDurableTask(params: { upstreamTask, }); - const status = getTaskStatus(eventsPayload); - if (!TERMINAL_STATUSES.has(status)) { + const status = getOpenClawTaskStatus(eventsPayload); + const eventTurnAwaitingInput = + isOpenClawAwaitingUserInputTurnComplete(eventsPayload); + if ( + !TERMINAL_STATUSES.has(status) && + !eventTurnAwaitingInput && + status !== "waiting" + ) { return { status: "pending", upstreamTask }; } @@ -435,9 +435,16 @@ export async function refreshOpenClawDurableTask(params: { signal: params.signal, }); upstreamTask = mergeUpstreamTaskState(upstreamTask, terminalPayload); - const terminalStatus = getTaskStatus(terminalPayload) || status; - if (terminalStatus === "succeeded" || terminalStatus === "completed") { - const answer = getAnswer(terminalPayload); + const terminalStatus = getOpenClawTaskStatus(terminalPayload) || status; + const turnAwaitingInput = + eventTurnAwaitingInput || + isOpenClawAwaitingUserInputTurnComplete(terminalPayload); + if ( + terminalStatus === "succeeded" || + terminalStatus === "completed" || + turnAwaitingInput + ) { + const answer = getOpenClawTaskAnswer(terminalPayload); if (!answer) { return { status: "failed", @@ -455,6 +462,9 @@ export async function refreshOpenClawDurableTask(params: { }), }; } + if (!TERMINAL_STATUSES.has(terminalStatus)) { + return { status: "pending", upstreamTask }; + } if (terminalStatus === "cancelled" || terminalStatus === "canceled") { return { status: "canceled", upstreamTask, error: "任务已停止" }; } diff --git a/lib/openclaw-chat/viewer-session.ts b/lib/openclaw-chat/viewer-session.ts index c1fdf9bd6..8105d4bbe 100644 --- a/lib/openclaw-chat/viewer-session.ts +++ b/lib/openclaw-chat/viewer-session.ts @@ -3,8 +3,11 @@ const OPENCLAW_3D_VIEWER_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; export type OpenClaw3DViewerSession = { messageId: string; + viewMode: "product" | "packaging"; explodeAmount: number; + packagingExplodeAmount: number; selectedNodeName: string | null; + selectedPackagingNodeName: string | null; updatedAt: number; }; @@ -53,12 +56,24 @@ export function readOpenClaw3DViewerSession(params: { } return { messageId, - explodeAmount: normalizeExplodeAmount(value.explodeAmount), + viewMode: value.viewMode === "packaging" ? "packaging" : "product", + explodeAmount: normalizeExplodeAmount( + value.productExplodeAmount ?? value.explodeAmount + ), + packagingExplodeAmount: + value.packagingExplodeAmount === undefined + ? 1 + : normalizeExplodeAmount(value.packagingExplodeAmount), selectedNodeName: typeof value.selectedNodeName === "string" && value.selectedNodeName.trim() ? value.selectedNodeName.trim() : null, + selectedPackagingNodeName: + typeof value.selectedPackagingNodeName === "string" && + value.selectedPackagingNodeName.trim() + ? value.selectedPackagingNodeName.trim() + : null, updatedAt, }; } catch { @@ -72,7 +87,10 @@ export function writeOpenClaw3DViewerSession(params: { chatId: string; messageId: string; explodeAmount: number; + viewMode?: "product" | "packaging"; + packagingExplodeAmount?: number; selectedNodeName?: string | null; + selectedPackagingNodeName?: string | null; now?: number; }) { const messageId = params.messageId.trim(); @@ -82,8 +100,15 @@ export function writeOpenClaw3DViewerSession(params: { getViewerSessionKey(params.chatId), JSON.stringify({ messageId, + viewMode: params.viewMode || "product", explodeAmount: normalizeExplodeAmount(params.explodeAmount), + packagingExplodeAmount: + params.packagingExplodeAmount === undefined + ? 1 + : normalizeExplodeAmount(params.packagingExplodeAmount), selectedNodeName: params.selectedNodeName?.trim() || null, + selectedPackagingNodeName: + params.selectedPackagingNodeName?.trim() || null, updatedAt: params.now ?? Date.now(), } satisfies OpenClaw3DViewerSession) ); diff --git a/tests/routes/openclaw-3d-artifacts.test.ts b/tests/routes/openclaw-3d-artifacts.test.ts index c758cc370..08e1de730 100644 --- a/tests/routes/openclaw-3d-artifacts.test.ts +++ b/tests/routes/openclaw-3d-artifacts.test.ts @@ -9,10 +9,15 @@ import { } from "@/lib/openclaw-chat/artifacts"; import { getOpenClawExplodedOffsetMm, + getOpenClawPackagingInteractionParts, getOpenClawSourceNodeOffset, mapOpenClawCadOffsetToGlb, normalizeOpenClawModelInteractionManifest, } from "@/lib/openclaw-chat/model-interaction"; +import { + isOpenClawProductionDrawingReleased, + normalizeOpenClawProductionDrawingManifest, +} from "@/lib/openclaw-chat/production-drawing"; import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; import { clearOpenClaw3DViewerSession, @@ -89,7 +94,56 @@ const manifestFile = { download_url: "https://files.example.com/wood-demo-model-interaction.json", }; +const packagingModelFile = { + ...modelFile, + filename: "wood-demo-packaging.glb", + display_name: "可旋转3D包装叠放模型", + artifact_role: "packaging_3d", + viewer: { + ...modelFile.viewer, + variant: "packaging", + default: false, + fallback_artifact_role: "packaging_preview", + interaction_manifest_artifact_role: "model_interaction_manifest", + }, +}; + test.describe("OpenClaw 3D artifacts", () => { + test("normalizes production drawing release safeguards", () => { + const pending = normalizeOpenClawProductionDrawingManifest({ + release_status: "draft", + automated_validation_status: "production_ready", + approval_status: "awaiting_engineering_release", + release_blockers: [ + "待结构工程师签批", + { code: "HOLE_CHECK", message: "孔位仍需复核" }, + ], + }); + expect(pending).toMatchObject({ + releaseStatus: "draft", + automatedValidationStatus: "production_ready", + approvalStatus: "awaiting_engineering_release", + releaseBlockers: ["待结构工程师签批", "孔位仍需复核"], + }); + expect(isOpenClawProductionDrawingReleased(pending)).toBe(false); + + const released = normalizeOpenClawProductionDrawingManifest({ + release: { + status: "production_released", + version: "V3", + approved_by: "结构工程师A", + sha256: "abc123", + }, + }); + expect(released).toMatchObject({ + releaseStatus: "production_released", + version: "V3", + approvedBy: "结构工程师A", + fileSha256: "abc123", + }); + expect(isOpenClawProductionDrawingReleased(released)).toBe(true); + }); + test("normalizes the documented GLB viewer contract", () => { const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], { baseUrl: "https://claw.example.com", @@ -121,7 +175,9 @@ test.describe("OpenClaw 3D artifacts", () => { modelFile, manifestFile, assembledModelFile, + packagingModelFile, { ...previewFile, artifact_role: "assembled_preview" }, + { ...previewFile, artifact_role: "packaging_preview" }, ], { baseUrl: "https://claw.example.com" } ); @@ -145,7 +201,89 @@ test.describe("OpenClaw 3D artifacts", () => { }); expect(bundle?.manifestIndex).toBe(1); expect(bundle?.alternateModelIndex).toBe(0); - expect(bundle?.previewIndex).toBe(3); + expect(bundle?.previewIndex).toBe(4); + expect(bundle?.packagingModelIndex).toBe(3); + expect(bundle?.packagingPreviewIndex).toBe(5); + expect(getOpenClaw3DArtifactVariant(artifacts[3])).toBe("packaging"); + }); + + test("normalizes v3 packaging models, actions, layers, and carton metrics", () => { + const manifest = normalizeOpenClawModelInteractionManifest({ + schema: "dalongxia.furniture-model-interaction.v3", + coordinates: { scale_mm_to_glb: 0.001 }, + capabilities: { + one_click_packaging_explode: true, + one_click_packaging_pack: true, + select_packaging_layer: true, + }, + actions: [ + { + id: "packaging_pack", + label: "合上包装", + to_state: "packaging_packed", + target: "packaging_layers", + nodes_field: "packaging.packaging_layers", + offset_field: "packed_offset_from_exploded_mm", + duration_ms: 850, + }, + { + id: "packaging_explode", + label: "展开包装", + to_state: "packaging_exploded", + target: "packaging_layers", + nodes_field: "packaging.packaging_layers", + }, + ], + packaging: { + shipment_mode: "拆装", + packaging_box_style_label: "中开箱", + package_count: 1, + package_dimensions_mm: { x: 1260, y: 638, z: 162 }, + package_dimensions: [ + { package_index: 1, x: 1260, y: 638, z: 162 }, + ], + total_packaging_volume_m3: 0.13, + volume_weight_kg: 21.7, + packaging_layers: [ + { + package_index: 1, + node_name: "PKG-01-top-001", + node_role: "top_cover", + name: "上盖", + dimensions_mm: { x: 1250, y: 628, z: 8 }, + packed_position_mm: { x: 5, y: 5, z: 154 }, + exploded_position_mm: { x: 5, y: 5, z: 420 }, + packed_offset_from_exploded_mm: { x: 0, y: 0, z: -266 }, + }, + ], + }, + }); + + expect(manifest?.schema).toBe("dalongxia.furniture-model-interaction.v3"); + expect(manifest?.actions[0]).toMatchObject({ + id: "packaging_pack", + toState: "packaging_packed", + target: "packaging_layers", + nodesField: "packaging.packaging_layers", + }); + expect(manifest?.packaging).toMatchObject({ + shipmentMode: "拆装", + boxStyleLabel: "中开箱", + packageCount: 1, + packageDimensionsMm: { x: 1260, y: 638, z: 162 }, + totalPackagingVolumeM3: 0.13, + volumeWeightKg: 21.7, + }); + expect(manifest?.capabilities.oneClickPackagingPack).toBe(true); + expect(getOpenClawPackagingInteractionParts(manifest!)[0]).toMatchObject({ + nodeName: "PKG-01-top-001", + partId: "top_cover", + partName: "上盖", + assembledPositionMm: { x: 5, y: 5, z: 154 }, + explodedPositionMm: { x: 5, y: 5, z: 420 }, + explodedOffsetMm: { x: 0, y: 0, z: 266 }, + draggable: false, + }); }); test("treats legacy interactive-only GLB as exploded source and can restore assembly", () => { @@ -332,12 +470,43 @@ test.describe("OpenClaw 3D artifacts", () => { }) ).toEqual({ messageId: "message-id", + viewMode: "product", explodeAmount: 1, + packagingExplodeAmount: 1, selectedNodeName: "DOOR-001:1", + selectedPackagingNodeName: null, updatedAt: 1_000, }); }); + test("restores the packaging tab and packed state after a refresh", () => { + const storage = createMemoryStorage(); + writeOpenClaw3DViewerSession({ + storage, + chatId: "chat-id", + messageId: "message-id", + viewMode: "packaging", + explodeAmount: 0.25, + packagingExplodeAmount: 0, + selectedPackagingNodeName: "PKG-01-top-001", + now: 1_000, + }); + + expect( + readOpenClaw3DViewerSession({ + storage, + chatId: "chat-id", + now: 2_000, + }) + ).toMatchObject({ + messageId: "message-id", + viewMode: "packaging", + explodeAmount: 0.25, + packagingExplodeAmount: 0, + selectedPackagingNodeName: "PKG-01-top-001", + }); + }); + test("only clears the viewer state owned by the matching message", () => { const storage = createMemoryStorage(); const now = Date.now(); diff --git a/tests/routes/openclaw-durable-task-state.test.ts b/tests/routes/openclaw-durable-task-state.test.ts index 53fe356d4..bd8638326 100644 --- a/tests/routes/openclaw-durable-task-state.test.ts +++ b/tests/routes/openclaw-durable-task-state.test.ts @@ -4,6 +4,11 @@ import { isPendingClawdTaskStatus, normalizeClawdTaskMetadata, } from "@/lib/openclaw-chat/task-state"; +import { + getOpenClawTaskAnswer, + getOpenClawTaskStatus, + isOpenClawAwaitingUserInputTurnComplete, +} from "@/lib/openclaw-chat/task-protocol"; test.describe("OpenClaw durable task state", () => { test("restores a persisted running task after the page remounts", () => { @@ -44,8 +49,49 @@ test.describe("OpenClaw durable task state", () => { expect(events).toEqual([first, second]); }); + test("refreshes a heartbeat even when it reuses the current event id", () => { + const first = { + event_id: 8, + message: "正在识别产品结构", + created_at: "2026-09-06T01:00:00Z", + }; + const heartbeat = { + event_id: 8, + heartbeat: true, + display_text: "正在生成包装排布", + created_at: "2026-09-06T01:00:05Z", + }; + let events = appendClawdTaskProgress([], first); + events = appendClawdTaskProgress(events, heartbeat); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + event_id: 8, + heartbeat: true, + message: "正在识别产品结构", + display_text: "正在生成包装排布", + created_at: "2026-09-06T01:00:05Z", + }); + }); + test("does not classify completed tasks as pending", () => { expect(isPendingClawdTaskStatus("completed")).toBe(false); expect(isPendingClawdTaskStatus("failed")).toBe(false); }); + + test("recognizes a completed turn that is waiting for the next upload", () => { + const response = { + task: { status: "waiting" }, + output: { + answer: "请上传正视图、侧视图、开门图和尺寸图", + turn_complete: true, + awaiting_user_input: true, + business_status: "awaiting_user_input", + }, + }; + + expect(getOpenClawTaskStatus(response)).toBe("waiting"); + expect(isOpenClawAwaitingUserInputTurnComplete(response)).toBe(true); + expect(getOpenClawTaskAnswer(response)).toContain("请上传正视图"); + }); });