From bcff82aeca95235fa38a3156ac5cd5c5515a7a51 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 11 Aug 2026 10:04:33 +0800 Subject: [PATCH] feat(claw): add secure forms and folder uploads --- .../canvas/clawd-chat/folder-upload/route.ts | 141 ++++ app/api/canvas/clawd-chat/route.ts | 119 ++-- components/canvas/CanvasChatPanel.tsx | 641 ++++++++++++++++-- .../canvas/chat-panel/canvas-chat-session.ts | 2 + lib/openclaw-chat/folder-archive.ts | 49 ++ lib/openclaw-chat/media-attachments.ts | 84 ++- playwright.helpers.config.ts | 1 + .../openclaw-folder-attachments.test.ts | 77 +++ 8 files changed, 1029 insertions(+), 85 deletions(-) create mode 100644 app/api/canvas/clawd-chat/folder-upload/route.ts create mode 100644 lib/openclaw-chat/folder-archive.ts create mode 100644 tests/routes/openclaw-folder-attachments.test.ts diff --git a/app/api/canvas/clawd-chat/folder-upload/route.ts b/app/api/canvas/clawd-chat/folder-upload/route.ts new file mode 100644 index 000000000..d557df075 --- /dev/null +++ b/app/api/canvas/clawd-chat/folder-upload/route.ts @@ -0,0 +1,141 @@ +import JSZip from "jszip"; +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getAllStorageConfigs } from "@/lib/db/queries"; +import { + countOpenClawArchiveRootFolders, + sanitizeOpenClawArchiveEntryPath, + sanitizeOpenClawArchiveName, +} from "@/lib/openclaw-chat/folder-archive"; +import { + OPENCLAW_CHAT_FOLDER_MAX_FILES, + OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES, +} from "@/lib/openclaw-chat/media-attachments"; +import { createLogger } from "@/lib/observability/logger"; +import { + toDbS3UploadConfig, + uploadFileWithS3Config, +} from "@/lib/storage/service"; + +export const runtime = "nodejs"; +export const maxDuration = 600; + +const logger = createLogger("openclaw-folder-upload"); + +function isUploadedFile(value: FormDataEntryValue): value is File { + return ( + typeof value === "object" && + value !== null && + "arrayBuffer" in value && + "name" in value && + "size" in value + ); +} + +function toArrayBuffer(buffer: Buffer) { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; +} + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + try { + const formData = await request.formData(); + const files = formData + .getAll("files") + .filter((entry): entry is File => isUploadedFile(entry)); + const relativePaths = formData + .getAll("relativePaths") + .map((entry) => (typeof entry === "string" ? entry : "")); + + if (files.length === 0) { + return NextResponse.json( + { error: "所选文件夹中没有可上传文件" }, + { status: 400 } + ); + } + if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) { + return NextResponse.json( + { error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` }, + { status: 413 } + ); + } + + const originalSize = files.reduce((sum, file) => sum + file.size, 0); + if (originalSize > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) { + return NextResponse.json( + { error: "文件夹总大小不能超过 300MB" }, + { status: 413 } + ); + } + + const archivePaths = files.map((file, index) => + sanitizeOpenClawArchiveEntryPath( + relativePaths[index] || file.name, + file.name, + index + ) + ); + const zip = new JSZip(); + for (const [index, file] of files.entries()) { + zip.file(archivePaths[index], await file.arrayBuffer(), { + date: new Date(file.lastModified || Date.now()), + }); + } + + const archiveBaseName = sanitizeOpenClawArchiveName( + String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "") + ); + const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`; + const archiveBuffer = await zip.generateAsync({ + type: "nodebuffer", + compression: "DEFLATE", + compressionOptions: { level: 6 }, + platform: "UNIX", + }); + + const storageConfigs = await getAllStorageConfigs(); + const qiniuStorage = storageConfigs + .filter((config) => config.type === "qiniu-kodo" && config.isEnabled) + .sort((left, right) => Number(right.isDefault) - Number(left.isDefault)) + .find((config) => toDbS3UploadConfig(config)); + const qiniuConfig = toDbS3UploadConfig(qiniuStorage); + if (!qiniuConfig) { + return NextResponse.json( + { error: "系统尚未配置可用的七牛云储存桶" }, + { status: 503 } + ); + } + + const uploaded = await uploadFileWithS3Config( + archiveName, + toArrayBuffer(archiveBuffer), + qiniuConfig + ); + if (!/^https?:\/\//i.test(uploaded.url)) { + throw new Error("七牛云未返回可访问的公网地址"); + } + + return NextResponse.json({ + url: uploaded.url, + name: archiveName, + contentType: "application/zip", + size: archiveBuffer.byteLength, + originalSize, + fileCount: files.length, + folderCount: countOpenClawArchiveRootFolders(archivePaths), + }); + } catch (error) { + logger.error("Failed to package and upload Claw folders", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "文件夹上传失败" }, + { status: 500 } + ); + } +} diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index e8d1b9278..5e6904e9c 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -55,6 +55,19 @@ const requestSchema = z.object({ displayMessage: z.string().trim().max(8000).optional(), userMessageId: z.string().uuid().optional(), metadata: z.record(z.unknown()).optional(), + sensitiveInput: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("taobao_credentials"), + username: z.string().min(1).max(160), + password: z.string().min(1).max(256), + }), + z.object({ + type: z.literal("taobao_verification"), + verification_code: z.string().min(4).max(12), + }), + ]) + .optional(), attachments: z .array(z.unknown()) .max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) @@ -102,6 +115,7 @@ type ExecuteClawdTaskParams = { conversationId: string; departmentName: string; metadata: Record | undefined; + sensitiveInput?: z.infer["sensitiveInput"]; mediaAttachments: OpenClawChatMediaAttachment[]; skillTagId: string; skillTagName: string; @@ -168,8 +182,10 @@ async function parseClawdChatRequest( let metadata: Record | undefined; let attachments: unknown[] = []; + let sensitiveInput: unknown; const metadataValue = formData.get("metadata"); const attachmentsValue = formData.get("attachments"); + const sensitiveInputValue = formData.get("sensitiveInput"); try { if (typeof metadataValue === "string" && metadataValue.trim()) { const parsedMetadata = JSON.parse(metadataValue); @@ -181,6 +197,9 @@ async function parseClawdChatRequest( if (!Array.isArray(parsedAttachments)) return null; attachments = parsedAttachments; } + if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) { + sensitiveInput = JSON.parse(sensitiveInputValue); + } } catch { return null; } @@ -192,6 +211,7 @@ async function parseClawdChatRequest( userMessageId: formData.get("userMessageId") || undefined, metadata, attachments, + sensitiveInput, }); if (!parsed.success) return null; const normalizedAttachments = normalizeOpenClawChatMediaAttachments( @@ -499,15 +519,27 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { assistantMessageId: params.assistantMessageId, }); - const response = await fetch(`${params.config.baseUrl}/api/chat`, { - method: "POST", - headers: { - Authorization: `Bearer ${params.config.token}`, - "Content-Type": "application/json", - Accept: "text/event-stream", - }, - body: JSON.stringify({ - conversation_id: params.conversationId, + const isSensitiveSubmission = Boolean(params.sensitiveInput); + const upstreamBody = JSON.stringify({ + conversation_id: params.conversationId, + user_id: params.userId, + user_name: params.userName, + username: params.userName, + department: params.departmentName, + department_name: params.departmentName, + user_department: params.departmentName, + skill_tag_id: params.skillTagId || undefined, + skill_tag_name: params.skillTagName || undefined, + message: params.upstreamMessage, + attachments: + params.mediaAttachments.length > 0 + ? params.mediaAttachments + : undefined, + metadata: { + source: "ai_chatbot_canvas", + page: "/canvas", + tenant: "default", + chat_id: params.chatId, user_id: params.userId, user_name: params.userName, username: params.userName, @@ -516,38 +548,29 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { user_department: params.departmentName, skill_tag_id: params.skillTagId || undefined, skill_tag_name: params.skillTagName || undefined, - message: params.upstreamMessage, - attachments: + media_attachments: params.mediaAttachments.length > 0 ? params.mediaAttachments : undefined, - metadata: { - source: "ai_chatbot_canvas", - page: "/canvas", - tenant: "default", - chat_id: params.chatId, - user_id: params.userId, - user_name: params.userName, - username: params.userName, - department: params.departmentName, - department_name: params.departmentName, - user_department: params.departmentName, - skill_tag_id: params.skillTagId || undefined, - skill_tag_name: params.skillTagName || undefined, - media_attachments: - params.mediaAttachments.length > 0 - ? params.mediaAttachments - : undefined, - spreadsheet_files: - params.spreadsheetFiles.length > 0 - ? params.spreadsheetFiles - : undefined, - spreadsheet_markdown_truncated: - params.spreadsheetMarkdownTruncated || undefined, - ...params.metadata, - }, - stream_progress: true, - }), + spreadsheet_files: + params.spreadsheetFiles.length > 0 + ? params.spreadsheetFiles + : undefined, + spreadsheet_markdown_truncated: + params.spreadsheetMarkdownTruncated || undefined, + ...params.metadata, + }, + stream_progress: !isSensitiveSubmission, + sensitive_input: params.sensitiveInput, + }); + const response = await fetch(`${params.config.baseUrl}/api/chat`, { + method: "POST", + headers: { + Authorization: `Bearer ${params.config.token}`, + "Content-Type": "application/json", + Accept: isSensitiveSubmission ? "application/json" : "text/event-stream", + }, + body: upstreamBody, signal: controller.signal, cache: "no-store", }); @@ -602,6 +625,13 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { error: getOpenClawTaskFailureMessage(error), }).catch(() => null); } finally { + if (params.sensitiveInput?.type === "taobao_credentials") { + params.sensitiveInput.username = ""; + params.sensitiveInput.password = ""; + } else if (params.sensitiveInput?.type === "taobao_verification") { + params.sensitiveInput.verification_code = ""; + } + params.sensitiveInput = undefined; clearTimeout(timeout); unregisterActiveClawdRequest(params.cancelKey); } @@ -752,11 +782,19 @@ export async function POST(request: Request) { const messageAttachments = parsed.attachments.map((attachment) => ({ name: attachment.name || - (attachment.type === "video" ? "视频附件" : "图片附件"), + (attachment.type === "video" + ? "视频附件" + : attachment.type === "file" + ? "文件夹压缩包.zip" + : "图片附件"), url: attachment.url, contentType: attachment.content_type || - (attachment.type === "video" ? "video/mp4" : "image/jpeg"), + (attachment.type === "video" + ? "video/mp4" + : attachment.type === "file" + ? "application/zip" + : "image/jpeg"), })); await persistPendingClawdTask({ @@ -794,6 +832,7 @@ export async function POST(request: Request) { conversationId: createConversationId(session.user.id, parsed.id), departmentName: String(userProfile?.department || "").trim(), metadata: parsed.metadata, + sensitiveInput: parsed.sensitiveInput, mediaAttachments: parsed.attachments, skillTagId, skillTagName, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index c260c9966..b5be8aed3 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -25,7 +25,7 @@ import { type UiSurfaceSnapshot, } from "@/lib/ui-surface-protocol"; import useSWR, { useSWRConfig } from "swr"; -import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy, Square, Search, GripVertical, Volume2 } from "lucide-react"; +import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -309,12 +309,18 @@ import { updateClawdRequestState, } from "@/components/canvas/chat-panel/clawd-request-state"; import { + buildOpenClawArchiveDisplayText, + buildOpenClawArchiveReferenceText, buildOpenClawVideoDisplayText, buildOpenClawVideoReferenceText, getOpenClawChatVideoContentType, + isOpenClawChatArchiveAttachment, isOpenClawChatVideoAttachment, isOpenClawChatVideoFile, normalizeOpenClawChatMediaAttachments, + OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES, + OPENCLAW_CHAT_FOLDER_MAX_FILES, + OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES, OPENCLAW_CHAT_VIDEO_ACCEPT, OPENCLAW_CHAT_VIDEO_MAX_BYTES, OPENCLAW_CHAT_VIDEO_MAX_FILES, @@ -855,9 +861,15 @@ function shouldShowClawdProcessingHint( message: string, hasTableFiles: boolean, hasImageAttachments: boolean, - hasVideoAttachments: boolean + hasVideoAttachments: boolean, + hasFolderAttachments: boolean ) { - if (hasTableFiles || hasImageAttachments || hasVideoAttachments) { + if ( + hasTableFiles || + hasImageAttachments || + hasVideoAttachments || + hasFolderAttachments + ) { return true; } @@ -1041,12 +1053,14 @@ function buildClawdImageReferenceText(attachments: PickedObjectAttachment[]) { function getClawdDefaultPrompt( hasTables: boolean, hasImages: boolean, - hasVideos: boolean + hasVideos: boolean, + hasFolders: boolean ) { const mediaNames = [ hasTables ? "表格" : "", hasImages ? "图片" : "", hasVideos ? "视频" : "", + hasFolders ? "文件夹" : "", ].filter(Boolean); return mediaNames.length > 0 ? `请分析上传的${mediaNames.join("、")}内容` @@ -1643,6 +1657,26 @@ type OpenClawActionsBlock = { actions: OpenClawQuickAction[]; }; +type OpenClawSecureField = { + name: "username" | "password" | "verification_code"; + label: string; + inputType: "text" | "password"; + inputMode?: "text" | "numeric"; + autocomplete?: string; + maxLength: number; +}; + +type OpenClawSecureForm = { + formType: "taobao_credentials" | "taobao_verification"; + title: string; + submitMessage: string; + fields: OpenClawSecureField[]; +}; + +type OpenClawSensitiveInput = + | { type: "taobao_credentials"; username: string; password: string } + | { type: "taobao_verification"; verification_code: string }; + type CanvasComposerUploadProgress = { title: string; message: string; @@ -1732,6 +1766,7 @@ function buildCanvasComposerUploadProgress( const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX = /```openclaw-actions\s*([\s\S]*?)```/gi; +const OPENCLAW_FORM_CODE_BLOCK_REGEX = /```openclaw-form\s*([\s\S]*?)```/gi; function normalizeOpenClawActionText(value: unknown, maxLength: number) { return String(value || "") @@ -1787,9 +1822,55 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n }; } +function parseOpenClawSecureFormPayload(payload: unknown): OpenClawSecureForm | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const row = payload as Record; + const formType = normalizeOpenClawActionText(row.form_type, 40); + if ( + row.openclaw_form !== true || + row.type !== "secure_form" || + row.persist !== false || + !["taobao_credentials", "taobao_verification"].includes(formType) || + !Array.isArray(row.fields) + ) { + return null; + } + const allowedNames = + formType === "taobao_credentials" + ? new Set(["username", "password"]) + : new Set(["verification_code"]); + const fields = row.fields + .map((item): OpenClawSecureField | null => { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const field = item as Record; + const name = normalizeOpenClawActionText(field.name, 40); + if (!allowedNames.has(name)) return null; + const inputType = field.input_type === "password" ? "password" : "text"; + return { + name: name as OpenClawSecureField["name"], + label: normalizeOpenClawActionText(field.label, 40) || name, + inputType, + inputMode: field.input_mode === "numeric" ? "numeric" : "text", + autocomplete: normalizeOpenClawActionText(field.autocomplete, 40) || undefined, + maxLength: Math.max(4, Math.min(256, Number(field.max_length) || 160)), + }; + }) + .filter((item): item is OpenClawSecureField => Boolean(item)); + if (fields.length !== allowedNames.size || new Set(fields.map((field) => field.name)).size !== fields.length) { + return null; + } + return { + formType: formType as OpenClawSecureForm["formType"], + title: normalizeOpenClawActionText(row.title, 60) || "安全登录", + submitMessage: normalizeOpenClawActionText(row.submit_message, 120), + fields, + }; +} + function extractOpenClawActionsFromMarkdown(content: string) { const blocks: OpenClawActionsBlock[] = []; - const markdown = String(content || "").replace( + const forms: OpenClawSecureForm[] = []; + const withoutActions = String(content || "").replace( OPENCLAW_ACTIONS_CODE_BLOCK_REGEX, (_match, rawJson) => { try { @@ -1804,10 +1885,24 @@ function extractOpenClawActionsFromMarkdown(content: string) { return "\n"; } ); + const markdown = withoutActions.replace( + OPENCLAW_FORM_CODE_BLOCK_REGEX, + (_match, rawJson) => { + try { + const parsed = JSON.parse(String(rawJson || "").trim()); + const form = parseOpenClawSecureFormPayload(parsed); + if (form) forms.push(form); + } catch { + // Invalid secure-form JSON should not break normal Markdown rendering. + } + return "\n"; + } + ); return { markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(), blocks, + forms, }; } @@ -2139,6 +2234,89 @@ function OpenClawQuickActions({ ); } +function OpenClawSecureForms({ + forms, + disabled, + onSubmit, +}: { + forms: OpenClawSecureForm[]; + disabled: boolean; + onSubmit: (form: OpenClawSecureForm, values: Record) => Promise; +}) { + const [valuesByForm, setValuesByForm] = useState>>({}); + const [submittingIndex, setSubmittingIndex] = useState(null); + if (forms.length === 0) return null; + + return ( +
+ {forms.map((form, formIndex) => { + const values = valuesByForm[formIndex] || {}; + const complete = form.fields.every((field) => Boolean(values[field.name])); + const submitting = submittingIndex === formIndex; + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + if (disabled || submitting || !complete) return; + setSubmittingIndex(formIndex); + try { + await onSubmit(form, { ...values }); + } finally { + setValuesByForm((current) => ({ ...current, [formIndex]: {} })); + setSubmittingIndex(null); + } + }} + > +
+ + {form.title} +
+
+ {form.fields.map((field) => ( + + ))} +
+ +

+ 仅用于本次登录验证,不写入聊天记录。 +

+
+ ); + })} +
+ ); +} + function CanvasAssistantTypingPlaceholder({ hint, label = "分析中", @@ -2902,6 +3080,7 @@ export function CanvasChatPanel({ const [messageViewportKey, setMessageViewportKey] = useState(0); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); + const clawdFolderInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const agentSlotInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); @@ -4410,8 +4589,13 @@ export function CanvasChatPanel({ [] ); - // 🆕 辅助函数:在 contenteditable 中插入图片标签 - const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => { + // 在 contenteditable 中插入图片、视频或文件夹压缩包附件标签 + const insertImageTagInEditor = useCallback(( + url: string, + name: string, + index: number, + contentType?: string + ) => { const editableDiv = textareaRef.current as HTMLDivElement | null; if (!editableDiv) return; if (!isValidChatHttpUrl(url)) { @@ -4435,11 +4619,40 @@ export function CanvasChatPanel({ tagSpan.setAttribute('data-attachment-name', name); tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none'; const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name; + const isVideo = isOpenClawChatVideoAttachment(url, contentType); + const isArchive = isOpenClawChatArchiveAttachment(url, contentType); - const image = document.createElement('img'); - image.src = buildPreviewImageUrl(url, 48); - image.alt = ''; - image.className = 'h-5 w-5 object-cover rounded-full border border-border/50'; + let mediaPreview: HTMLElement; + if (isArchive) { + mediaPreview = document.createElement('span'); + mediaPreview.className = + 'inline-flex h-5 min-w-7 items-center justify-center rounded border border-border/60 bg-background px-1 text-[9px] font-semibold text-muted-foreground'; + mediaPreview.textContent = 'ZIP'; + mediaPreview.setAttribute('aria-label', '文件夹压缩包'); + } else if (isVideo) { + const videoPreview = document.createElement('video'); + videoPreview.src = url; + videoPreview.className = + 'h-5 w-5 object-cover rounded-full border border-border/50 bg-black/5'; + videoPreview.muted = true; + videoPreview.preload = 'metadata'; + videoPreview.playsInline = true; + videoPreview.setAttribute('aria-label', '视频附件'); + mediaPreview = videoPreview; + } else { + const imagePreview = document.createElement('img'); + imagePreview.src = buildPreviewImageUrl(url, 48); + imagePreview.className = + 'h-5 w-5 object-cover rounded-full border border-border/50 bg-black/5'; + imagePreview.alt = ''; + mediaPreview = imagePreview; + } + if (mediaPreview instanceof HTMLVideoElement) { + mediaPreview.muted = true; + mediaPreview.preload = 'metadata'; + mediaPreview.playsInline = true; + mediaPreview.setAttribute('aria-label', '视频附件'); + } const label = document.createElement('span'); label.className = 'text-xs text-foreground/80'; @@ -4452,7 +4665,7 @@ export function CanvasChatPanel({ removeButton.className = 'ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-[11px] leading-none text-foreground/45 hover:bg-black/10 hover:text-foreground dark:hover:bg-white/15'; removeButton.textContent = '×'; - tagSpan.appendChild(image); + tagSpan.appendChild(mediaPreview); tagSpan.appendChild(label); tagSpan.appendChild(removeButton); @@ -4774,8 +4987,14 @@ export function CanvasChatPanel({ const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true'; const objectName = tagSpan.getAttribute('data-object-name'); const attachmentName = tagSpan.getAttribute('data-attachment-name'); + const tagContentType = + tagSpan.getAttribute('data-attachment-content-type') || undefined; if (url) { + if (isOpenClawChatArchiveAttachment(url, tagContentType)) { + setTagPreview(null); + return; + } const rect = tagSpan.getBoundingClientRect(); // 从 attachments 中获取物品信息 const attachment = attachments.find(a => @@ -4799,6 +5018,10 @@ export function CanvasChatPanel({ setTagPreview({ url, name: objectName || attachmentName || tagSpan.textContent || '图片', + contentType: + attachment?.contentType || + tagContentType || + undefined, x: rect.left + rect.width / 2, y: rect.top, containerBounds, @@ -11784,7 +12007,10 @@ export function CanvasChatPanel({ ]); // 处理发送(图片已在上传时添加到画布,这里只发送消息) - const handleSend = useCallback(async (overrideInput?: string) => { + const handleSend = useCallback(async ( + overrideInput?: string, + sensitiveInput?: OpenClawSensitiveInput + ) => { const currentSendGuard = getSendGuard(activeChatId); if (currentSendGuard.inFlight || status === "streaming" || status === "submitted") { return; @@ -11851,23 +12077,40 @@ export function CanvasChatPanel({ attachment.contentType ) ); + const folderAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter( + (attachment) => + isOpenClawChatArchiveAttachment( + attachment.url, + attachment.contentType + ) + ); const imageAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter( (attachment) => !isOpenClawChatVideoAttachment( attachment.url, attachment.contentType + ) && + !isOpenClawChatArchiveAttachment( + attachment.url, + attachment.contentType ) ); const hasImageAttachments = imageAttachmentsSnapshot.length > 0; const hasVideoAttachments = videoAttachmentsSnapshot.length > 0; + const hasFolderAttachments = folderAttachmentsSnapshot.length > 0; const openClawMediaAttachments = normalizeOpenClawChatMediaAttachments( sanitizedAttachmentsSnapshot.map((attachment) => ({ type: isOpenClawChatVideoAttachment( - attachment.url, - attachment.contentType - ) + attachment.url, + attachment.contentType + ) ? "video" - : "image", + : isOpenClawChatArchiveAttachment( + attachment.url, + attachment.contentType + ) + ? "file" + : "image", url: attachment.url, name: normalizeChatAttachmentName(attachment.name), content_type: attachment.contentType, @@ -11879,6 +12122,7 @@ export function CanvasChatPanel({ !hasTableFiles && !hasImageAttachments && !hasVideoAttachments && + !hasFolderAttachments && !hasPendingClawdSkillTagPrompt ) { return; @@ -11891,7 +12135,8 @@ export function CanvasChatPanel({ : getClawdDefaultPrompt( hasTableFiles, hasImageAttachments, - hasVideoAttachments + hasVideoAttachments, + hasFolderAttachments )); const imageReferenceText = buildClawdImageReferenceText( imageAttachmentsSnapshot @@ -11899,11 +12144,15 @@ export function CanvasChatPanel({ const videoReferenceText = buildOpenClawVideoReferenceText( openClawMediaAttachments ); + const folderReferenceText = buildOpenClawArchiveReferenceText( + openClawMediaAttachments + ); const requestMessage = [ baseRequestMessage, clawdSkillTagPrompt, imageReferenceText, videoReferenceText, + folderReferenceText, ] .filter(Boolean) .join("\n\n"); @@ -11923,6 +12172,7 @@ export function CanvasChatPanel({ buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot), buildClawdImageDisplayText(imageAttachmentsSnapshot), buildOpenClawVideoDisplayText(openClawMediaAttachments), + buildOpenClawArchiveDisplayText(openClawMediaAttachments), ] .filter(Boolean) .join("\n\n"); @@ -11943,6 +12193,11 @@ export function CanvasChatPanel({ .map((attachment) => normalizeMediaUrl(attachment.url)) .filter(Boolean) .sort(), + folderAttachments: folderAttachmentsSnapshot + .map((attachment) => normalizeMediaUrl(attachment.url)) + .filter(Boolean) + .sort(), + sensitiveType: sensitiveInput?.type || null, }); if (shouldBlockDuplicateSend(requestChatId, sendSignature)) { @@ -11954,7 +12209,8 @@ export function CanvasChatPanel({ [textContent, clawdSkillTagPrompt].filter(Boolean).join("\n\n"), hasTableFiles, hasImageAttachments, - hasVideoAttachments + hasVideoAttachments, + hasFolderAttachments ) ? CLAWD_PROCESSING_HINT : null; @@ -12071,6 +12327,9 @@ export function CanvasChatPanel({ formData.append("userMessageId", userMessageId); formData.append("message", requestMessage); formData.append("displayMessage", optimisticMessageText); + if (sensitiveInput) { + formData.append("sensitiveInput", JSON.stringify(sensitiveInput)); + } formData.append( "metadata", JSON.stringify({ @@ -12078,6 +12337,7 @@ export function CanvasChatPanel({ activeChatId: requestChatId, imageAttachmentCount: imageAttachmentsSnapshot.length || undefined, videoAttachmentCount: videoAttachmentsSnapshot.length || undefined, + folderAttachmentCount: folderAttachmentsSnapshot.length || undefined, skillTagId: clawdSkillTagForRequest?.id || undefined, skillTagName: clawdSkillTagForRequest?.name || undefined, skillTagPromptIncluded: hasPendingClawdSkillTagPrompt || undefined, @@ -12666,6 +12926,28 @@ export function CanvasChatPanel({ [handleSend, isClawdMode] ); + const handleOpenClawSecureForm = useCallback( + async (form: OpenClawSecureForm, values: Record) => { + if (!isClawdMode) { + toast.error(`请先切换到 ${CLAW_MODE_LABEL} 对话后再登录`); + return; + } + if (form.formType === "taobao_credentials") { + await handleSend(form.submitMessage, { + type: "taobao_credentials", + username: values.username || "", + password: values.password || "", + }); + return; + } + await handleSend(form.submitMessage, { + type: "taobao_verification", + verification_code: values.verification_code || "", + }); + }, + [handleSend, isClawdMode] + ); + const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => { if (!firstImageUrl || hasSetCanvasTitle.current) { return; @@ -13049,6 +13331,157 @@ export function CanvasChatPanel({ } }, [clearComposerUploadProgress, updateComposerUploadProgress]); + const uploadClawdFolderForComposerAttachment = useCallback(async ( + files: File[] + ): Promise => { + if (files.length === 0) { + toast.error("所选文件夹中没有文件"); + return null; + } + if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) { + toast.error(`单次最多上传 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件`); + return null; + } + + const totalBytes = files.reduce((sum, file) => sum + file.size, 0); + if (totalBytes > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) { + toast.error("文件夹总大小不能超过 300MB"); + return null; + } + + const relativePaths = files.map( + (file) => file.webkitRelativePath || file.name + ); + const rootFolders = Array.from( + new Set( + relativePaths + .map((path) => path.replace(/\\/g, "/").split("/")[0]) + .filter(Boolean) + ) + ); + const archiveBaseName = + rootFolders.length === 1 + ? rootFolders[0] + : `${rootFolders.length}个文件夹-${Date.now()}`; + const progressOptions: CanvasUploadProgressOptions = { + title: "正在打包并上传文件夹", + fileName: `${archiveBaseName}.zip`, + target: "composer-reference", + }; + const formData = new FormData(); + files.forEach((file, index) => { + formData.append("files", file, file.name); + formData.append("relativePaths", relativePaths[index]); + }); + formData.append("archiveName", archiveBaseName); + + updateComposerUploadProgress(progressOptions, { + loaded: 0, + total: totalBytes, + percent: 1, + phase: "preparing", + }); + + try { + const data = await new Promise>( + (resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/canvas/clawd-chat/folder-upload"); + xhr.timeout = 10 * 60 * 1000; + xhr.upload.onprogress = (event) => { + const total = event.lengthComputable ? event.total : totalBytes; + const rawPercent = total > 0 ? (event.loaded / total) * 100 : 5; + updateComposerUploadProgress(progressOptions, { + loaded: event.loaded, + total, + percent: Math.max(1, Math.min(92, Math.round(rawPercent))), + phase: "server", + }); + }; + xhr.upload.onload = () => { + updateComposerUploadProgress(progressOptions, { + loaded: totalBytes, + total: totalBytes, + percent: 94, + phase: "finalizing", + }); + }; + xhr.onerror = () => reject(new Error("文件夹上传网络异常")); + xhr.ontimeout = () => reject(new Error("文件夹打包上传超时")); + xhr.onload = () => { + let payload: Record = {}; + try { + payload = JSON.parse(xhr.responseText || "{}") as Record< + string, + unknown + >; + } catch { + reject(new Error("文件夹上传返回格式不正确")); + return; + } + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(String(payload.error || "文件夹上传失败"))); + return; + } + resolve(payload); + }; + xhr.send(formData); + } + ); + + const url = String(data.url || "").trim(); + if (!isValidChatHttpUrl(url)) { + throw new Error("七牛云未返回可访问的文件地址"); + } + updateComposerUploadProgress(progressOptions, { + loaded: totalBytes, + total: totalBytes, + percent: 100, + phase: "complete", + }); + clearComposerUploadProgress(350); + return { + imageUrl: url, + name: String(data.name || `${archiveBaseName}.zip`), + contentType: "application/zip", + }; + } catch (error) { + clearComposerUploadProgress(); + toast.error(error instanceof Error ? error.message : "文件夹上传失败"); + return null; + } + }, [clearComposerUploadProgress, updateComposerUploadProgress]); + + const handleClawdFolderSelect = useCallback(async ( + event: React.ChangeEvent + ) => { + const files = Array.from(event.target.files || []); + event.target.value = ""; + if (files.length === 0) return; + + const existingArchiveCount = attachments.filter((attachment) => + isOpenClawChatArchiveAttachment( + attachment.url, + attachment.contentType + ) + ).length; + if (existingArchiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) { + toast.info( + `一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个文件夹压缩包` + ); + return; + } + + const uploaded = await uploadClawdFolderForComposerAttachment(files); + if (!uploaded) return; + addUploadedImageAttachmentToComposer(uploaded); + toast.success("文件夹已打包上传到七牛云,可继续添加其他文件夹"); + }, [ + addUploadedImageAttachmentToComposer, + attachments, + uploadClawdFolderForComposerAttachment, + ]); + const handlePastedImages = useCallback(async (files: File[]) => { if (files.length === 0) return; @@ -13814,7 +14247,7 @@ export function CanvasChatPanel({ ? activeClawdSkillTag ? activeClawdSkillTag.placeholder?.trim() || `继续向 ${CLAW_MODE_LABEL} 提问,当前使用「${activeClawdSkillTag.name}」` - : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频或表格` + : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频、表格或文件夹` : isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : isVideoWorkspace @@ -13828,7 +14261,7 @@ export function CanvasChatPanel({ ? activeClawdSkillTag ? activeClawdSkillTag.placeholder?.trim() || `继续向 ${CLAW_MODE_LABEL} 提问,当前使用「${activeClawdSkillTag.name}」` - : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频或表格` + : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频、表格或文件夹` : isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : isVideoWorkspace @@ -15192,6 +15625,45 @@ export function CanvasChatPanel({ message.role === "user" ? collectMessageVideoAttachments(message as any) : []; + const userArchiveAttachments: Array<{ + url: string; + name: string; + contentType?: string; + }> = + message.role === "user" + ? Array.from( + new Map( + [ + ...(Array.isArray(message.parts) ? message.parts : []), + ...(Array.isArray((message as any).attachments) + ? (message as any).attachments + : []), + ] + .map((part: any) => { + const url = String(part?.url || part?.data || "").trim(); + const contentType = String( + part?.mediaType || part?.contentType || "" + ).trim(); + return { + url, + name: String(part?.name || "Folder.zip").trim() || "Folder.zip", + contentType: contentType || undefined, + }; + }) + .filter((attachment) => + isOpenClawChatArchiveAttachment( + attachment.url, + attachment.contentType + ) + ) + .map((attachment) => [attachment.url, attachment]) + ).values() + ) + : []; const assistantTextParts = message.role === "assistant" ? (message.parts || []).filter((part: any) => part.type === "text" && part.text) : []; @@ -15410,6 +15882,29 @@ export function CanvasChatPanel({ ); }); + userArchiveAttachments.forEach((attachment, idx) => { + renderedUserParts.push( + + + + {attachment.name} + + + ); + }); + message.parts?.forEach((part: any, idx: number) => { if (part.type === 'text' && part.text) { const normalizedText = String(part.text) @@ -15483,13 +15978,14 @@ export function CanvasChatPanel({ ); const openClawActionsResult = isClawAssistantMessage ? extractOpenClawActionsFromMarkdown(textWithoutImages) - : { markdown: textWithoutImages, blocks: [] }; + : { markdown: textWithoutImages, blocks: [], forms: [] }; const textWithoutActions = isClawAssistantMessage ? stripOpenClawLowValueDiagnosticTables( openClawActionsResult.markdown ) : openClawActionsResult.markdown; const openClawActionBlocks = openClawActionsResult.blocks; + const openClawSecureForms = openClawActionsResult.forms; const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions); const plainText = normalizeCanvasAgentText(textWithoutActions); const analysisToggleKey = `${message.id}-${i}-analysis`; @@ -15796,6 +16292,18 @@ export function CanvasChatPanel({ onAction={handleOpenClawQuickAction} /> ) : null} + {openClawSecureForms.length > 0 ? ( + + ) : null} ); } @@ -17443,6 +17951,21 @@ export function CanvasChatPanel({ className="hidden" onChange={handleFileSelect} /> + { + clawdFolderInputRef.current = element; + if (element) { + element.setAttribute("webkitdirectory", ""); + element.setAttribute("directory", ""); + } + }} + type="file" + multiple + className="hidden" + onChange={(event) => { + void handleClawdFolderSelect(event); + }} + /> {videoFrameSlots.map((_, index) => ( ))} {!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && (isClawdMode || !isVideoModel || showVideoAttachmentButton) && ( - + + + { + window.setTimeout(() => fileInputRef.current?.click(), 0); + }} + > + + 上传文件 + + { + window.setTimeout( + () => clawdFolderInputRef.current?.click(), + 0 + ); + }} + > + + 上传文件夹(可重复添加) + + + + ) : ( + + + ) )} {!isWorkspacePresentation ? ( |\u0000-\u001f]/g, "-") + .replace(/\s+/g, " ") + .replace(/^\.+|\.+$/g, "") + .trim() + .slice(0, 120); + return normalized || `Claw文件夹-${Date.now()}`; +} + +function sanitizeArchivePathSegment(value: string, fallback: string) { + const normalized = String(value || "") + .normalize("NFKC") + .replace(/[\\:*?"<>|\u0000-\u001f]/g, "-") + .replace(/\s+/g, " ") + .replace(/^\.+|\.+$/g, "") + .trim() + .slice(0, 160); + return normalized || fallback; +} + +export function sanitizeOpenClawArchiveEntryPath( + relativePath: string, + fallbackFileName: string, + index: number +) { + const fallback = sanitizeArchivePathSegment( + fallbackFileName, + `文件-${index + 1}` + ); + const segments = String(relativePath || fallback) + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment && segment !== "." && segment !== "..") + .slice(0, 30) + .map((segment, segmentIndex) => + sanitizeArchivePathSegment(segment, `目录-${segmentIndex + 1}`) + ); + return (segments.length > 0 ? segments : [fallback]).join("/"); +} + +export function countOpenClawArchiveRootFolders(paths: string[]) { + return new Set( + paths + .map((path) => String(path || "").replace(/\\/g, "/").split("/")[0]) + .filter(Boolean) + ).size; +} diff --git a/lib/openclaw-chat/media-attachments.ts b/lib/openclaw-chat/media-attachments.ts index 0156b52e9..a2b13c3dc 100644 --- a/lib/openclaw-chat/media-attachments.ts +++ b/lib/openclaw-chat/media-attachments.ts @@ -1,6 +1,10 @@ export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video"; +export const OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE = "openclaw-chat-folder"; export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2; export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = 500 * 1024 * 1024; +export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5; +export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000; +export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = 300 * 1024 * 1024; 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"; @@ -22,9 +26,10 @@ const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record = { }; const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i; +const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i; export type OpenClawChatMediaAttachment = { - type: "image" | "video"; + type: "image" | "video" | "file"; url: string; name?: string; content_type?: string; @@ -88,6 +93,18 @@ export function isOpenClawChatVideoAttachment( ); } +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 normalizeOpenClawChatMediaAttachments( value: unknown ): OpenClawChatMediaAttachment[] { @@ -98,6 +115,7 @@ export function normalizeOpenClawChatMediaAttachments( const result: OpenClawChatMediaAttachment[] = []; const seenUrls = new Set(); let videoCount = 0; + let archiveCount = 0; for (const item of value) { if (!item || typeof item !== "object") { @@ -117,20 +135,36 @@ export function normalizeOpenClawChatMediaAttachments( const isVideo = requestedType === "video" || isOpenClawChatVideoAttachment(url, rawContentType); + const isArchive = + !isVideo && + (requestedType === "file" || + requestedType === "archive" || + isOpenClawChatArchiveAttachment(url, rawContentType)); if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) { continue; } + if (isArchive && archiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) { + continue; + } if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) { break; } - const type = isVideo ? "video" : "image"; + const type = isVideo ? "video" : isArchive ? "file" : "image"; const fallbackContentType = isVideo ? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4" - : "image/jpeg"; - const contentType = - rawContentType.startsWith(`${type}/`) ? rawContentType : fallbackContentType; + : isArchive + ? "application/zip" + : "image/jpeg"; + const contentType = isArchive + ? rawContentType === "application/zip" || + rawContentType === "application/x-zip-compressed" + ? rawContentType + : fallbackContentType + : rawContentType.startsWith(`${type}/`) + ? rawContentType + : fallbackContentType; const name = String(raw.name || "") .trim() .slice(0, 200); @@ -145,6 +179,9 @@ export function normalizeOpenClawChatMediaAttachments( if (isVideo) { videoCount += 1; } + if (isArchive) { + archiveCount += 1; + } } return result; @@ -181,3 +218,40 @@ export function buildOpenClawVideoReferenceText( 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 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"); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 1c1ca1287..abc670e50 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ /routes\/openclaw-config\.test\.ts/, /routes\/openclaw-durable-task-state\.test\.ts/, /routes\/openclaw-request-state\.test\.ts/, + /routes\/openclaw-folder-attachments\.test\.ts/, /routes\/openclaw-video-attachments\.test\.ts/, /routes\/hicad-embedded-performance\.test\.ts/, /routes\/hicad-manufacturing-extension\.test\.ts/, diff --git a/tests/routes/openclaw-folder-attachments.test.ts b/tests/routes/openclaw-folder-attachments.test.ts new file mode 100644 index 000000000..d6842202d --- /dev/null +++ b/tests/routes/openclaw-folder-attachments.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from "@playwright/test"; +import { + countOpenClawArchiveRootFolders, + sanitizeOpenClawArchiveEntryPath, + sanitizeOpenClawArchiveName, +} from "@/lib/openclaw-chat/folder-archive"; +import { + buildOpenClawArchiveDisplayText, + buildOpenClawArchiveReferenceText, + isOpenClawChatArchiveAttachment, + normalizeOpenClawChatMediaAttachments, +} from "@/lib/openclaw-chat/media-attachments"; + +test.describe("OpenClaw folder attachments", () => { + test("keeps safe folder structure while removing traversal segments", () => { + expect( + sanitizeOpenClawArchiveEntryPath( + "../../设计资料/主图:终稿.png", + "主图.png", + 0 + ) + ).toBe("设计资料/主图-终稿.png"); + expect(sanitizeOpenClawArchiveName(" 商品/资料:* ")).toBe("商品-资料--"); + }); + + test("counts multiple selected root folders", () => { + expect( + countOpenClawArchiveRootFolders([ + "产品A/图片/1.png", + "产品A/文档/readme.md", + "产品B/图片/2.png", + ]) + ).toBe(2); + }); + + test("normalizes ZIP URLs as file attachments", () => { + const attachments = normalizeOpenClawChatMediaAttachments([ + { + type: "file", + url: "https://cdn.example.com/产品资料.zip", + name: "产品资料.zip", + contentType: "application/zip", + }, + ]); + + expect(attachments).toEqual([ + { + type: "file", + url: "https://cdn.example.com/产品资料.zip", + name: "产品资料.zip", + content_type: "application/zip", + }, + ]); + expect( + isOpenClawChatArchiveAttachment( + attachments[0].url, + attachments[0].content_type + ) + ).toBe(true); + }); + + test("adds the public archive URL to the OpenClaw fallback message", () => { + const attachments = normalizeOpenClawChatMediaAttachments([ + { + type: "file", + url: "https://cdn.example.com/catalog.zip", + name: "catalog.zip", + }, + ]); + + expect(buildOpenClawArchiveDisplayText(attachments)).toContain("catalog.zip"); + expect(buildOpenClawArchiveReferenceText(attachments)).toContain( + "https://cdn.example.com/catalog.zip" + ); + expect(buildOpenClawArchiveReferenceText(attachments)).toContain("解压"); + }); +});