347 lines
10 KiB
TypeScript
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()}`;
|
|
}
|