feat(claw): preview 3D artifacts in chat

This commit is contained in:
134 2026-09-02 12:42:02 +08:00 committed by root
parent cb9a90d9ac
commit 0a375de55c
13 changed files with 932 additions and 10 deletions

View File

@ -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");
}

View File

@ -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({

View File

@ -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 });

View File

@ -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<string, number>();
const falTaskSyncInFlight = new Set<string>();
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<string, unknown>;
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,
};
});

View File

@ -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<string, any>;
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 ? (
<OpenClawModel3DPreview
artifacts={openClawArtifacts}
chatId={rawChatId}
messageId={message.id}
/>
) : null}
</div>
</div>
<div className="mt-1 flex items-center gap-2 text-[#8f96a3]">

View File

@ -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: () => (
<div className="flex h-full w-full items-center justify-center bg-zinc-950 text-zinc-400">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
),
}
);
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 (
<section className="mt-3 w-full max-w-[760px] overflow-hidden rounded-lg border border-black/10 bg-white dark:border-white/10 dark:bg-zinc-950">
<header className="flex h-11 items-center justify-between gap-3 border-b border-black/8 px-3 dark:border-white/10">
<div className="flex min-w-0 items-center gap-2">
<Box className="h-4 w-4 shrink-0 text-[#687386] dark:text-zinc-400" />
<span className="truncate text-[13px] font-medium text-[#2f3640] dark:text-zinc-200">
{model.displayName || model.filename || "3D 模型"}
</span>
<span className="shrink-0 text-[11px] uppercase text-[#9aa2af] dark:text-zinc-500">
{model.viewer?.format || "glb"}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{isActive ? (
<button
type="button"
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)}
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<a
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模型"
href={downloadUrl}
>
<Download className="h-4 w-4" />
</a>
</div>
</header>
<div
className="relative h-[clamp(280px,42vw,430px)] w-full overflow-hidden"
style={{ backgroundColor: model.viewer?.background || "#f7f9fc" }}
>
{isActive ? (
<Model3DViewer
autoFitModel
modelFormat={model.viewer?.format || "glb"}
modelUrl={modelUrl}
previewImageUrl={previewUrl}
resetViewToken={resetViewToken}
settings={{
autoRotate: model.viewer?.autoRotate || false,
cameraPreset: "iso",
envPreset: "studio",
lightIntensity: 1,
showGrid: false,
wireframe: false,
}}
/>
) : (
<div className="relative flex h-full w-full items-center justify-center bg-[#f7f9fc] dark:bg-zinc-950">
{previewUrl ? (
<img
alt={`${model.displayName || "3D 模型"}静态预览`}
className="absolute inset-0 h-full w-full object-contain"
loading="lazy"
src={previewUrl}
/>
) : (
<Box className="h-16 w-16 text-[#c7cdd6] dark:text-zinc-700" />
)}
<button
type="button"
className="relative inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#202631] px-4 text-[13px] font-medium text-white shadow-sm transition-colors hover:bg-[#11151c] dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white"
onClick={activateViewer}
>
<Box className="h-4 w-4" />
查看 3D
</button>
</div>
)}
</div>
{webGlUnavailable ? (
<p className="border-t border-black/8 px-3 py-2 text-xs text-amber-700 dark:border-white/10 dark:text-amber-300">
当前浏览器无法启动3D预览,已保留静态预览和模型下载。
</p>
) : null}
</section>
);
}

View File

@ -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 (
<group rotation={modelRotation || [0, 0, 0]}>
<primitive object={scene} scale={1} />
<primitive
object={scene}
position={fitTransform.position}
scale={fitTransform.scale}
/>
</group>
);
}
@ -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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
<AlertCircle className="h-8 w-8 text-yellow-500/50" />
@ -737,9 +779,17 @@ export function Model3DViewer({
return (
<ViewerErrorBoundary
fallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
<AlertCircle className="h-8 w-8 text-red-500/50" />
<p className="text-sm">3D 预览加载失败,请尝试在新窗口打开</p>
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3 overflow-hidden bg-zinc-950 p-4 text-zinc-300">
{previewImageUrl ? (
<img
alt="3D 模型静态预览"
className="absolute inset-0 h-full w-full object-contain opacity-80"
src={previewImageUrl}
/>
) : null}
<div className="relative flex max-w-xs flex-col items-center gap-3 rounded-lg bg-black/70 px-4 py-3 text-center backdrop-blur-sm">
<AlertCircle className="h-7 w-7 text-amber-300" />
<p className="text-sm">3D 预览加载失败,已切换为静态预览</p>
<Button
asChild
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
@ -750,6 +800,7 @@ export function Model3DViewer({
打开3D文件
</a>
</Button>
</div>
</div>
}
>
@ -810,6 +861,7 @@ export function Model3DViewer({
>
<group position={[0, 0, 0]}>
<ModelScene
autoFitModel={autoFitModel}
layerVisibility={layerVisibility}
modelRotation={modelRotation}
onGroundYChange={handleGroundYChange}

View File

@ -0,0 +1,218 @@
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readText(value: unknown, maxLength = MAX_TEXT_LENGTH) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function 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<string>();
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()}`;
}

View File

@ -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,

View File

@ -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,
});
}

View File

@ -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") {

View File

@ -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/,

View File

@ -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");
});
});