347 lines
10 KiB
TypeScript

export type OpenClawArtifactViewer = {
type: "model_3d";
format: "glb" | "gltf";
variant: "assembled" | "exploded" | null;
isDefault: boolean;
cameraControls: boolean;
autoRotate: boolean;
background: string | null;
fallbackArtifactRole: string | null;
alternateArtifactRole: string | null;
interactionManifestArtifactRole: string | null;
partSelection: boolean;
partDragging: boolean;
resetParts: boolean;
oneClickExplode: boolean;
oneClickAssemble: boolean;
explodeActionId: string | null;
assembleActionId: string | null;
transitionDurationMs: number;
explodeSlider: boolean;
};
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 readNumber(
value: unknown,
fallback: number,
limits?: { min?: number; max?: number }
) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
if (limits?.min !== undefined && parsed < limits.min) return fallback;
if (limits?.max !== undefined && parsed > limits.max) return fallback;
return parsed;
}
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);
const variant = readText(raw.variant, 32).toLowerCase();
return {
type: "model_3d",
format: inferViewerFormat({
filename: artifact.filename,
mimeType: artifact.mimeType,
value: raw.format,
}),
variant:
variant === "assembled" || variant === "exploded" ? variant : null,
isDefault: readBoolean(raw.default ?? raw.isDefault, false),
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,
alternateArtifactRole:
readText(raw.alternate_artifact_role ?? raw.alternateArtifactRole) || null,
interactionManifestArtifactRole:
readText(
raw.interaction_manifest_artifact_role ??
raw.interactionManifestArtifactRole
) || null,
partSelection: readBoolean(
raw.part_selection ?? raw.partSelection,
false
),
partDragging: readBoolean(raw.part_dragging ?? raw.partDragging, false),
resetParts: readBoolean(raw.reset_parts ?? raw.resetParts, false),
oneClickExplode: readBoolean(
raw.one_click_explode ?? raw.oneClickExplode,
false
),
oneClickAssemble: readBoolean(
raw.one_click_assemble ?? raw.oneClickAssemble,
false
),
explodeActionId:
readText(raw.explode_action_id ?? raw.explodeActionId, 160) || null,
assembleActionId:
readText(raw.assemble_action_id ?? raw.assembleActionId, 160) || null,
transitionDurationMs: readNumber(
raw.transition_duration_ms ?? raw.transitionDurationMs,
700,
{ min: 0, max: 30_000 }
),
explodeSlider: readBoolean(
raw.explode_slider ?? raw.explodeSlider,
false
),
};
}
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 === "assembled_3d" ||
role === "interactive_3d" ||
mimeType === "model/gltf-binary" ||
mimeType === "model/gltf+json" ||
filename.endsWith(".glb") ||
filename.endsWith(".gltf")
);
}
export type OpenClaw3DArtifactBundle = {
modelIndex: number;
model: OpenClawArtifact;
alternateModelIndex: number;
manifestIndex: number;
previewIndex: number;
};
export function getOpenClaw3DArtifactBundle(
artifacts: OpenClawArtifact[]
): OpenClaw3DArtifactBundle | null {
const modelIndexes = artifacts
.map((artifact, index) =>
isOpenClawInteractive3DArtifact(artifact) ? index : -1
)
.filter((index) => index >= 0);
if (modelIndexes.length === 0) return null;
const modelIndex =
modelIndexes.find((index) => artifacts[index]?.viewer?.isDefault) ??
modelIndexes.find(
(index) => artifacts[index]?.artifactRole.toLowerCase() === "assembled_3d"
) ??
modelIndexes[0];
const model = artifacts[modelIndex];
if (!model) return null;
const alternateRole = model.viewer?.alternateArtifactRole?.toLowerCase();
const alternateModelIndex = alternateRole
? artifacts.findIndex(
(artifact, index) =>
index !== modelIndex &&
artifact.artifactRole.toLowerCase() === alternateRole &&
isOpenClawInteractive3DArtifact(artifact)
)
: modelIndexes.find((index) => index !== modelIndex) ?? -1;
const manifestRole =
model.viewer?.interactionManifestArtifactRole?.toLowerCase() ||
"model_interaction_manifest";
const manifestIndex = artifacts.findIndex(
(artifact) => artifact.artifactRole.toLowerCase() === manifestRole
);
const fallbackRole =
model.viewer?.fallbackArtifactRole?.toLowerCase() ||
(model.viewer?.variant === "exploded" ? "cad_preview" : "assembled_preview");
let previewIndex = artifacts.findIndex(
(artifact) => artifact.artifactRole.toLowerCase() === fallbackRole
);
if (previewIndex < 0) {
previewIndex = artifacts.findIndex((artifact) => {
const role = artifact.artifactRole.toLowerCase();
return role === "assembled_preview" || role === "cad_preview";
});
}
return {
modelIndex,
model,
alternateModelIndex,
manifestIndex,
previewIndex,
};
}
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()}`;
}