diff --git a/app/api/canvas/clawd-chat/artifact/route.ts b/app/api/canvas/clawd-chat/artifact/route.ts new file mode 100644 index 000000000..2f4e76be8 --- /dev/null +++ b/app/api/canvas/clawd-chat/artifact/route.ts @@ -0,0 +1,266 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getChatById, getMessageById, getSystemSetting } from "@/lib/db/queries"; +import { + normalizeOpenClawChatConfig, + OPENCLAW_CHAT_CONFIG_KEY, +} from "@/lib/openclaw-chat/config"; +import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +const querySchema = z.object({ + chatId: z.string().uuid(), + messageId: z.string().uuid(), + index: z.coerce.number().int().min(0).max(127), + download: z.enum(["0", "1"]).optional().default("0"), +}); + +const MAX_REDIRECTS = 4; +const RESPONSE_HEADERS = [ + "accept-ranges", + "content-length", + "content-range", + "etag", + "last-modified", +] as const; + +function isPrivateIpv4(address: string) { + const octets = address.split(".").map(Number); + if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value))) { + return true; + } + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a >= 224 + ); +} + +function isPrivateIpAddress(address: string) { + if (isIP(address) === 4) return isPrivateIpv4(address); + if (isIP(address) !== 6) return true; + const normalized = address.toLowerCase(); + if (normalized.startsWith("::ffff:")) { + return isPrivateIpv4(normalized.slice("::ffff:".length)); + } + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("fe8") || + normalized.startsWith("fe9") || + normalized.startsWith("fea") || + normalized.startsWith("feb") || + normalized.startsWith("ff") + ); +} + +async function validateTargetUrl(target: URL, trustedOrigin: string) { + if (target.username || target.password) return false; + if (target.origin === trustedOrigin) { + return target.protocol === "http:" || target.protocol === "https:"; + } + if (target.protocol !== "https:") return false; + const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") + ) { + return false; + } + if (isIP(hostname)) return !isPrivateIpAddress(hostname); + + const addresses = await lookup(hostname, { all: true, verbatim: true }).catch( + () => [] + ); + return ( + addresses.length > 0 && + addresses.every((entry) => !isPrivateIpAddress(entry.address)) + ); +} + +function normalizeRangeHeader(value: string | null) { + const range = String(value || "").trim(); + if (!range) return null; + const match = range.match(/^bytes=(\d*)-(\d*)$/); + if (!match || (!match[1] && !match[2])) return ""; + if (match[1] && match[2] && Number(match[1]) > Number(match[2])) return ""; + return range; +} + +function inferContentType(filename: string, declaredType: string) { + if (declaredType) return declaredType; + const lower = filename.toLowerCase(); + if (lower.endsWith(".glb")) return "model/gltf-binary"; + if (lower.endsWith(".gltf")) return "model/gltf+json"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + return "application/octet-stream"; +} + +function safeDownloadName(value: string) { + const normalized = value.replace(/[\r\n"\\/]/g, "_").trim() || "artifact"; + return normalized.slice(0, 180); +} + +async function fetchArtifact(params: { + request: Request; + initialUrl: URL; + method: "GET" | "HEAD"; + range: string | null; + token: string; + trustedOrigin: string; +}) { + let target = params.initialUrl; + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { + if (!(await validateTargetUrl(target, params.trustedOrigin))) { + throw new Error("不允许访问该文件地址"); + } + const headers = new Headers({ Accept: "*/*" }); + if (params.range) headers.set("Range", params.range); + if (target.origin === params.trustedOrigin && params.token) { + headers.set("Authorization", `Bearer ${params.token}`); + } + const response = await fetch(target, { + method: params.method, + headers, + cache: "no-store", + redirect: "manual", + signal: params.request.signal, + }); + if (![301, 302, 303, 307, 308].includes(response.status)) { + return { response, target }; + } + if (redirects === MAX_REDIRECTS) throw new Error("文件重定向次数过多"); + const location = response.headers.get("location"); + if (!location) throw new Error("文件重定向地址无效"); + target = new URL(location, target); + } + throw new Error("文件地址无效"); +} + +async function handleRequest(request: Request, method: "GET" | "HEAD") { + const session = await auth().catch(() => null); + if (!session?.user?.id) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const url = new URL(request.url); + const parsed = querySchema.safeParse({ + chatId: url.searchParams.get("chatId"), + messageId: url.searchParams.get("messageId"), + index: url.searchParams.get("index"), + download: url.searchParams.get("download") || undefined, + }); + if (!parsed.success) { + return NextResponse.json({ error: "请求参数不正确" }, { status: 400 }); + } + + const chat = await getChatById({ id: parsed.data.chatId }).catch(() => null); + if (!chat || chat.userId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const rows = await getMessageById({ id: parsed.data.messageId }).catch( + () => [] + ); + const message = rows.find((row) => row.chatId === parsed.data.chatId); + const metadata = normalizeClawdTaskMetadata(message?.videoMetadata); + const artifact = metadata?.artifacts?.[parsed.data.index]; + if (!message || !artifact) { + return NextResponse.json({ error: "文件不存在" }, { status: 404 }); + } + + const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch( + () => null + ); + const config = normalizeOpenClawChatConfig(setting?.value); + let target: URL; + let trustedOrigin: string; + try { + target = new URL(artifact.downloadUrl); + trustedOrigin = new URL(config.baseUrl).origin; + } catch { + return NextResponse.json({ error: "文件地址无效" }, { status: 400 }); + } + + const range = normalizeRangeHeader(request.headers.get("range")); + if (range === "") { + return NextResponse.json({ error: "Range 请求格式无效" }, { status: 400 }); + } + + try { + const { response, target: finalTarget } = await fetchArtifact({ + request, + initialUrl: target, + method, + range, + token: config.token, + trustedOrigin, + }); + if (!response.ok && response.status !== 206) { + return NextResponse.json( + { error: `文件读取失败(HTTP ${response.status})` }, + { status: response.status >= 400 && response.status < 600 ? response.status : 502 } + ); + } + + const headers = new Headers(); + for (const name of RESPONSE_HEADERS) { + const value = response.headers.get(name); + if (value) headers.set(name, value); + } + headers.set( + "content-type", + response.headers.get("content-type") || + inferContentType(artifact.filename || finalTarget.pathname, artifact.mimeType) + ); + headers.set("cache-control", "private, max-age=3600, must-revalidate"); + headers.set("x-content-type-options", "nosniff"); + if (parsed.data.download === "1") { + const filename = safeDownloadName(artifact.filename || artifact.displayName); + headers.set( + "content-disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(filename)}` + ); + } + + return new Response(method === "HEAD" ? null : response.body, { + status: response.status, + headers, + }); + } catch (error) { + console.warn("[Clawd Artifact] Proxy failed:", { + chatId: parsed.data.chatId, + messageId: parsed.data.messageId, + artifactIndex: parsed.data.index, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: error instanceof Error ? error.message : "文件读取失败" }, + { status: 502 } + ); + } +} + +export async function GET(request: Request) { + return handleRequest(request, "GET"); +} + +export async function HEAD(request: Request) { + return handleRequest(request, "HEAD"); +} diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index fe66d0cfe..d650daef1 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -29,6 +29,7 @@ import { OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS, type OpenClawChatMediaAttachment, } from "@/lib/openclaw-chat/media-attachments"; +import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts"; import { buildOpenClawDisplayMessage, buildOpenClawMessageWithSpreadsheetMarkdown, @@ -337,6 +338,9 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { assistantMessageId: params.assistantMessageId, answer, requestId: getRequestId(payload), + artifacts: extractOpenClawArtifacts(payload, { + baseUrl: params.config.baseUrl, + }), }); clearClawdRequestCancellation(params.cancelKey); return; @@ -370,6 +374,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { ...target, answer: outcome.answer, requestId: outcome.requestId, + artifacts: outcome.artifacts, }); } else if (outcome.status === "canceled") { await markClawdTaskCanceled({ diff --git a/app/api/canvas/clawd-chat/status/route.ts b/app/api/canvas/clawd-chat/status/route.ts index 70c369393..423940dd4 100644 --- a/app/api/canvas/clawd-chat/status/route.ts +++ b/app/api/canvas/clawd-chat/status/route.ts @@ -75,6 +75,7 @@ async function reconcileDetachedOpenClawTask(params: { ...target, answer: outcome.answer, requestId: outcome.requestId, + artifacts: outcome.artifacts, }); } else if (outcome.status === "failed") { await markClawdTaskFailed({ ...target, error: outcome.error }); diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 65b4f9114..247f29bb3 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -14,6 +14,10 @@ import { updateMessageById, } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; +import { + buildOpenClawArtifactProxyUrl, + normalizeOpenClawArtifacts, +} from "@/lib/openclaw-chat/artifacts"; import { getFalImageTaskStatus, resolveFalImageTaskToMarkdown, @@ -32,12 +36,29 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000; const falTaskLastPolledAt = new Map(); const falTaskSyncInFlight = new Set(); -function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) { +function toPublicVideoMetadata(params: { + message: DBMessage; + requestOrigin: string; +}) { + const value = params.message.videoMetadata; if (!value || typeof value !== "object" || Array.isArray(value)) return value; const metadata = value as Record; if (metadata.taskKind !== "clawd_chat") return value; const publicMetadata = { ...metadata }; delete publicMetadata.upstreamTaskKey; + publicMetadata.artifacts = normalizeOpenClawArtifacts(metadata.artifacts).map( + (artifact, artifactIndex) => ({ + ...artifact, + downloadUrl: new URL( + buildOpenClawArtifactProxyUrl({ + chatId: params.message.chatId, + messageId: params.message.id, + artifactIndex, + }), + params.requestOrigin + ).toString(), + }) + ); return publicMetadata; } @@ -286,7 +307,11 @@ export async function GET(request: NextRequest) { createdAt: msg.createdAt, parts: parts, attachments: msg.attachments || [], - videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null, + videoMetadata: + toPublicVideoMetadata({ + message: msg, + requestOrigin: request.nextUrl.origin, + }) || null, }; }); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a475ab9bb..2ab6e55b3 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -118,6 +118,8 @@ import { OPENCLAW_AUTH_REQUIRED_MESSAGE, OPENCLAW_UPGRADE_RETRY_MESSAGE, } from "@/lib/openclaw-chat/errors"; +import { normalizeOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts"; +import { OpenClawModel3DPreview } from "@/components/canvas/chat-panel/OpenClawModel3DPreview"; import { isCanvasImageReadyForAi, isCanvasImageUploadPending, @@ -495,6 +497,26 @@ function isClawdChatMessage(message: unknown) { return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd"; } +function getOpenClawMessageArtifacts(message: unknown) { + if (!message || typeof message !== "object") return []; + const raw = message as Record; + const metadata = + raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata) + ? raw.metadata + : {}; + const videoMetadata = + raw.videoMetadata && + typeof raw.videoMetadata === "object" && + !Array.isArray(raw.videoMetadata) + ? raw.videoMetadata + : {}; + return normalizeOpenClawArtifacts( + Array.isArray(videoMetadata.artifacts) + ? videoMetadata.artifacts + : metadata.artifacts + ); +} + const CLAWD_IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff"; const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([ @@ -16132,6 +16154,9 @@ export function CanvasChatPanel({ const isClawAssistantMessage = message.role === "assistant" && (isClawdMode || isClawdChatMessage(message)); + const openClawArtifacts = isClawAssistantMessage + ? getOpenClawMessageArtifacts(message) + : []; const messageTimestamp = getMessageTimestamp(message); const prevMessageTimestamp = @@ -16710,6 +16735,13 @@ export function CanvasChatPanel({ } return null; })} + {openClawArtifacts.length > 0 && rawChatId ? ( + + ) : null}
diff --git a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx new file mode 100644 index 000000000..6a648c889 --- /dev/null +++ b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx @@ -0,0 +1,179 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ +import dynamic from "next/dynamic"; +import { Box, Download, Loader2, RotateCcw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + buildOpenClawArtifactProxyUrl, + isOpenClawInteractive3DArtifact, + type OpenClawArtifact, +} from "@/lib/openclaw-chat/artifacts"; + +const Model3DViewer = dynamic( + () => + import("@/components/canvas/model3d/Model3DViewer").then( + (module) => module.Model3DViewer + ), + { + ssr: false, + loading: () => ( +
+ +
+ ), + } +); + +function supportsWebGl() { + try { + const canvas = document.createElement("canvas"); + return Boolean( + canvas.getContext("webgl2") || + canvas.getContext("webgl") || + canvas.getContext("experimental-webgl") + ); + } catch { + return false; + } +} + +export function OpenClawModel3DPreview({ + artifacts, + chatId, + messageId, +}: { + artifacts: OpenClawArtifact[]; + chatId: string; + messageId: string; +}) { + const modelIndex = useMemo( + () => artifacts.findIndex(isOpenClawInteractive3DArtifact), + [artifacts] + ); + const model = modelIndex >= 0 ? artifacts[modelIndex] : null; + const fallbackRole = model?.viewer?.fallbackArtifactRole || "cad_preview"; + const previewIndex = artifacts.findIndex( + (artifact) => artifact.artifactRole === fallbackRole + ); + const [isActive, setIsActive] = useState(false); + const [webGlUnavailable, setWebGlUnavailable] = useState(false); + const [resetViewToken, setResetViewToken] = useState(0); + + if (!model) return null; + + const modelUrl = buildOpenClawArtifactProxyUrl({ + chatId, + messageId, + artifactIndex: modelIndex, + }); + const downloadUrl = buildOpenClawArtifactProxyUrl({ + chatId, + messageId, + artifactIndex: modelIndex, + download: true, + }); + const previewUrl = + previewIndex >= 0 + ? buildOpenClawArtifactProxyUrl({ + chatId, + messageId, + artifactIndex: previewIndex, + }) + : null; + + const activateViewer = () => { + if (!supportsWebGl()) { + setWebGlUnavailable(true); + return; + } + setWebGlUnavailable(false); + setIsActive(true); + }; + + return ( +
+
+
+ + + {model.displayName || model.filename || "3D 模型"} + + + {model.viewer?.format || "glb"} + +
+
+ {isActive ? ( + + ) : null} + + + +
+
+ +
+ {isActive ? ( + + ) : ( +
+ {previewUrl ? ( + {`${model.displayName + ) : ( + + )} + +
+ )} +
+ {webGlUnavailable ? ( +

+ 当前浏览器无法启动3D预览,已保留静态预览和模型下载。 +

+ ) : null} +
+ ); +} diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index a58791365..ff459f2c2 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -53,6 +53,8 @@ export type Model3DViewerSettings = { type Model3DViewerProps = { modelUrl?: string | null; + modelFormat?: "glb" | "gltf" | null; + autoFitModel?: boolean; previewImageUrl?: string | null; emptyStateTitle?: string; settings: Model3DViewerSettings; @@ -323,9 +325,14 @@ function cloneSceneWithIndependentMaterials(scene: Object3D) { function resolveSceneGroundY( scene: Object3D, - modelRotation?: [number, number, number] + modelRotation?: [number, number, number], + fitTransform?: { position: [number, number, number]; scale: number } ) { const probeScene = scene.clone(true); + if (fitTransform) { + probeScene.position.set(...fitTransform.position); + probeScene.scale.setScalar(fitTransform.scale); + } const probeGroup = new THREE.Group(); if (modelRotation) { probeGroup.rotation.set(...modelRotation); @@ -338,6 +345,26 @@ function resolveSceneGroundY( return bounds.min.y; } +function getModelFitTransform(scene: Object3D) { + const bounds = new THREE.Box3().setFromObject(scene); + if (bounds.isEmpty()) return { position: [0, 0, 0] as [number, number, number], scale: 1 }; + const size = bounds.getSize(new THREE.Vector3()); + const center = bounds.getCenter(new THREE.Vector3()); + const largestDimension = Math.max(size.x, size.y, size.z); + if (!Number.isFinite(largestDimension) || largestDimension <= 0) { + return { position: [0, 0, 0] as [number, number, number], scale: 1 }; + } + const scale = 2 / largestDimension; + return { + position: [ + -center.x * scale, + -center.y * scale, + -center.z * scale, + ] as [number, number, number], + scale, + }; +} + function ModelScene({ url, settings, @@ -346,6 +373,7 @@ function ModelScene({ onLayersChange, onStatsChange, onGroundYChange, + autoFitModel, }: { url: string; settings: Model3DViewerSettings; @@ -354,6 +382,7 @@ function ModelScene({ onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; onGroundYChange?: (groundY: number) => void; + autoFitModel?: boolean; }) { const { gl } = useThree(); const ktx2Loader = useMemo(() => { @@ -382,11 +411,18 @@ function ModelScene({ [model.scene] ); const meta = useMemo(() => collectSceneMeta(scene), [scene]); + const fitTransform = useMemo( + () => + autoFitModel + ? getModelFitTransform(scene) + : { position: [0, 0, 0] as [number, number, number], scale: 1 }, + [autoFitModel, scene] + ); const modelRotationKey = modelRotation?.join("|") || ""; const groundY = useMemo( - () => resolveSceneGroundY(scene, modelRotation), + () => resolveSceneGroundY(scene, modelRotation, fitTransform), // eslint-disable-next-line react-hooks/exhaustive-deps - [scene, modelRotationKey] + [scene, modelRotationKey, fitTransform] ); useEffect(() => { @@ -437,7 +473,11 @@ function ModelScene({ return ( - + ); } @@ -679,6 +719,8 @@ function EmptyState({ export function Model3DViewer({ modelUrl, + modelFormat, + autoFitModel = false, previewImageUrl, emptyStateTitle = "准备生成 3D 资产", settings, @@ -715,7 +757,7 @@ export function Model3DViewer({ ); } - if (!isGlbOrGltf(modelUrl)) { + if (!isGlbOrGltf(modelUrl) && modelFormat !== "glb" && modelFormat !== "gltf") { return (
@@ -737,9 +779,17 @@ export function Model3DViewer({ return ( - -

3D 预览加载失败,请尝试在新窗口打开

+
+ {previewImageUrl ? ( + 3D 模型静态预览 + ) : null} +
+ +

3D 预览加载失败,已切换为静态预览

+
} > @@ -810,6 +861,7 @@ export function Model3DViewer({ > { + 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()}`; +} diff --git a/lib/openclaw-chat/task-state.ts b/lib/openclaw-chat/task-state.ts index e23292594..d86e5ae98 100644 --- a/lib/openclaw-chat/task-state.ts +++ b/lib/openclaw-chat/task-state.ts @@ -1,3 +1,8 @@ +import { + normalizeOpenClawArtifacts, + type OpenClawArtifact, +} from "@/lib/openclaw-chat/artifacts"; + export const CLAWD_TASK_KIND = "clawd_chat"; export type ClawdTaskStatus = @@ -18,6 +23,7 @@ export type ClawdTaskMetadata = { assistantMessageId: string; processingHint: string; progressEvents: ClawdTaskProgressPayload[]; + artifacts?: OpenClawArtifact[]; skillTagId?: string | null; skillTagName?: string | null; requestId?: string | null; @@ -75,6 +81,7 @@ export function normalizeClawdTaskMetadata( Boolean(item) && typeof item === "object" && !Array.isArray(item) ) : [], + artifacts: normalizeOpenClawArtifacts(raw.artifacts), skillTagId: raw.skillTagId ? String(raw.skillTagId) : null, skillTagName: raw.skillTagName ? String(raw.skillTagName) : null, requestId: raw.requestId ? String(raw.requestId) : null, diff --git a/lib/openclaw-chat/task-store.ts b/lib/openclaw-chat/task-store.ts index 30f609e2f..6314e025b 100644 --- a/lib/openclaw-chat/task-store.ts +++ b/lib/openclaw-chat/task-store.ts @@ -8,6 +8,7 @@ import { updateMessageById, } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; +import type { OpenClawArtifact } from "@/lib/openclaw-chat/artifacts"; import { decryptOpenClawTaskSecret, encryptOpenClawTaskSecret, @@ -74,6 +75,7 @@ function buildTaskMetadata(params: { skillTagId?: string | null; skillTagName?: string | null; progressEvents?: ClawdTaskProgressPayload[]; + artifacts?: OpenClawArtifact[]; requestId?: string | null; error?: string | null; upstreamTask?: ClawdUpstreamTaskState | null; @@ -90,6 +92,7 @@ function buildTaskMetadata(params: { assistantMessageId: params.assistantMessageId, processingHint: params.processingHint, progressEvents: params.progressEvents || [], + artifacts: params.artifacts || [], skillTagId: params.skillTagId || null, skillTagName: params.skillTagName || null, requestId: params.requestId || null, @@ -179,6 +182,7 @@ async function updateClawdAssistantTask(params: { requestId?: string | null; error?: string | null; upstreamTask?: ClawdUpstreamTaskState; + artifacts?: OpenClawArtifact[]; }) { const rows = await getMessageById({ id: params.assistantMessageId }); const message = rows.find((row) => row.chatId === params.chatId); @@ -196,6 +200,8 @@ async function updateClawdAssistantTask(params: { requestId: params.requestId !== undefined ? params.requestId : metadata.requestId, error: params.error !== undefined ? params.error : metadata.error, + artifacts: + params.artifacts !== undefined ? params.artifacts : metadata.artifacts, upstreamTaskId: params.upstreamTask?.taskId !== undefined ? params.upstreamTask.taskId @@ -262,6 +268,7 @@ export async function markClawdTaskCompleted(params: { assistantMessageId: string; answer: string; requestId?: string | null; + artifacts?: OpenClawArtifact[]; }) { return updateClawdAssistantTask({ chatId: params.chatId, @@ -269,6 +276,7 @@ export async function markClawdTaskCompleted(params: { status: "completed", parts: [{ type: "text", text: params.answer }], requestId: params.requestId, + artifacts: params.artifacts, error: null, }); } diff --git a/lib/openclaw-chat/upstream-task-client.ts b/lib/openclaw-chat/upstream-task-client.ts index fc8c4a77d..abbd3e9b6 100644 --- a/lib/openclaw-chat/upstream-task-client.ts +++ b/lib/openclaw-chat/upstream-task-client.ts @@ -6,6 +6,10 @@ import { type ClawdUpstreamTaskState, persistClawdUpstreamTaskState, } from "@/lib/openclaw-chat/task-store"; +import { + extractOpenClawArtifacts, + type OpenClawArtifact, +} from "@/lib/openclaw-chat/artifacts"; import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret"; @@ -30,6 +34,7 @@ export type OpenClawTaskOutcome = upstreamTask: ClawdUpstreamTaskState; answer: string; requestId: string | null; + artifacts: OpenClawArtifact[]; } | { status: "failed" | "canceled"; @@ -445,6 +450,9 @@ export async function refreshOpenClawDurableTask(params: { upstreamTask, answer, requestId: getRequestId(terminalPayload), + artifacts: extractOpenClawArtifacts(terminalPayload, { + baseUrl: params.config.baseUrl, + }), }; } if (terminalStatus === "cancelled" || terminalStatus === "canceled") { diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index f2cf47ad9..db07b0e54 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ /routes\/openclaw-request-state\.test\.ts/, /routes\/openclaw-folder-attachments\.test\.ts/, /routes\/openclaw-video-attachments\.test\.ts/, + /routes\/openclaw-3d-artifacts\.test\.ts/, /routes\/hicad-embedded-performance\.test\.ts/, /routes\/hicad-manufacturing-extension\.test\.ts/, /routes\/openapi-access-key\.test\.ts/, diff --git a/tests/routes/openclaw-3d-artifacts.test.ts b/tests/routes/openclaw-3d-artifacts.test.ts new file mode 100644 index 000000000..7818584d9 --- /dev/null +++ b/tests/routes/openclaw-3d-artifacts.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; +import { + buildOpenClawArtifactProxyUrl, + extractOpenClawArtifacts, + isOpenClawInteractive3DArtifact, + normalizeOpenClawArtifacts, +} from "@/lib/openclaw-chat/artifacts"; +import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; + +const modelFile = { + type: "file", + filename: "wood-demo-interactive.glb", + display_name: "可旋转3D拆件模型", + artifact_role: "interactive_3d", + display_mode: "interactive", + mime_type: "model/gltf-binary", + download_url: "/files/wood-demo-interactive.glb", + viewer: { + type: "model_3d", + format: "glb", + camera_controls: true, + auto_rotate: false, + background: "#F7F9FC", + fallback_artifact_role: "cad_preview", + }, +}; + +const previewFile = { + type: "image", + filename: "wood-demo-preview.png", + artifact_role: "cad_preview", + display_mode: "inline", + mime_type: "image/png", + download_url: "https://files.example.com/wood-demo-preview.png", +}; + +test.describe("OpenClaw 3D artifacts", () => { + test("normalizes the documented GLB viewer contract", () => { + const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], { + baseUrl: "https://claw.example.com", + }); + + expect(artifacts).toHaveLength(2); + expect(artifacts[0]).toMatchObject({ + artifactRole: "interactive_3d", + displayMode: "interactive", + displayName: "可旋转3D拆件模型", + downloadUrl: "https://claw.example.com/files/wood-demo-interactive.glb", + mimeType: "model/gltf-binary", + viewer: { + type: "model_3d", + format: "glb", + cameraControls: true, + autoRotate: false, + background: "#F7F9FC", + fallbackArtifactRole: "cad_preview", + }, + }); + expect(isOpenClawInteractive3DArtifact(artifacts[0])).toBe(true); + expect(isOpenClawInteractive3DArtifact(artifacts[1])).toBe(false); + }); + + test("extracts files from durable task results without duplicating them", () => { + const artifacts = extractOpenClawArtifacts( + { + output: { files: [modelFile, previewFile] }, + task: { result: { files: [modelFile] } }, + }, + { baseUrl: "https://claw.example.com" } + ); + + expect(artifacts).toHaveLength(2); + expect(artifacts.map((artifact) => artifact.artifactRole)).toEqual([ + "interactive_3d", + "cad_preview", + ]); + }); + + test("rejects private schemes and keeps the original URL out of proxy links", () => { + expect( + normalizeOpenClawArtifacts([ + { ...modelFile, download_url: "file:///tmp/private.glb" }, + { ...modelFile, download_url: "javascript:alert(1)" }, + ]) + ).toEqual([]); + + const proxyUrl = buildOpenClawArtifactProxyUrl({ + chatId: "11111111-1111-4111-8111-111111111111", + messageId: "22222222-2222-4222-8222-222222222222", + artifactIndex: 0, + download: true, + }); + expect(proxyUrl).toContain("download=1"); + expect(proxyUrl).not.toContain("files.example.com"); + }); + + test("restores persisted artifacts with Claw task metadata", () => { + const task = normalizeClawdTaskMetadata({ + composerMode: "clawd", + source: "openclaw", + taskKind: "clawd_chat", + status: "completed", + userMessageId: "user-message-id", + assistantMessageId: "assistant-message-id", + processingHint: "", + progressEvents: [], + artifacts: [ + { + ...modelFile, + download_url: "https://files.example.com/model.glb", + }, + ], + createdAt: 100, + updatedAt: 200, + }); + + expect(task?.artifacts).toHaveLength(1); + expect(task?.artifacts?.[0].viewer?.format).toBe("glb"); + }); +});