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 = { 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 = { "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(); 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"); }