export type OpenClawArtifactViewer = { type: "model_3d"; format: "glb" | "gltf"; cameraControls: boolean; autoRotate: boolean; background: string | null; fallbackArtifactRole: string | null; }; 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 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); return { type: "model_3d", format: inferViewerFormat({ filename: artifact.filename, mimeType: artifact.mimeType, value: raw.format, }), 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, }; } 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 === "interactive_3d" || mimeType === "model/gltf-binary" || mimeType === "model/gltf+json" || filename.endsWith(".glb") || filename.endsWith(".gltf") ); } 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()}`; }