ai-chatbot/lib/openclaw-chat/media-attachments.ts

401 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video";
export const OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE = "openclaw-chat-folder";
export const OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE = "openclaw-chat-file";
export const OPENCLAW_CHAT_FILE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2;
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_CHAT_FILE_MAX_FILES = 10;
export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5;
export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000;
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS = 16;
export const OPENCLAW_CHAT_VIDEO_ACCEPT =
"video/mp4,video/quicktime,video/webm,video/x-m4v,video/x-matroska,.mp4,.mov,.webm,.m4v,.mkv";
export const OPENCLAW_CHAT_FILE_ACCEPT =
".zip,.rar,.7z,.tar,.gz,.tgz,.pdf,.txt,.md,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.tsv,.json,.xml,.mp3,.wav,.m4a,.aac,.flac,.ogg,.psd,.ai,.dxf,.dwg,.step,.stp,application/zip,application/x-zip-compressed,application/vnd.rar,application/x-rar-compressed,application/x-7z-compressed,application/x-tar,application/gzip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/csv,text/tab-separated-values,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/flac,audio/ogg";
const VIDEO_CONTENT_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
"video/x-matroska",
]);
const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
m4v: "video/x-m4v",
mkv: "video/x-matroska",
mov: "video/quicktime",
mp4: "video/mp4",
webm: "video/webm",
};
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i;
const OPENCLAW_FILE_EXTENSION_PATTERN =
/\.(zip|rar|7z|tar|gz|tgz|pdf|txt|md|docx?|pptx?|xlsx?|csv|tsv|json|xml|mp3|wav|m4a|aac|flac|ogg|psd|ai|dxf|dwg|step|stp|folder\.json)(?:$|[?#])/i;
const BLOCKED_INLINE_FILE_EXTENSION_PATTERN =
/\.(html?|xhtml|svg|js|mjs|cjs|exe|dll|msi|bat|cmd|com|scr|ps1|sh|jar|apk|dmg|pkg|deb|rpm)(?:$|[?#])/i;
const BLOCKED_INLINE_FILE_CONTENT_TYPES = new Set([
"application/java-archive",
"application/javascript",
"application/vnd.android.package-archive",
"application/x-apple-diskimage",
"application/x-dosexec",
"application/x-msdownload",
"application/x-msi",
"application/x-sh",
"application/xhtml+xml",
"image/svg+xml",
"text/html",
"text/javascript",
]);
const FILE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
"7z": "application/x-7z-compressed",
csv: "text/csv",
gz: "application/gzip",
rar: "application/vnd.rar",
tar: "application/x-tar",
tgz: "application/gzip",
tsv: "text/tab-separated-values",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
zip: "application/zip",
};
const GENERIC_FILE_CONTENT_TYPES = new Set([
"application/gzip",
"application/octet-stream",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.rar",
"application/vnd.yedir.folder-manifest+json",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-zip-compressed",
"application/zip",
"text/csv",
"text/tab-separated-values",
]);
export type OpenClawChatMediaAttachment = {
type: "image" | "video" | "file";
url: string;
name?: string;
content_type?: string;
};
type OpenClawChatAttachmentLike = {
type?: unknown;
url?: unknown;
name?: unknown;
contentType?: unknown;
content_type?: unknown;
};
type OpenClawChatVideoFileLike = {
name: string;
type: string;
};
function normalizeContentType(value: unknown) {
return String(value || "")
.split(";", 1)[0]
.trim()
.toLowerCase();
}
function getExtension(value: string) {
const cleanValue = String(value || "").split(/[?#]/, 1)[0];
return cleanValue.split(".").pop()?.trim().toLowerCase() || "";
}
function isHttpUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
export function getOpenClawChatVideoContentType(
file: OpenClawChatVideoFileLike
) {
const contentType = normalizeContentType(file.type);
if (VIDEO_CONTENT_TYPES.has(contentType)) {
return contentType;
}
return VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(file.name)] || "";
}
export function isOpenClawChatVideoFile(file: OpenClawChatVideoFileLike) {
return Boolean(getOpenClawChatVideoContentType(file));
}
export function isOpenClawChatVideoAttachment(
url: string,
contentType?: string | null
) {
return (
normalizeContentType(contentType).startsWith("video/") ||
VIDEO_EXTENSION_PATTERN.test(String(url || ""))
);
}
export function isOpenClawChatArchiveAttachment(
url: string,
contentType?: string | null
) {
const normalizedContentType = normalizeContentType(contentType);
return (
normalizedContentType === "application/zip" ||
normalizedContentType === "application/x-zip-compressed" ||
ARCHIVE_EXTENSION_PATTERN.test(String(url || ""))
);
}
export function getOpenClawChatFileContentType(file: {
name: string;
type?: string | null;
}) {
const normalized = normalizeContentType(file.type);
if (
normalized.startsWith("image/") ||
normalized.startsWith("video/") ||
BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized) ||
BLOCKED_INLINE_FILE_EXTENSION_PATTERN.test(file.name)
) {
return "";
}
if (
normalized &&
normalized !== "application/octet-stream" &&
!BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized)
) {
return normalized;
}
if (file.name.toLowerCase().endsWith(".folder.json")) {
return "application/vnd.yedir.folder-manifest+json";
}
return (
FILE_CONTENT_TYPE_BY_EXTENSION[getExtension(file.name)] ||
(OPENCLAW_FILE_EXTENSION_PATTERN.test(file.name)
? "application/octet-stream"
: "")
);
}
export function isOpenClawChatFile(file: {
name: string;
type?: string | null;
}) {
return Boolean(getOpenClawChatFileContentType(file));
}
export function isOpenClawChatFolderFileAllowed(file: {
name: string;
type?: string | null;
}) {
const normalized = normalizeContentType(file.type);
return !(
BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized) ||
BLOCKED_INLINE_FILE_EXTENSION_PATTERN.test(file.name)
);
}
export function isOpenClawChatFileAttachment(
url: string,
contentType?: string | null
) {
const normalizedContentType = normalizeContentType(contentType);
return (
GENERIC_FILE_CONTENT_TYPES.has(normalizedContentType) ||
(Boolean(normalizedContentType) &&
!normalizedContentType.startsWith("image/") &&
!normalizedContentType.startsWith("video/") &&
!BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalizedContentType)) ||
OPENCLAW_FILE_EXTENSION_PATTERN.test(String(url || ""))
);
}
export function normalizeOpenClawChatMediaAttachments(
value: unknown
): OpenClawChatMediaAttachment[] {
if (!Array.isArray(value)) {
return [];
}
const result: OpenClawChatMediaAttachment[] = [];
const seenUrls = new Set<string>();
let videoCount = 0;
let archiveCount = 0;
for (const item of value) {
if (!item || typeof item !== "object") {
continue;
}
const raw = item as OpenClawChatAttachmentLike;
const url = String(raw.url || "").trim();
if (!url || !isHttpUrl(url) || seenUrls.has(url)) {
continue;
}
const requestedType = String(raw.type || "").trim().toLowerCase();
const rawContentType = normalizeContentType(
raw.content_type || raw.contentType
);
const isVideo =
requestedType === "video" ||
isOpenClawChatVideoAttachment(url, rawContentType);
const isFile =
!isVideo &&
(requestedType === "file" ||
requestedType === "archive" ||
isOpenClawChatFileAttachment(url, rawContentType));
if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) {
continue;
}
if (isFile && archiveCount >= OPENCLAW_CHAT_FILE_MAX_FILES) {
continue;
}
if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) {
break;
}
const name = String(raw.name || "")
.trim()
.slice(0, 200);
const type = isVideo ? "video" : isFile ? "file" : "image";
const fallbackContentType = isVideo
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
: isFile
? getOpenClawChatFileContentType({ name: name || url, type: rawContentType }) ||
"application/octet-stream"
: "image/jpeg";
const contentType = isFile
? rawContentType || fallbackContentType
: rawContentType.startsWith(`${type}/`)
? rawContentType
: fallbackContentType;
result.push({
type,
url,
...(name ? { name } : {}),
content_type: contentType,
});
seenUrls.add(url);
if (isVideo) {
videoCount += 1;
}
if (isFile) {
archiveCount += 1;
}
}
return result;
}
export function buildOpenClawVideoDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const videos = attachments.filter((attachment) => attachment.type === "video");
if (videos.length === 0) {
return "";
}
const lines = videos.map(
(attachment, index) => `- ${attachment.name || `视频${index + 1}`}`
);
return `已上传视频:\n${lines.join("\n")}`;
}
export function buildOpenClawVideoReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const videos = attachments.filter((attachment) => attachment.type === "video");
if (videos.length === 0) {
return "";
}
const lines = videos.map(
(attachment, index) =>
`- ${attachment.name || `视频${index + 1}`}: ${attachment.url}`
);
return [
"用户上传了以下视频,请调用视频理解能力并按视频文件地址处理:",
lines.join("\n"),
].join("\n");
}
export function buildOpenClawArchiveDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const archives = attachments.filter(
(attachment) => attachment.type === "file"
);
if (archives.length === 0) {
return "";
}
const lines = archives.map(
(attachment, index) =>
`- ${attachment.name || `文件夹压缩包${index + 1}.zip`}`
);
return `已上传文件夹:\n${lines.join("\n")}`;
}
export function buildOpenClawFileDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const files = attachments.filter((attachment) => attachment.type === "file");
if (files.length === 0) {
return "";
}
const lines = files.map(
(attachment, index) => `- ${attachment.name || `文件附件${index + 1}`}`
);
return `已上传文件:\n${lines.join("\n")}`;
}
export function buildOpenClawArchiveReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const archives = attachments.filter(
(attachment) => attachment.type === "file"
);
if (archives.length === 0) {
return "";
}
const lines = archives.map(
(attachment, index) =>
`- ${attachment.name || `文件夹压缩包${index + 1}.zip`}: ${attachment.url}`
);
return [
"用户上传了以下文件夹压缩包,压缩包保留原目录结构,请下载并解压后处理:",
lines.join("\n"),
].join("\n");
}
export function buildOpenClawFileReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const files = attachments.filter((attachment) => attachment.type === "file");
if (files.length === 0) {
return "";
}
const lines = files.map(
(attachment, index) =>
`- ${attachment.name || `文件附件${index + 1}`}: ${attachment.url} (${attachment.content_type || "application/octet-stream"})`
);
return [
"用户上传了以下文件。表格请下载后读取,压缩包请下载并解压;application/vnd.yedir.folder-manifest+json 是保留目录结构和各文件下载地址的文件夹清单:",
lines.join("\n"),
].join("\n");
}