feat(claw): preview 3D artifacts in chat
This commit is contained in:
parent
cb9a90d9ac
commit
0a375de55c
266
app/api/canvas/clawd-chat/artifact/route.ts
Normal file
266
app/api/canvas/clawd-chat/artifact/route.ts
Normal 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");
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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]">
|
||||
|
||||
179
components/canvas/chat-panel/OpenClawModel3DPreview.tsx
Normal file
179
components/canvas/chat-panel/OpenClawModel3DPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
218
lib/openclaw-chat/artifacts.ts
Normal file
218
lib/openclaw-chat/artifacts.ts
Normal 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()}`;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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/,
|
||||
|
||||
120
tests/routes/openclaw-3d-artifacts.test.ts
Normal file
120
tests/routes/openclaw-3d-artifacts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user