219 lines
6.3 KiB
TypeScript

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()}`;
}