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 5357c57c6..54a038a95 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, @@ -307,12 +307,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, @@ -807,9 +813,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; } @@ -993,12 +1005,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("、")}内容` @@ -1595,6 +1609,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; @@ -1684,6 +1718,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 || "") @@ -1739,9 +1774,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 { @@ -1756,10 +1837,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, }; } @@ -2091,6 +2186,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 = "分析中", @@ -2829,6 +3007,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>([]); @@ -4322,7 +4501,7 @@ export function CanvasChatPanel({ [] ); - // 在 contenteditable 中插入图片或视频附件标签 + // 在 contenteditable 中插入图片、视频或文件夹压缩包附件标签 const insertImageTagInEditor = useCallback(( url: string, name: string, @@ -4356,16 +4535,34 @@ export function CanvasChatPanel({ 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 mediaPreview = isVideo - ? document.createElement('video') - : document.createElement('img'); - mediaPreview.src = isVideo ? url : buildPreviewImageUrl(url, 48); - mediaPreview.className = - 'h-5 w-5 object-cover rounded-full border border-border/50 bg-black/5'; - if (mediaPreview instanceof HTMLImageElement) { - mediaPreview.alt = ''; + 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; @@ -4861,8 +5058,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 => @@ -4888,7 +5091,7 @@ export function CanvasChatPanel({ name: objectName || attachmentName || tagSpan.textContent || '图片', contentType: attachment?.contentType || - tagSpan.getAttribute('data-attachment-content-type') || + tagContentType || undefined, x: rect.left + rect.width / 2, y: rect.top, @@ -11889,7 +12092,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 || @@ -11961,23 +12167,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, @@ -11989,6 +12212,7 @@ export function CanvasChatPanel({ !hasTableFiles && !hasImageAttachments && !hasVideoAttachments && + !hasFolderAttachments && !hasPendingClawdSkillTagPrompt ) { return; @@ -12001,7 +12225,8 @@ export function CanvasChatPanel({ : getClawdDefaultPrompt( hasTableFiles, hasImageAttachments, - hasVideoAttachments + hasVideoAttachments, + hasFolderAttachments )); const imageReferenceText = buildClawdImageReferenceText( imageAttachmentsSnapshot @@ -12009,11 +12234,15 @@ export function CanvasChatPanel({ const videoReferenceText = buildOpenClawVideoReferenceText( openClawMediaAttachments ); + const folderReferenceText = buildOpenClawArchiveReferenceText( + openClawMediaAttachments + ); const requestMessage = [ baseRequestMessage, clawdSkillTagPrompt, imageReferenceText, videoReferenceText, + folderReferenceText, ] .filter(Boolean) .join("\n\n"); @@ -12033,6 +12262,7 @@ export function CanvasChatPanel({ buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot), buildClawdImageDisplayText(imageAttachmentsSnapshot), buildOpenClawVideoDisplayText(openClawMediaAttachments), + buildOpenClawArchiveDisplayText(openClawMediaAttachments), ] .filter(Boolean) .join("\n\n"); @@ -12053,6 +12283,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)) { @@ -12064,7 +12299,8 @@ export function CanvasChatPanel({ [textContent, clawdSkillTagPrompt].filter(Boolean).join("\n\n"), hasTableFiles, hasImageAttachments, - hasVideoAttachments + hasVideoAttachments, + hasFolderAttachments ) ? CLAWD_PROCESSING_HINT : null; @@ -12181,6 +12417,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({ @@ -12188,6 +12427,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, @@ -12832,6 +13072,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; @@ -13204,6 +13466,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; @@ -13981,7 +14394,7 @@ export function CanvasChatPanel({ ? activeClawdSkillTag ? activeClawdSkillTag.placeholder?.trim() || `继续向 ${CLAW_MODE_LABEL} 提问,当前使用「${activeClawdSkillTag.name}」` - : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片或表格` + : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频、表格或文件夹` : isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : isVideoWorkspace @@ -13995,7 +14408,7 @@ export function CanvasChatPanel({ ? activeClawdSkillTag ? activeClawdSkillTag.placeholder?.trim() || `继续向 ${CLAW_MODE_LABEL} 提问,当前使用「${activeClawdSkillTag.name}」` - : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片或表格` + : `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频、表格或文件夹` : isDetailGenerationMode ? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)" : isVideoWorkspace @@ -15536,22 +15949,31 @@ export function CanvasChatPanel({ : ""; if (part.type === 'file' && fileUrl) { + const attachmentContentType = String( + part.mediaType || part.contentType || "" + ).toLowerCase(); const isVideoAttachment = isLikelyVideoAttachment( fileUrl, - String(part.mediaType || part.contentType || "").toLowerCase() + attachmentContentType ); - renderedUserParts.push( - + const isArchiveAttachment = + isOpenClawChatArchiveAttachment( + fileUrl, + attachmentContentType + ); + const attachmentLabel = + part.name || + (isVideoAttachment + ? 'Video' + : isArchiveAttachment + ? 'Folder.zip' + : 'Image'); + const attachmentContent = ( + <> {isVideoAttachment ? (