export type OpenClawArtifactViewer = { type: "model_3d"; format: "glb" | "gltf"; variant: "assembled" | "exploded" | null; isDefault: boolean; cameraControls: boolean; autoRotate: boolean; background: string | null; fallbackArtifactRole: string | null; alternateArtifactRole: string | null; interactionManifestArtifactRole: string | null; partSelection: boolean; partDragging: boolean; resetParts: boolean; oneClickExplode: boolean; oneClickAssemble: boolean; explodeActionId: string | null; assembleActionId: string | null; transitionDurationMs: number; explodeSlider: boolean; }; export type OpenClawArtifact = { type: string; filename: string; displayName: string; artifactRole: string; displayMode: string; mimeType: string; downloadUrl: string; partId: string | null; partName: string | null; dimensionsMm: { x: number; y: number; z: number } | null; color: string | null; viewer: OpenClawArtifactViewer | null; }; const MAX_ARTIFACTS = 128; const MAX_TEXT_LENGTH = 500; const MAX_URL_LENGTH = 8192; 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 readBoolean(value: unknown, fallback: boolean) { return typeof value === "boolean" ? value : fallback; } function readNumber( value: unknown, fallback: number, limits?: { min?: number; max?: number } ) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; if (limits?.min !== undefined && parsed < limits.min) return fallback; if (limits?.max !== undefined && parsed > limits.max) return fallback; return parsed; } function normalizeArtifactUrl(value: unknown, baseUrl?: string) { const raw = readText(value, MAX_URL_LENGTH); if (!raw) return ""; try { const url = baseUrl ? new URL(raw, baseUrl) : new URL(raw); return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : ""; } catch { return ""; } } function normalizeDimensions(value: unknown) { const raw = readRecord(value); const dimensions = ["x", "y", "z"].map((key) => Number(raw[key])); if (dimensions.some((dimension) => !Number.isFinite(dimension) || dimension < 0)) { return null; } return { x: dimensions[0], y: dimensions[1], z: dimensions[2] }; } function inferViewerFormat(params: { filename: string; mimeType: string; value: unknown; }) { const explicit = readText(params.value).toLowerCase(); if (explicit === "gltf") return "gltf" as const; if (explicit === "glb") return "glb" as const; if ( params.mimeType.toLowerCase() === "model/gltf+json" || params.filename.toLowerCase().endsWith(".gltf") ) { return "gltf" as const; } return "glb" as const; } function normalizeViewer( value: unknown, artifact: { filename: string; mimeType: string } ): OpenClawArtifactViewer | null { const raw = readRecord(value); if (Object.keys(raw).length === 0) return null; const type = readText(raw.type).toLowerCase(); if (type && type !== "model_3d") return null; const background = readText(raw.background, 32); const variant = readText(raw.variant, 32).toLowerCase(); return { type: "model_3d", format: inferViewerFormat({ filename: artifact.filename, mimeType: artifact.mimeType, value: raw.format, }), variant: variant === "assembled" || variant === "exploded" ? 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), background: background || null, fallbackArtifactRole: readText(raw.fallback_artifact_role ?? raw.fallbackArtifactRole) || null, alternateArtifactRole: readText(raw.alternate_artifact_role ?? raw.alternateArtifactRole) || null, interactionManifestArtifactRole: readText( raw.interaction_manifest_artifact_role ?? raw.interactionManifestArtifactRole ) || null, partSelection: readBoolean( raw.part_selection ?? raw.partSelection, false ), partDragging: readBoolean(raw.part_dragging ?? raw.partDragging, false), resetParts: readBoolean(raw.reset_parts ?? raw.resetParts, false), oneClickExplode: readBoolean( raw.one_click_explode ?? raw.oneClickExplode, false ), oneClickAssemble: readBoolean( raw.one_click_assemble ?? raw.oneClickAssemble, false ), explodeActionId: readText(raw.explode_action_id ?? raw.explodeActionId, 160) || null, assembleActionId: readText(raw.assemble_action_id ?? raw.assembleActionId, 160) || null, transitionDurationMs: readNumber( raw.transition_duration_ms ?? raw.transitionDurationMs, 700, { min: 0, max: 30_000 } ), explodeSlider: readBoolean( raw.explode_slider ?? raw.explodeSlider, false ), }; } function normalizeArtifact( value: unknown, baseUrl?: string ): OpenClawArtifact | null { const raw = readRecord(value); if (Object.keys(raw).length === 0) return null; const downloadUrl = normalizeArtifactUrl( raw.download_url ?? raw.downloadUrl ?? raw.url, baseUrl ); if (!downloadUrl) return null; const filename = readText(raw.filename) || (() => { try { return decodeURIComponent(new URL(downloadUrl).pathname.split("/").at(-1) || ""); } catch { return ""; } })(); const mimeType = readText(raw.mime_type ?? raw.mimeType, 160); const artifactRole = readText(raw.artifact_role ?? raw.artifactRole, 160); const displayMode = readText(raw.display_mode ?? raw.displayMode, 80); const color = readText(raw.color, 32); const artifactBase = { type: readText(raw.type, 80) || "file", filename: filename || "artifact", displayName: readText(raw.display_name ?? raw.displayName) || filename || "文件", artifactRole, displayMode, mimeType, downloadUrl, partId: readText(raw.part_id ?? raw.partId) || null, partName: readText(raw.part_name ?? raw.partName) || null, dimensionsMm: normalizeDimensions(raw.dimensions_mm ?? raw.dimensionsMm), color: color || null, }; return { ...artifactBase, viewer: normalizeViewer(raw.viewer, artifactBase), } satisfies OpenClawArtifact; } export function normalizeOpenClawArtifacts( value: unknown, options?: { baseUrl?: string } ): OpenClawArtifact[] { if (!Array.isArray(value)) return []; const artifacts: OpenClawArtifact[] = []; const seen = new Set(); for (const item of value.slice(0, MAX_ARTIFACTS)) { const artifact = normalizeArtifact(item, options?.baseUrl); if (!artifact) continue; const key = [artifact.downloadUrl, artifact.artifactRole, artifact.filename].join("|"); if (seen.has(key)) continue; seen.add(key); artifacts.push(artifact); } return artifacts; } export function extractOpenClawArtifacts( payload: unknown, options?: { baseUrl?: string } ) { const combined: unknown[] = []; const visit = (value: unknown, depth: number) => { if (depth > 4) return; const record = readRecord(value); if (Array.isArray(record.files)) combined.push(...record.files); for (const key of ["task", "output", "result", "response", "data"] as const) { if (record[key]) visit(record[key], depth + 1); } }; visit(payload, 0); return normalizeOpenClawArtifacts(combined, options); } export function isOpenClawInteractive3DArtifact( artifact: OpenClawArtifact ) { const role = artifact.artifactRole.toLowerCase(); const mimeType = artifact.mimeType.toLowerCase(); const filename = artifact.filename.toLowerCase(); return ( role === "assembled_3d" || role === "interactive_3d" || mimeType === "model/gltf-binary" || mimeType === "model/gltf+json" || filename.endsWith(".glb") || filename.endsWith(".gltf") ); } export type OpenClaw3DArtifactBundle = { modelIndex: number; model: OpenClawArtifact; alternateModelIndex: number; manifestIndex: number; previewIndex: number; }; export function getOpenClaw3DArtifactBundle( artifacts: OpenClawArtifact[] ): OpenClaw3DArtifactBundle | null { const modelIndexes = artifacts .map((artifact, index) => isOpenClawInteractive3DArtifact(artifact) ? index : -1 ) .filter((index) => index >= 0); if (modelIndexes.length === 0) return null; const modelIndex = modelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ?? modelIndexes.find( (index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d" ) ?? modelIndexes[0]; const model = artifacts[modelIndex]; if (!model) return null; const alternateRole = model.viewer?.alternateArtifactRole?.toLowerCase(); const alternateModelIndex = alternateRole ? artifacts.findIndex( (artifact, index) => index !== modelIndex && artifact.artifactRole.toLowerCase() === alternateRole && isOpenClawInteractive3DArtifact(artifact) ) : modelIndexes.find((index) => index !== modelIndex) ?? -1; const manifestRole = model.viewer?.interactionManifestArtifactRole?.toLowerCase() || "model_interaction_manifest"; const manifestIndex = artifacts.findIndex( (artifact) => artifact.artifactRole.toLowerCase() === manifestRole ); const fallbackRole = model.viewer?.fallbackArtifactRole?.toLowerCase() || (model.viewer?.variant === "exploded" ? "cad_preview" : "assembled_preview"); let previewIndex = artifacts.findIndex( (artifact) => artifact.artifactRole.toLowerCase() === fallbackRole ); if (previewIndex < 0) { previewIndex = artifacts.findIndex((artifact) => { const role = artifact.artifactRole.toLowerCase(); return role === "assembled_preview" || role === "cad_preview"; }); } return { modelIndex, model, alternateModelIndex, manifestIndex, previewIndex, }; } export function buildOpenClawArtifactProxyUrl(params: { chatId: string; messageId: string; artifactIndex: number; download?: boolean; }) { const query = new URLSearchParams({ chatId: params.chatId, messageId: params.messageId, index: String(params.artifactIndex), }); if (params.download) query.set("download", "1"); return `/api/canvas/clawd-chat/artifact?${query.toString()}`; }