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,
|
OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS,
|
||||||
type OpenClawChatMediaAttachment,
|
type OpenClawChatMediaAttachment,
|
||||||
} from "@/lib/openclaw-chat/media-attachments";
|
} from "@/lib/openclaw-chat/media-attachments";
|
||||||
|
import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
buildOpenClawDisplayMessage,
|
buildOpenClawDisplayMessage,
|
||||||
buildOpenClawMessageWithSpreadsheetMarkdown,
|
buildOpenClawMessageWithSpreadsheetMarkdown,
|
||||||
@ -337,6 +338,9 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
|||||||
assistantMessageId: params.assistantMessageId,
|
assistantMessageId: params.assistantMessageId,
|
||||||
answer,
|
answer,
|
||||||
requestId: getRequestId(payload),
|
requestId: getRequestId(payload),
|
||||||
|
artifacts: extractOpenClawArtifacts(payload, {
|
||||||
|
baseUrl: params.config.baseUrl,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
clearClawdRequestCancellation(params.cancelKey);
|
clearClawdRequestCancellation(params.cancelKey);
|
||||||
return;
|
return;
|
||||||
@ -370,6 +374,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
|
|||||||
...target,
|
...target,
|
||||||
answer: outcome.answer,
|
answer: outcome.answer,
|
||||||
requestId: outcome.requestId,
|
requestId: outcome.requestId,
|
||||||
|
artifacts: outcome.artifacts,
|
||||||
});
|
});
|
||||||
} else if (outcome.status === "canceled") {
|
} else if (outcome.status === "canceled") {
|
||||||
await markClawdTaskCanceled({
|
await markClawdTaskCanceled({
|
||||||
|
|||||||
@ -75,6 +75,7 @@ async function reconcileDetachedOpenClawTask(params: {
|
|||||||
...target,
|
...target,
|
||||||
answer: outcome.answer,
|
answer: outcome.answer,
|
||||||
requestId: outcome.requestId,
|
requestId: outcome.requestId,
|
||||||
|
artifacts: outcome.artifacts,
|
||||||
});
|
});
|
||||||
} else if (outcome.status === "failed") {
|
} else if (outcome.status === "failed") {
|
||||||
await markClawdTaskFailed({ ...target, error: outcome.error });
|
await markClawdTaskFailed({ ...target, error: outcome.error });
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import {
|
|||||||
updateMessageById,
|
updateMessageById,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
buildOpenClawArtifactProxyUrl,
|
||||||
|
normalizeOpenClawArtifacts,
|
||||||
|
} from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
getFalImageTaskStatus,
|
getFalImageTaskStatus,
|
||||||
resolveFalImageTaskToMarkdown,
|
resolveFalImageTaskToMarkdown,
|
||||||
@ -32,12 +36,29 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000;
|
|||||||
const falTaskLastPolledAt = new Map<string, number>();
|
const falTaskLastPolledAt = new Map<string, number>();
|
||||||
const falTaskSyncInFlight = new Set<string>();
|
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;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||||
const metadata = value as Record<string, unknown>;
|
const metadata = value as Record<string, unknown>;
|
||||||
if (metadata.taskKind !== "clawd_chat") return value;
|
if (metadata.taskKind !== "clawd_chat") return value;
|
||||||
const publicMetadata = { ...metadata };
|
const publicMetadata = { ...metadata };
|
||||||
delete publicMetadata.upstreamTaskKey;
|
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;
|
return publicMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,7 +307,11 @@ export async function GET(request: NextRequest) {
|
|||||||
createdAt: msg.createdAt,
|
createdAt: msg.createdAt,
|
||||||
parts: parts,
|
parts: parts,
|
||||||
attachments: msg.attachments || [],
|
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_AUTH_REQUIRED_MESSAGE,
|
||||||
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
OPENCLAW_UPGRADE_RETRY_MESSAGE,
|
||||||
} from "@/lib/openclaw-chat/errors";
|
} from "@/lib/openclaw-chat/errors";
|
||||||
|
import { normalizeOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts";
|
||||||
|
import { OpenClawModel3DPreview } from "@/components/canvas/chat-panel/OpenClawModel3DPreview";
|
||||||
import {
|
import {
|
||||||
isCanvasImageReadyForAi,
|
isCanvasImageReadyForAi,
|
||||||
isCanvasImageUploadPending,
|
isCanvasImageUploadPending,
|
||||||
@ -495,6 +497,26 @@ function isClawdChatMessage(message: unknown) {
|
|||||||
return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd";
|
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 =
|
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";
|
".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([
|
const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
|
||||||
@ -16132,6 +16154,9 @@ export function CanvasChatPanel({
|
|||||||
const isClawAssistantMessage =
|
const isClawAssistantMessage =
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
(isClawdMode || isClawdChatMessage(message));
|
(isClawdMode || isClawdChatMessage(message));
|
||||||
|
const openClawArtifacts = isClawAssistantMessage
|
||||||
|
? getOpenClawMessageArtifacts(message)
|
||||||
|
: [];
|
||||||
|
|
||||||
const messageTimestamp = getMessageTimestamp(message);
|
const messageTimestamp = getMessageTimestamp(message);
|
||||||
const prevMessageTimestamp =
|
const prevMessageTimestamp =
|
||||||
@ -16710,6 +16735,13 @@ export function CanvasChatPanel({
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
{openClawArtifacts.length > 0 && rawChatId ? (
|
||||||
|
<OpenClawModel3DPreview
|
||||||
|
artifacts={openClawArtifacts}
|
||||||
|
chatId={rawChatId}
|
||||||
|
messageId={message.id}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-2 text-[#8f96a3]">
|
<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 = {
|
type Model3DViewerProps = {
|
||||||
modelUrl?: string | null;
|
modelUrl?: string | null;
|
||||||
|
modelFormat?: "glb" | "gltf" | null;
|
||||||
|
autoFitModel?: boolean;
|
||||||
previewImageUrl?: string | null;
|
previewImageUrl?: string | null;
|
||||||
emptyStateTitle?: string;
|
emptyStateTitle?: string;
|
||||||
settings: Model3DViewerSettings;
|
settings: Model3DViewerSettings;
|
||||||
@ -323,9 +325,14 @@ function cloneSceneWithIndependentMaterials(scene: Object3D) {
|
|||||||
|
|
||||||
function resolveSceneGroundY(
|
function resolveSceneGroundY(
|
||||||
scene: Object3D,
|
scene: Object3D,
|
||||||
modelRotation?: [number, number, number]
|
modelRotation?: [number, number, number],
|
||||||
|
fitTransform?: { position: [number, number, number]; scale: number }
|
||||||
) {
|
) {
|
||||||
const probeScene = scene.clone(true);
|
const probeScene = scene.clone(true);
|
||||||
|
if (fitTransform) {
|
||||||
|
probeScene.position.set(...fitTransform.position);
|
||||||
|
probeScene.scale.setScalar(fitTransform.scale);
|
||||||
|
}
|
||||||
const probeGroup = new THREE.Group();
|
const probeGroup = new THREE.Group();
|
||||||
if (modelRotation) {
|
if (modelRotation) {
|
||||||
probeGroup.rotation.set(...modelRotation);
|
probeGroup.rotation.set(...modelRotation);
|
||||||
@ -338,6 +345,26 @@ function resolveSceneGroundY(
|
|||||||
return bounds.min.y;
|
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({
|
function ModelScene({
|
||||||
url,
|
url,
|
||||||
settings,
|
settings,
|
||||||
@ -346,6 +373,7 @@ function ModelScene({
|
|||||||
onLayersChange,
|
onLayersChange,
|
||||||
onStatsChange,
|
onStatsChange,
|
||||||
onGroundYChange,
|
onGroundYChange,
|
||||||
|
autoFitModel,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
settings: Model3DViewerSettings;
|
settings: Model3DViewerSettings;
|
||||||
@ -354,6 +382,7 @@ function ModelScene({
|
|||||||
onLayersChange?: (layers: Model3DLayer[]) => void;
|
onLayersChange?: (layers: Model3DLayer[]) => void;
|
||||||
onStatsChange?: (stats: Model3DStats) => void;
|
onStatsChange?: (stats: Model3DStats) => void;
|
||||||
onGroundYChange?: (groundY: number) => void;
|
onGroundYChange?: (groundY: number) => void;
|
||||||
|
autoFitModel?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { gl } = useThree();
|
const { gl } = useThree();
|
||||||
const ktx2Loader = useMemo(() => {
|
const ktx2Loader = useMemo(() => {
|
||||||
@ -382,11 +411,18 @@ function ModelScene({
|
|||||||
[model.scene]
|
[model.scene]
|
||||||
);
|
);
|
||||||
const meta = useMemo(() => collectSceneMeta(scene), [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 modelRotationKey = modelRotation?.join("|") || "";
|
||||||
const groundY = useMemo(
|
const groundY = useMemo(
|
||||||
() => resolveSceneGroundY(scene, modelRotation),
|
() => resolveSceneGroundY(scene, modelRotation, fitTransform),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[scene, modelRotationKey]
|
[scene, modelRotationKey, fitTransform]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -437,7 +473,11 @@ function ModelScene({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group rotation={modelRotation || [0, 0, 0]}>
|
<group rotation={modelRotation || [0, 0, 0]}>
|
||||||
<primitive object={scene} scale={1} />
|
<primitive
|
||||||
|
object={scene}
|
||||||
|
position={fitTransform.position}
|
||||||
|
scale={fitTransform.scale}
|
||||||
|
/>
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -679,6 +719,8 @@ function EmptyState({
|
|||||||
|
|
||||||
export function Model3DViewer({
|
export function Model3DViewer({
|
||||||
modelUrl,
|
modelUrl,
|
||||||
|
modelFormat,
|
||||||
|
autoFitModel = false,
|
||||||
previewImageUrl,
|
previewImageUrl,
|
||||||
emptyStateTitle = "准备生成 3D 资产",
|
emptyStateTitle = "准备生成 3D 资产",
|
||||||
settings,
|
settings,
|
||||||
@ -715,7 +757,7 @@ export function Model3DViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isGlbOrGltf(modelUrl)) {
|
if (!isGlbOrGltf(modelUrl) && modelFormat !== "glb" && modelFormat !== "gltf") {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
|
<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" />
|
<AlertCircle className="h-8 w-8 text-yellow-500/50" />
|
||||||
@ -737,9 +779,17 @@ export function Model3DViewer({
|
|||||||
return (
|
return (
|
||||||
<ViewerErrorBoundary
|
<ViewerErrorBoundary
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-zinc-950 p-4 text-zinc-400">
|
<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">
|
||||||
<AlertCircle className="h-8 w-8 text-red-500/50" />
|
{previewImageUrl ? (
|
||||||
<p className="text-sm">3D 预览加载失败,请尝试在新窗口打开</p>
|
<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
|
<Button
|
||||||
asChild
|
asChild
|
||||||
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
|
className="gap-2 border-white/10 text-zinc-300 hover:bg-white/5"
|
||||||
@ -750,6 +800,7 @@ export function Model3DViewer({
|
|||||||
打开3D文件
|
打开3D文件
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -810,6 +861,7 @@ export function Model3DViewer({
|
|||||||
>
|
>
|
||||||
<group position={[0, 0, 0]}>
|
<group position={[0, 0, 0]}>
|
||||||
<ModelScene
|
<ModelScene
|
||||||
|
autoFitModel={autoFitModel}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
modelRotation={modelRotation}
|
modelRotation={modelRotation}
|
||||||
onGroundYChange={handleGroundYChange}
|
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 const CLAWD_TASK_KIND = "clawd_chat";
|
||||||
|
|
||||||
export type ClawdTaskStatus =
|
export type ClawdTaskStatus =
|
||||||
@ -18,6 +23,7 @@ export type ClawdTaskMetadata = {
|
|||||||
assistantMessageId: string;
|
assistantMessageId: string;
|
||||||
processingHint: string;
|
processingHint: string;
|
||||||
progressEvents: ClawdTaskProgressPayload[];
|
progressEvents: ClawdTaskProgressPayload[];
|
||||||
|
artifacts?: OpenClawArtifact[];
|
||||||
skillTagId?: string | null;
|
skillTagId?: string | null;
|
||||||
skillTagName?: string | null;
|
skillTagName?: string | null;
|
||||||
requestId?: string | null;
|
requestId?: string | null;
|
||||||
@ -75,6 +81,7 @@ export function normalizeClawdTaskMetadata(
|
|||||||
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
Boolean(item) && typeof item === "object" && !Array.isArray(item)
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
|
artifacts: normalizeOpenClawArtifacts(raw.artifacts),
|
||||||
skillTagId: raw.skillTagId ? String(raw.skillTagId) : null,
|
skillTagId: raw.skillTagId ? String(raw.skillTagId) : null,
|
||||||
skillTagName: raw.skillTagName ? String(raw.skillTagName) : null,
|
skillTagName: raw.skillTagName ? String(raw.skillTagName) : null,
|
||||||
requestId: raw.requestId ? String(raw.requestId) : null,
|
requestId: raw.requestId ? String(raw.requestId) : null,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
updateMessageById,
|
updateMessageById,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
|
import type { OpenClawArtifact } from "@/lib/openclaw-chat/artifacts";
|
||||||
import {
|
import {
|
||||||
decryptOpenClawTaskSecret,
|
decryptOpenClawTaskSecret,
|
||||||
encryptOpenClawTaskSecret,
|
encryptOpenClawTaskSecret,
|
||||||
@ -74,6 +75,7 @@ function buildTaskMetadata(params: {
|
|||||||
skillTagId?: string | null;
|
skillTagId?: string | null;
|
||||||
skillTagName?: string | null;
|
skillTagName?: string | null;
|
||||||
progressEvents?: ClawdTaskProgressPayload[];
|
progressEvents?: ClawdTaskProgressPayload[];
|
||||||
|
artifacts?: OpenClawArtifact[];
|
||||||
requestId?: string | null;
|
requestId?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
upstreamTask?: ClawdUpstreamTaskState | null;
|
upstreamTask?: ClawdUpstreamTaskState | null;
|
||||||
@ -90,6 +92,7 @@ function buildTaskMetadata(params: {
|
|||||||
assistantMessageId: params.assistantMessageId,
|
assistantMessageId: params.assistantMessageId,
|
||||||
processingHint: params.processingHint,
|
processingHint: params.processingHint,
|
||||||
progressEvents: params.progressEvents || [],
|
progressEvents: params.progressEvents || [],
|
||||||
|
artifacts: params.artifacts || [],
|
||||||
skillTagId: params.skillTagId || null,
|
skillTagId: params.skillTagId || null,
|
||||||
skillTagName: params.skillTagName || null,
|
skillTagName: params.skillTagName || null,
|
||||||
requestId: params.requestId || null,
|
requestId: params.requestId || null,
|
||||||
@ -179,6 +182,7 @@ async function updateClawdAssistantTask(params: {
|
|||||||
requestId?: string | null;
|
requestId?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
upstreamTask?: ClawdUpstreamTaskState;
|
upstreamTask?: ClawdUpstreamTaskState;
|
||||||
|
artifacts?: OpenClawArtifact[];
|
||||||
}) {
|
}) {
|
||||||
const rows = await getMessageById({ id: params.assistantMessageId });
|
const rows = await getMessageById({ id: params.assistantMessageId });
|
||||||
const message = rows.find((row) => row.chatId === params.chatId);
|
const message = rows.find((row) => row.chatId === params.chatId);
|
||||||
@ -196,6 +200,8 @@ async function updateClawdAssistantTask(params: {
|
|||||||
requestId:
|
requestId:
|
||||||
params.requestId !== undefined ? params.requestId : metadata.requestId,
|
params.requestId !== undefined ? params.requestId : metadata.requestId,
|
||||||
error: params.error !== undefined ? params.error : metadata.error,
|
error: params.error !== undefined ? params.error : metadata.error,
|
||||||
|
artifacts:
|
||||||
|
params.artifacts !== undefined ? params.artifacts : metadata.artifacts,
|
||||||
upstreamTaskId:
|
upstreamTaskId:
|
||||||
params.upstreamTask?.taskId !== undefined
|
params.upstreamTask?.taskId !== undefined
|
||||||
? params.upstreamTask.taskId
|
? params.upstreamTask.taskId
|
||||||
@ -262,6 +268,7 @@ export async function markClawdTaskCompleted(params: {
|
|||||||
assistantMessageId: string;
|
assistantMessageId: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
requestId?: string | null;
|
requestId?: string | null;
|
||||||
|
artifacts?: OpenClawArtifact[];
|
||||||
}) {
|
}) {
|
||||||
return updateClawdAssistantTask({
|
return updateClawdAssistantTask({
|
||||||
chatId: params.chatId,
|
chatId: params.chatId,
|
||||||
@ -269,6 +276,7 @@ export async function markClawdTaskCompleted(params: {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
parts: [{ type: "text", text: params.answer }],
|
parts: [{ type: "text", text: params.answer }],
|
||||||
requestId: params.requestId,
|
requestId: params.requestId,
|
||||||
|
artifacts: params.artifacts,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,10 @@ import {
|
|||||||
type ClawdUpstreamTaskState,
|
type ClawdUpstreamTaskState,
|
||||||
persistClawdUpstreamTaskState,
|
persistClawdUpstreamTaskState,
|
||||||
} from "@/lib/openclaw-chat/task-store";
|
} 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 type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
||||||
import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
|
import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret";
|
||||||
|
|
||||||
@ -30,6 +34,7 @@ export type OpenClawTaskOutcome =
|
|||||||
upstreamTask: ClawdUpstreamTaskState;
|
upstreamTask: ClawdUpstreamTaskState;
|
||||||
answer: string;
|
answer: string;
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
|
artifacts: OpenClawArtifact[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: "failed" | "canceled";
|
status: "failed" | "canceled";
|
||||||
@ -445,6 +450,9 @@ export async function refreshOpenClawDurableTask(params: {
|
|||||||
upstreamTask,
|
upstreamTask,
|
||||||
answer,
|
answer,
|
||||||
requestId: getRequestId(terminalPayload),
|
requestId: getRequestId(terminalPayload),
|
||||||
|
artifacts: extractOpenClawArtifacts(terminalPayload, {
|
||||||
|
baseUrl: params.config.baseUrl,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (terminalStatus === "cancelled" || terminalStatus === "canceled") {
|
if (terminalStatus === "cancelled" || terminalStatus === "canceled") {
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
/routes\/openclaw-request-state\.test\.ts/,
|
/routes\/openclaw-request-state\.test\.ts/,
|
||||||
/routes\/openclaw-folder-attachments\.test\.ts/,
|
/routes\/openclaw-folder-attachments\.test\.ts/,
|
||||||
/routes\/openclaw-video-attachments\.test\.ts/,
|
/routes\/openclaw-video-attachments\.test\.ts/,
|
||||||
|
/routes\/openclaw-3d-artifacts\.test\.ts/,
|
||||||
/routes\/hicad-embedded-performance\.test\.ts/,
|
/routes\/hicad-embedded-performance\.test\.ts/,
|
||||||
/routes\/hicad-manufacturing-extension\.test\.ts/,
|
/routes\/hicad-manufacturing-extension\.test\.ts/,
|
||||||
/routes\/openapi-access-key\.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