diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts index 6b168b7a6..bfdb9fa1f 100644 --- a/app/(chat)/api/files/direct-upload/route.ts +++ b/app/(chat)/api/files/direct-upload/route.ts @@ -3,6 +3,7 @@ import { CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, + ListPartsCommand, PutObjectCommand, UploadPartCommand, } from "@aws-sdk/client-s3"; @@ -21,13 +22,22 @@ import { maybeEnqueueStorageMigrationTask, resolveS3UploadTarget, } from "@/lib/storage/service"; -import { OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE } from "@/lib/openclaw-chat/media-attachments"; +import { + getOpenClawChatFileContentType, + isOpenClawChatFolderFileAllowed, + OPENCLAW_CHAT_FILE_MAX_BYTES, + OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE, + OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE, + OPENCLAW_CHAT_VIDEO_MAX_BYTES, + OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE, +} from "@/lib/openclaw-chat/media-attachments"; const logger = createLogger("direct-upload-api"); -const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024; +const DIRECT_UPLOAD_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES; +const DIRECT_UPLOAD_MEDIA_MAX_BYTES = 500 * 1024 * 1024; const MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024; const DEFAULT_PART_SIZE_BYTES = 8 * 1024 * 1024; -const PRESIGNED_URL_EXPIRES_SECONDS = 15 * 60; +const PRESIGNED_URL_EXPIRES_SECONDS = 6 * 60 * 60; const DIRECT_UPLOAD_PREFIX = "uploads/direct"; function elapsedMs(startedAt: number) { @@ -76,6 +86,18 @@ const CompleteSchema = z.object({ clientUploadId: z.string().max(128).optional(), }); +const ResumeSchema = z.object({ + action: z.literal("resume"), + key: z.string().min(1).max(512), + uploadId: z.string().min(1).max(512), + filename: z.string().min(1).max(256), + contentType: z.string().min(1).max(128), + size: z.number().int().positive().max(DIRECT_UPLOAD_MAX_BYTES), + partSize: z.number().int().min(5 * 1024 * 1024).max(128 * 1024 * 1024), + purpose: z.string().max(64).optional(), + clientUploadId: z.string().max(128).optional(), +}); + const AbortSchema = z.object({ action: z.literal("abort"), key: z.string().min(1).max(512), @@ -85,6 +107,7 @@ const AbortSchema = z.object({ const RequestSchema = z.discriminatedUnion("action", [ InitiateSchema, + ResumeSchema, CompleteSchema, AbortSchema, ]); @@ -112,23 +135,49 @@ function assertUserOwnedKey(key: string, userId: string) { const normalized = String(key || "").replace(/^\/+/, ""); const prefix = `${userUploadPrefix(userId)}/`; if (!normalized.startsWith(prefix)) { - throw new Error("Invalid upload object key"); + const error = new Error("Invalid upload object key"); + error.name = "InvalidUploadObjectKeyError"; + throw error; } return normalized; } -function isAllowedDirectUpload(contentType: string, purpose?: string) { +function isAllowedDirectUpload( + contentType: string, + purpose: string | undefined, + filename: string +) { return ( allowedDirectUploadTypes.has(contentType) || (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE && - allowedOpenClawVideoUploadTypes.has(contentType)) + allowedOpenClawVideoUploadTypes.has(contentType)) || + (purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE && + Boolean( + getOpenClawChatFileContentType({ name: filename, type: contentType }) + )) || + (purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE && + isOpenClawChatFolderFileAllowed({ name: filename, type: contentType })) ); } +function getPurposeMaxBytes(purpose?: string) { + if (purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE) { + return OPENCLAW_CHAT_VIDEO_MAX_BYTES; + } + if ( + purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || + purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ) { + return DIRECT_UPLOAD_MAX_BYTES; + } + return DIRECT_UPLOAD_MEDIA_MAX_BYTES; +} + function computePartSize(size: number) { if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES; - if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024; - return 16 * 1024 * 1024; + if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024; + if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024; + return 64 * 1024 * 1024; } async function createPartUrls(params: { @@ -136,24 +185,24 @@ async function createPartUrls(params: { bucket: string; key: string; uploadId: string; - partCount: number; + partNumbers: number[]; }) { - const urls = []; - for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) { - const command = new UploadPartCommand({ - Bucket: params.bucket, - Key: params.key, - UploadId: params.uploadId, - PartNumber: partNumber, - }); - urls.push({ - partNumber, - url: await getSignedUrl(params.client, command, { - expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, - }), - }); - } - return urls; + return Promise.all( + params.partNumbers.map(async (partNumber) => { + const command = new UploadPartCommand({ + Bucket: params.bucket, + Key: params.key, + UploadId: params.uploadId, + PartNumber: partNumber, + }); + return { + partNumber, + url: await getSignedUrl(params.client, command, { + expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, + }), + }; + }) + ); } export async function POST(request: Request) { @@ -216,7 +265,7 @@ export async function POST(request: Request) { payload.filename ); - if (!isAllowedDirectUpload(contentType, payload.purpose)) { + if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) { logger.warn("unsupported_direct_upload_type", { contentType, filenameExtension: payload.filename.toLowerCase().split(".").pop() || null, @@ -227,11 +276,17 @@ export async function POST(request: Request) { return NextResponse.json( { error: - "直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频", + "直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单", }, { status: 400 } ); } + if (payload.size > getPurposeMaxBytes(payload.purpose)) { + return NextResponse.json( + { error: `文件不能超过 ${getPurposeMaxBytes(payload.purpose) / 1024 / 1024}MB` }, + { status: 413 } + ); + } const displayName = sanitizeDisplayName(payload.filename); const key = buildStorageObjectKey( @@ -239,7 +294,13 @@ export async function POST(request: Request) { userUploadPrefix(session.user.id) ); - if (payload.size < MULTIPART_THRESHOLD_BYTES) { + const multipartThreshold = + payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || + payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE || + payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ? DEFAULT_PART_SIZE_BYTES + : MULTIPART_THRESHOLD_BYTES; + if (payload.size < multipartThreshold) { const presignStartedAt = Date.now(); const command = new PutObjectCommand({ Bucket: bucket, @@ -299,7 +360,7 @@ export async function POST(request: Request) { bucket, key, uploadId, - partCount, + partNumbers: Array.from({ length: partCount }, (_, index) => index + 1), }); logger.info("direct_upload_multipart_prepared", { elapsedMs: elapsedMs(multipartStartedAt), @@ -323,6 +384,98 @@ export async function POST(request: Request) { }); } + if (payload.action === "resume") { + const key = assertUserOwnedKey(payload.key, session.user.id); + const contentType = normalizeContentType(payload.contentType, payload.filename); + if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) { + return NextResponse.json({ error: "不支持续传该文件类型" }, { status: 400 }); + } + if (payload.size > getPurposeMaxBytes(payload.purpose)) { + return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 }); + } + const expectedPartSize = computePartSize(payload.size); + if (payload.partSize !== expectedPartSize) { + return NextResponse.json( + { error: "续传分片参数已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" }, + { status: 409 } + ); + } + + try { + const listed = await client.send( + new ListPartsCommand({ + Bucket: bucket, + Key: key, + UploadId: payload.uploadId, + MaxParts: 1000, + }) + ); + const uploadedParts = (listed.Parts || []).flatMap((part) => { + if (!part.ETag || typeof part.PartNumber !== "number") return []; + return [ + { + ETag: part.ETag, + PartNumber: part.PartNumber, + size: Number(part.Size || 0), + }, + ]; + }); + const uploadedPartNumbers = new Set( + uploadedParts.map((part) => part.PartNumber) + ); + const partCount = Math.ceil(payload.size / expectedPartSize); + const missingPartNumbers = Array.from( + { length: partCount }, + (_, index) => index + 1 + ).filter((partNumber) => !uploadedPartNumbers.has(partNumber)); + const parts = await createPartUrls({ + client, + bucket, + key, + uploadId: payload.uploadId, + partNumbers: missingPartNumbers, + }); + + logger.info("direct_upload_multipart_resumed", { + totalElapsedMs: elapsedMs(requestStartedAt), + size: payload.size, + uploadedPartCount: uploadedParts.length, + missingPartCount: missingPartNumbers.length, + clientUploadId: payload.clientUploadId || null, + }); + return NextResponse.json({ + mode: "multipart", + key, + uploadId: payload.uploadId, + partSize: expectedPartSize, + partCount, + parts, + uploadedParts, + resumed: true, + expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, + clientUploadId: payload.clientUploadId || null, + }); + } catch (error) { + const head = await client + .send(new HeadObjectCommand({ Bucket: bucket, Key: key })) + .catch(() => null); + if (head?.ContentLength === payload.size) { + return NextResponse.json({ + mode: "completed", + key, + clientUploadId: payload.clientUploadId || null, + }); + } + logger.warn("direct_upload_resume_session_missing", error, { + clientUploadId: payload.clientUploadId || null, + }); + return NextResponse.json( + { error: "续传会话已失效,请重新开始上传", code: "RESUME_SESSION_EXPIRED" }, + { status: 409 } + ); + } + } + if (payload.action === "abort") { const key = assertUserOwnedKey(payload.key, session.user.id); const abortStartedAt = Date.now(); @@ -343,15 +496,18 @@ export async function POST(request: Request) { const key = assertUserOwnedKey(payload.key, session.user.id); const contentType = normalizeContentType(payload.contentType, payload.filename); - if (!isAllowedDirectUpload(contentType, payload.purpose)) { + if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) { return NextResponse.json( { error: - "直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频", + "直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单", }, { status: 400 } ); } + if (payload.size > getPurposeMaxBytes(payload.purpose)) { + return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 }); + } if (payload.uploadId) { const completeMultipartStartedAt = Date.now(); @@ -397,8 +553,10 @@ export async function POST(request: Request) { const actualSize = typeof head.ContentLength === "number" ? head.ContentLength : null; - if (actualSize !== null && actualSize <= 0) { - throw new Error("Uploaded object is empty"); + if (actualSize !== null && actualSize !== payload.size) { + throw new Error( + `Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}` + ); } const url = buildS3PublicUrl(target.config, key); @@ -407,21 +565,43 @@ export async function POST(request: Request) { const assetStartedAt = Date.now(); const asset = await upsertMediaAsset({ userId: session.user.id, - mediaType: resolvedContentType.startsWith("video/") ? "video" : "image", + mediaType: + payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || + payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ? "file" + : resolvedContentType.startsWith("video/") + ? "video" + : "image", sourceType: payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ? "openclaw_chat_video_upload" - : "upload", + : payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE + ? "openclaw_chat_file_upload" + : payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ? "openclaw_chat_folder_file_upload" + : "upload", status: "ready", originalUrl: url, - previewUrl: url, - thumbnailUrl: url, + previewUrl: + payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || + payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ? null + : url, + thumbnailUrl: + payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE || + payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ? null + : url, mimeType: resolvedContentType, displayName: payload.filename, sourceTable: payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE ? "openclaw_chat_video_upload" - : "direct_upload", + : payload.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE + ? "openclaw_chat_file_upload" + : payload.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE + ? "openclaw_chat_folder_file_upload" + : "direct_upload", metadata: { pathname: key, size: actualSize ?? payload.size, @@ -468,7 +648,13 @@ export async function POST(request: Request) { error: "Direct upload failed", details: error instanceof Error ? error.message : String(error), }, - { status: 500 } + { + status: + error instanceof Error && + error.name === "InvalidUploadObjectKeyError" + ? 403 + : 500, + } ); } } diff --git a/app/api/canvas/clawd-chat/folder-upload/route.ts b/app/api/canvas/clawd-chat/folder-upload/route.ts index d557df075..106de73d0 100644 --- a/app/api/canvas/clawd-chat/folder-upload/route.ts +++ b/app/api/canvas/clawd-chat/folder-upload/route.ts @@ -9,7 +9,6 @@ import { } 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 { @@ -21,6 +20,7 @@ export const runtime = "nodejs"; export const maxDuration = 600; const logger = createLogger("openclaw-folder-upload"); +const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024; function isUploadedFile(value: FormDataEntryValue): value is File { return ( @@ -68,9 +68,9 @@ export async function POST(request: Request) { } const originalSize = files.reduce((sum, file) => sum + file.size, 0); - if (originalSize > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) { + if (originalSize > LEGACY_FOLDER_UPLOAD_MAX_BYTES) { return NextResponse.json( - { error: "文件夹总大小不能超过 300MB" }, + { error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" }, { status: 413 } ); } diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 5e6904e9c..d9bce3217 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -785,7 +785,7 @@ export async function POST(request: Request) { (attachment.type === "video" ? "视频附件" : attachment.type === "file" - ? "文件夹压缩包.zip" + ? "文件附件" : "图片附件"), url: attachment.url, contentType: @@ -793,7 +793,7 @@ export async function POST(request: Request) { (attachment.type === "video" ? "video/mp4" : attachment.type === "file" - ? "application/zip" + ? "application/octet-stream" : "image/jpeg"), })); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 12c8a647d..156b734f3 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -309,18 +309,26 @@ import { updateClawdRequestState, } from "@/components/canvas/chat-panel/clawd-request-state"; import { - buildOpenClawArchiveDisplayText, - buildOpenClawArchiveReferenceText, + buildOpenClawFileDisplayText, + buildOpenClawFileReferenceText, buildOpenClawVideoDisplayText, buildOpenClawVideoReferenceText, + getOpenClawChatFileContentType, getOpenClawChatVideoContentType, - isOpenClawChatArchiveAttachment, + isOpenClawChatFolderFileAllowed, + isOpenClawChatFile, + isOpenClawChatFileAttachment, isOpenClawChatVideoAttachment, isOpenClawChatVideoFile, normalizeOpenClawChatMediaAttachments, + OPENCLAW_CHAT_FILE_ACCEPT, + OPENCLAW_CHAT_FILE_MAX_BYTES, + OPENCLAW_CHAT_FILE_MAX_FILES, + OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE, OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES, OPENCLAW_CHAT_FOLDER_MAX_FILES, OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES, + OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE, OPENCLAW_CHAT_VIDEO_ACCEPT, OPENCLAW_CHAT_VIDEO_MAX_BYTES, OPENCLAW_CHAT_VIDEO_MAX_FILES, @@ -486,9 +494,6 @@ function isClawdChatMessage(message: unknown) { return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd"; } -const CLAWD_TABLE_ACCEPT = - ".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values"; -const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]); const CLAWD_IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff"; const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([ @@ -502,8 +507,24 @@ const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([ "tif", "tiff", ]); -const CLAWD_TABLE_MAX_FILES = 3; -const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024; +function isClawdSpreadsheetAttachment( + url: string, + contentType?: string | null +) { + const normalizedType = String(contentType || "").toLowerCase(); + return ( + normalizedType.includes("spreadsheet") || + normalizedType === "text/csv" || + normalizedType === "text/tab-separated-values" || + /\.(xlsx|csv|tsv)(?:$|[?#])/i.test(String(url || "")) + ); +} + +function getClawdFileBadge(name: string, contentType?: string) { + if (String(contentType || "").includes("folder-manifest")) return "FOLDER"; + const extension = name.split(".").pop()?.trim().toUpperCase() || ""; + return extension && extension.length <= 6 ? extension : "FILE"; +} const CLAWD_PROCESSING_HINT = "已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。"; const CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000; @@ -512,12 +533,107 @@ const CLAWD_SUBMITTED_COMPOSER_RESTORE_GUARD_DELAYS_MS = [ ]; const CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY = "ai-chatbot:clawd-submitted-composer"; +const CLAWD_FOLDER_UPLOAD_STORAGE_PREFIX = "yedir:clawd-folder-upload:v1:"; +const CLAWD_FOLDER_UPLOAD_STORAGE_TTL_MS = 14 * 24 * 60 * 60 * 1000; +const CLAWD_FOLDER_UPLOAD_CONCURRENCY = 3; + +type ClawdFolderUploadedEntry = { + path: string; + signature: string; + name: string; + size: number; + contentType: string; + url: string | null; + assetId?: string; +}; + +type ClawdFolderUploadResumeState = { + version: 1; + fingerprint: string; + updatedAt: number; + entries: Record; +}; + +function hashClawdUploadValue(value: string) { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + +function getClawdFolderFilePath(file: File) { + return (file.webkitRelativePath || file.name).replace(/\\/g, "/"); +} + +function getClawdFolderFileSignature(file: File, path: string) { + return `${path}\u0000${file.size}\u0000${file.lastModified}`; +} + +function getClawdFolderFingerprint(files: File[]) { + return files + .map((file) => + getClawdFolderFileSignature(file, getClawdFolderFilePath(file)) + ) + .sort() + .join("\u0001"); +} + +function getClawdFolderUploadStorageKey(fingerprint: string) { + return `${CLAWD_FOLDER_UPLOAD_STORAGE_PREFIX}${hashClawdUploadValue( + fingerprint + )}`; +} + +function readClawdFolderUploadResumeState(files: File[]) { + if (typeof window === "undefined") return null; + const fingerprint = getClawdFolderFingerprint(files); + const storageKey = getClawdFolderUploadStorageKey(fingerprint); + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return null; + const parsed = JSON.parse(raw) as ClawdFolderUploadResumeState; + if ( + parsed.version !== 1 || + parsed.fingerprint !== fingerprint || + Date.now() - parsed.updatedAt > CLAWD_FOLDER_UPLOAD_STORAGE_TTL_MS + ) { + window.localStorage.removeItem(storageKey); + return null; + } + return parsed; + } catch { + window.localStorage.removeItem(storageKey); + return null; + } +} + +function writeClawdFolderUploadResumeState( + files: File[], + state: ClawdFolderUploadResumeState | null +) { + if (typeof window === "undefined") return; + const fingerprint = getClawdFolderFingerprint(files); + const storageKey = getClawdFolderUploadStorageKey(fingerprint); + try { + if (!state) { + window.localStorage.removeItem(storageKey); + return; + } + window.localStorage.setItem( + storageKey, + JSON.stringify({ ...state, updatedAt: Date.now() }) + ); + } catch {} +} + const CLAWD_SIMPLE_PROMPT_PATTERNS = [ /^(hi|hello|hey|你好|您好|哈喽|嗨|在吗|早上好|下午好|晚上好)[\s!.。!??~~]*$/i, /^(你是谁|你叫什么|你叫什么名字|你能做什么|你可以做什么|有什么功能|介绍一下自己|帮我介绍一下你)[\s!.。!??~~]*$/, ]; -function getClawdTableFileExtension(fileName: string) { +function getClawdFileExtension(fileName: string) { return String(fileName || "") .split(".") .pop() @@ -525,19 +641,12 @@ function getClawdTableFileExtension(fileName: string) { .trim() || ""; } -function isClawdTableFile(file: File) { - return CLAWD_TABLE_ALLOWED_EXTENSIONS.has( - getClawdTableFileExtension(file.name) - ); -} - function isClawdImageFile(file: File) { return ( file.type.startsWith("image/") || - CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdTableFileExtension(file.name)) + CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdFileExtension(file.name)) ); } - function normalizeClawdComposerText(value: string) { return String(value || "") .replace(/\u200b/g, " ") @@ -1711,6 +1820,8 @@ function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) { switch (phase) { case "preparing": return "正在准备上传"; + case "resuming": + return "正在恢复上传断点"; case "direct": return "正在上传到云储存"; case "multipart": @@ -1718,11 +1829,11 @@ function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) { case "server": return "正在通过服务器上传"; case "finalizing": - return "正在确认图片地址"; + return "正在确认文件地址"; case "complete": return "上传完成"; default: - return "正在上传图片"; + return "正在上传文件"; } } @@ -4621,15 +4732,15 @@ 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 isFile = isOpenClawChatFileAttachment(url, contentType); let mediaPreview: HTMLElement; - if (isArchive) { + if (isFile) { 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', '文件夹压缩包'); + mediaPreview.textContent = getClawdFileBadge(name, contentType); + mediaPreview.setAttribute('aria-label', '文件附件'); } else if (isVideo) { const videoPreview = document.createElement('video'); videoPreview.src = url; @@ -4992,7 +5103,7 @@ export function CanvasChatPanel({ tagSpan.getAttribute('data-attachment-content-type') || undefined; if (url) { - if (isOpenClawChatArchiveAttachment(url, tagContentType)) { + if (isOpenClawChatFileAttachment(url, tagContentType)) { setTagPreview(null); return; } @@ -12080,7 +12191,7 @@ export function CanvasChatPanel({ ); const folderAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter( (attachment) => - isOpenClawChatArchiveAttachment( + isOpenClawChatFileAttachment( attachment.url, attachment.contentType ) @@ -12091,7 +12202,7 @@ export function CanvasChatPanel({ attachment.url, attachment.contentType ) && - !isOpenClawChatArchiveAttachment( + !isOpenClawChatFileAttachment( attachment.url, attachment.contentType ) @@ -12106,7 +12217,7 @@ export function CanvasChatPanel({ attachment.contentType ) ? "video" - : isOpenClawChatArchiveAttachment( + : isOpenClawChatFileAttachment( attachment.url, attachment.contentType ) @@ -12145,7 +12256,7 @@ export function CanvasChatPanel({ const videoReferenceText = buildOpenClawVideoReferenceText( openClawMediaAttachments ); - const folderReferenceText = buildOpenClawArchiveReferenceText( + const folderReferenceText = buildOpenClawFileReferenceText( openClawMediaAttachments ); const requestMessage = [ @@ -12173,7 +12284,7 @@ export function CanvasChatPanel({ buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot), buildClawdImageDisplayText(imageAttachmentsSnapshot), buildOpenClawVideoDisplayText(openClawMediaAttachments), - buildOpenClawArchiveDisplayText(openClawMediaAttachments), + buildOpenClawFileDisplayText(openClawMediaAttachments), ] .filter(Boolean) .join("\n\n"); @@ -13157,8 +13268,8 @@ export function CanvasChatPanel({ return null; } - if (file.size > 50 * 1024 * 1024) { - toast.error("图片大小不能超过 50MB"); + if (file.size > OPENCLAW_CHAT_FILE_MAX_BYTES) { + toast.error("图片大小不能超过 2GB"); return null; } @@ -13281,7 +13392,7 @@ export function CanvasChatPanel({ return null; } if (file.size > OPENCLAW_CHAT_VIDEO_MAX_BYTES) { - toast.error(`${file.name} 超过 500MB,请先压缩视频`); + toast.error(`${file.name} 超过 2GB,请先压缩视频`); return null; } @@ -13332,6 +13443,81 @@ export function CanvasChatPanel({ } }, [clearComposerUploadProgress, updateComposerUploadProgress]); + const uploadClawdFileForComposerAttachment = useCallback(async ( + file: File, + progressOptions: CanvasUploadProgressOptions, + options?: { + purpose?: string; + onProgress?: (progress: UploadProgress) => void; + clearProgress?: boolean; + } + ): Promise => { + const purpose = options?.purpose || OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE; + const isFolderFile = purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE; + if (isFolderFile && !isOpenClawChatFolderFileAllowed(file)) { + throw new Error(`${file.name} 为不安全的可执行或网页文件,已拒绝上传`); + } + const normalizedContentType = isFolderFile + ? file.type || "application/octet-stream" + : getOpenClawChatFileContentType(file); + + if (!isFolderFile && !normalizedContentType) { + throw new Error(`${file.name} 格式不支持`); + } + if (file.size <= 0) { + throw new Error(`${file.name} 是空文件`); + } + if (file.size > OPENCLAW_CHAT_FILE_MAX_BYTES) { + throw new Error(`${file.name} 超过 2GB`); + } + + const uploadFile = + file.type === normalizedContentType + ? file + : new File([file], file.name, { + type: normalizedContentType, + lastModified: file.lastModified, + }); + const formData = new FormData(); + formData.append("file", uploadFile); + formData.append("purpose", purpose); + updateComposerUploadProgress(progressOptions); + + const response = await fetchUploadWithRetry(formData, { + onProgress: (progress) => { + if (options?.onProgress) { + options.onProgress(progress); + } else { + updateComposerUploadProgress(progressOptions, progress); + } + }, + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error( + String(data?.error || data?.details || `${file.name} 上传失败`) + ); + } + + const imageUrl = String(data?.url || "").trim(); + if (!isValidChatHttpUrl(imageUrl)) { + throw new Error("储存桶未返回可访问的文件地址"); + } + if (options?.clearProgress !== false) { + clearComposerUploadProgress(250); + } + return { + imageUrl, + name: file.name, + contentType: + String(data?.contentType || "").trim() || normalizedContentType, + assetId: + typeof data?.assetId === "string" && data.assetId.trim() + ? data.assetId.trim() + : undefined, + }; + }, [clearComposerUploadProgress, updateComposerUploadProgress]); + const uploadClawdFolderForComposerAttachment = useCallback(async ( files: File[] ): Promise => { @@ -13346,13 +13532,11 @@ export function CanvasChatPanel({ const totalBytes = files.reduce((sum, file) => sum + file.size, 0); if (totalBytes > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) { - toast.error("文件夹总大小不能超过 300MB"); + toast.error("文件夹总大小不能超过 2GB"); return null; } - const relativePaths = files.map( - (file) => file.webkitRelativePath || file.name - ); + const relativePaths = files.map(getClawdFolderFilePath); const rootFolders = Array.from( new Set( relativePaths @@ -13365,16 +13549,10 @@ export function CanvasChatPanel({ ? rootFolders[0] : `${rootFolders.length}个文件夹-${Date.now()}`; const progressOptions: CanvasUploadProgressOptions = { - title: "正在打包并上传文件夹", - fileName: `${archiveBaseName}.zip`, + title: "正在上传文件夹", + fileName: archiveBaseName, 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, @@ -13384,74 +13562,191 @@ export function CanvasChatPanel({ }); 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", - }); + const fingerprint = getClawdFolderFingerprint(files); + const resumeState = readClawdFolderUploadResumeState(files); + const state: ClawdFolderUploadResumeState = resumeState || { + version: 1, + fingerprint, + updatedAt: Date.now(), + entries: {}, + }; + const uploadedEntries: Array = + new Array(files.length); + const loadedByIndex = new Map(); + let aggregateLoaded = 0; + + files.forEach((file, index) => { + const path = relativePaths[index]; + const signature = getClawdFolderFileSignature(file, path); + const restored = state.entries[path]; + if ( + restored?.signature === signature && + (file.size === 0 || + (restored.url && isValidChatHttpUrl(restored.url))) + ) { + uploadedEntries[index] = restored; + loadedByIndex.set(index, file.size); + aggregateLoaded += file.size; + } + }); + + const reportFolderProgress = ( + index: number, + loaded: number, + phase: UploadProgress["phase"] + ) => { + const nextLoaded = Math.min(files[index].size, Math.max(0, loaded)); + const previousLoaded = loadedByIndex.get(index) || 0; + loadedByIndex.set(index, nextLoaded); + aggregateLoaded += nextLoaded - previousLoaded; + updateComposerUploadProgress(progressOptions, { + loaded: aggregateLoaded, + total: totalBytes, + percent: + totalBytes > 0 + ? Math.max( + 1, + Math.min(96, Math.round((aggregateLoaded / totalBytes) * 96)) + ) + : 96, + phase, + }); + }; + + let nextIndex = 0; + const uploadWorker = async () => { + for (;;) { + const index = nextIndex; + nextIndex += 1; + if (index >= files.length) return; + if (uploadedEntries[index]) continue; + + const file = files[index]; + const path = relativePaths[index]; + const signature = getClawdFolderFileSignature(file, path); + if (file.size === 0) { + const emptyEntry: ClawdFolderUploadedEntry = { + path, + signature, + name: file.name, + size: 0, + contentType: file.type || "application/octet-stream", + url: null, + }; + uploadedEntries[index] = emptyEntry; + state.entries[path] = emptyEntry; + writeClawdFolderUploadResumeState(files, state); + continue; + } + + const uploaded = await uploadClawdFileForComposerAttachment( + file, + { + ...progressOptions, + fileName: path, + fileIndex: index, + fileTotal: files.length, + }, + { + purpose: OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE, + clearProgress: false, + onProgress: (progress) => { + reportFolderProgress(index, progress.loaded, progress.phase); + }, + } + ); + reportFolderProgress(index, file.size, "complete"); + const entry: ClawdFolderUploadedEntry = { + path, + signature, + name: file.name, + size: file.size, + contentType: uploaded.contentType || "application/octet-stream", + url: uploaded.imageUrl, + assetId: uploaded.assetId, }; - xhr.upload.onload = () => { + uploadedEntries[index] = entry; + state.entries[path] = entry; + writeClawdFolderUploadResumeState(files, state); + } + }; + + const workerResults = await Promise.allSettled( + Array.from( + { + length: Math.min( + CLAWD_FOLDER_UPLOAD_CONCURRENCY, + files.length + ), + }, + () => uploadWorker() + ) + ); + const failedWorker = workerResults.find( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + if (failedWorker) { + throw failedWorker.reason; + } + + const manifest = { + version: 1, + kind: "yedir-folder-manifest", + name: archiveBaseName, + createdAt: new Date().toISOString(), + fileCount: files.length, + totalSize: totalBytes, + files: uploadedEntries.filter( + (entry): entry is ClawdFolderUploadedEntry => Boolean(entry) + ), + }; + const manifestName = `${archiveBaseName}.folder.json`; + const manifestFile = new File( + [JSON.stringify(manifest)], + manifestName, + { type: "application/vnd.yedir.folder-manifest+json" } + ); + const uploadedManifest = await uploadClawdFileForComposerAttachment( + manifestFile, + { + ...progressOptions, + fileName: manifestName, + }, + { + clearProgress: false, + onProgress: (progress) => { updateComposerUploadProgress(progressOptions, { loaded: totalBytes, total: totalBytes, - percent: 94, + percent: Math.max(97, Math.min(99, progress.percent)), 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", }); + writeClawdFolderUploadResumeState(files, null); clearComposerUploadProgress(350); return { - imageUrl: url, - name: String(data.name || `${archiveBaseName}.zip`), - contentType: "application/zip", + ...uploadedManifest, + name: manifestName, + contentType: "application/vnd.yedir.folder-manifest+json", }; } catch (error) { clearComposerUploadProgress(); toast.error(error instanceof Error ? error.message : "文件夹上传失败"); return null; } - }, [clearComposerUploadProgress, updateComposerUploadProgress]); + }, [ + clearComposerUploadProgress, + updateComposerUploadProgress, + uploadClawdFileForComposerAttachment, + ]); const handleClawdFolderSelect = useCallback(async ( event: React.ChangeEvent @@ -13461,14 +13756,14 @@ export function CanvasChatPanel({ if (files.length === 0) return; const existingArchiveCount = attachments.filter((attachment) => - isOpenClawChatArchiveAttachment( + isOpenClawChatFileAttachment( attachment.url, attachment.contentType ) ).length; if (existingArchiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) { toast.info( - `一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个文件夹压缩包` + `一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个表格、压缩包或文件夹` ); return; } @@ -13476,7 +13771,7 @@ export function CanvasChatPanel({ const uploaded = await uploadClawdFolderForComposerAttachment(files); if (!uploaded) return; addUploadedImageAttachmentToComposer(uploaded); - toast.success("文件夹已打包上传到七牛云,可继续添加其他文件夹"); + toast.success("文件夹已上传并保留目录结构,可继续添加其他文件夹"); }, [ addUploadedImageAttachmentToComposer, attachments, @@ -13761,31 +14056,31 @@ export function CanvasChatPanel({ let selectedFiles = Array.from(files); if (isClawdMode) { - const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file)); const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/") ); const videoFiles = selectedFiles.filter((file) => isOpenClawChatVideoFile(file) ); + const genericFiles = selectedFiles.filter((file) => + isOpenClawChatFile(file) + ); const unsupportedFiles = selectedFiles.filter( (file) => - !isClawdTableFile(file) && !file.type.startsWith("image/") && - !isOpenClawChatVideoFile(file) + !isOpenClawChatVideoFile(file) && + !isOpenClawChatFile(file) ); unsupportedFiles.forEach((file) => { toast.error( - `${file.name} 格式不支持,请上传图片、视频或 .xlsx/.csv/.tsv` + `${file.name} 格式不支持,请上传图片、视频、表格或压缩包` ); }); - if (tableFiles.length > 0) { - addClawdTableFiles(tableFiles); - } let handledImageCount = 0; let handledVideoCount = 0; + let handledFileCount = 0; if (imageFiles.length > 0) { try { for (const [index, file] of imageFiles.entries()) { @@ -13857,6 +14152,53 @@ export function CanvasChatPanel({ } } + if (genericFiles.length > 0) { + const existingFileCount = attachments.filter((attachment) => + isOpenClawChatFileAttachment( + attachment.url, + attachment.contentType + ) + ).length; + const availableFileSlots = Math.max( + 0, + OPENCLAW_CHAT_FILE_MAX_FILES - existingFileCount + ); + const acceptedFiles = genericFiles.slice(0, availableFileSlots); + if (acceptedFiles.length < genericFiles.length) { + toast.info( + `${CLAW_MODE_LABEL} 对话一次最多添加 ${OPENCLAW_CHAT_FILE_MAX_FILES} 个文件附件` + ); + } + + try { + for (const [index, file] of acceptedFiles.entries()) { + try { + const uploaded = await uploadClawdFileForComposerAttachment( + file, + { + title: + acceptedFiles.length === 1 + ? "正在上传文件到储存桶" + : `正在上传 ${acceptedFiles.length} 个文件`, + fileName: file.name, + fileIndex: index, + fileTotal: acceptedFiles.length, + target: "composer-reference", + } + ); + handledFileCount += 1; + addUploadedImageAttachmentToComposer(uploaded); + } catch (error) { + toast.error( + error instanceof Error ? error.message : `${file.name} 上传失败` + ); + } + } + } finally { + clearComposerUploadProgress(450); + } + } + if (handledImageCount > 0) { toast.success( handledImageCount === 1 @@ -13871,6 +14213,13 @@ export function CanvasChatPanel({ : `已添加 ${handledVideoCount} 个视频为 ${CLAW_MODE_LABEL} 附件` ); } + if (handledFileCount > 0) { + toast.success( + handledFileCount === 1 + ? "文件已上传并添加到 Claw 对话" + : `已上传并添加 ${handledFileCount} 个文件` + ); + } e.target.value = ""; return; } @@ -14105,58 +14454,6 @@ export function CanvasChatPanel({ e.target.value = ''; }; - function addClawdTableFiles(selectedFiles: File[]) { - if (selectedFiles.length === 0) return; - - const remainingSlots = CLAWD_TABLE_MAX_FILES - clawdTableFiles.length; - if (remainingSlots <= 0) { - toast.error(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格`); - return; - } - - const acceptedFiles: File[] = []; - for (const file of selectedFiles) { - if (!isClawdTableFile(file)) { - toast.error(`${file.name} 格式不支持,请上传 .xlsx、.csv 或 .tsv`); - continue; - } - if (file.size <= 0) { - toast.error(`${file.name} 是空文件`); - continue; - } - if (file.size > CLAWD_TABLE_MAX_BYTES) { - toast.error(`${file.name} 超过 10MB,请先拆分或压缩`); - continue; - } - acceptedFiles.push(file); - } - - if (acceptedFiles.length === 0) return; - - setClawdTableFiles((currentFiles) => { - const existingSignatures = new Set( - currentFiles.map( - (file) => `${file.name}:${file.size}:${file.lastModified}` - ) - ); - const nextFiles = [...currentFiles]; - for (const file of acceptedFiles) { - if (nextFiles.length >= CLAWD_TABLE_MAX_FILES) break; - const signature = `${file.name}:${file.size}:${file.lastModified}`; - if (existingSignatures.has(signature)) { - continue; - } - existingSignatures.add(signature); - nextFiles.push(file); - } - return nextFiles; - }); - - if (acceptedFiles.length > remainingSlots) { - toast.info(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格,已自动保留前 ${remainingSlots} 个`); - } - } - const removeClawdTableFile = useCallback((index: number) => { setClawdTableFiles((currentFiles) => currentFiles.filter((_, fileIndex) => fileIndex !== index) @@ -15626,7 +15923,7 @@ export function CanvasChatPanel({ message.role === "user" ? collectMessageVideoAttachments(message as any) : []; - const userArchiveAttachments: Array<{ + const userFileAttachments: Array<{ url: string; name: string; contentType?: string; @@ -15651,15 +15948,20 @@ export function CanvasChatPanel({ ).trim(); return { url, - name: String(part?.name || "Folder.zip").trim() || "Folder.zip", + name: String(part?.name || "文件").trim() || "文件", contentType: contentType || undefined, }; }) - .filter((attachment) => - isOpenClawChatArchiveAttachment( - attachment.url, - attachment.contentType - ) + .filter( + (attachment) => + isOpenClawChatFileAttachment( + attachment.url, + attachment.contentType + ) && + !isClawdSpreadsheetAttachment( + attachment.url, + attachment.contentType + ) ) .map((attachment) => [attachment.url, attachment]) ).values() @@ -15883,10 +16185,10 @@ export function CanvasChatPanel({ ); }); - userArchiveAttachments.forEach((attachment, idx) => { + userFileAttachments.forEach((attachment, idx) => { renderedUserParts.push( @@ -16009,9 +16311,11 @@ export function CanvasChatPanel({ const shouldDeferAssistantMedia = (isClawAssistantMessage || shouldTypeAgentAssistantMessage) && (shouldAnimateAnalysis || shouldAnimatePlainText); - const standaloneImageUrls = isClawAssistantMessage - ? filterStandaloneAssistantImageUrls(rawText, imageUrls) - : imageUrls; + const standaloneImageUrls = + filterStandaloneAssistantImageUrls( + rawText, + imageUrls + ); const visibleImageUrls = shouldDeferAssistantMedia ? [] : standaloneImageUrls; @@ -17943,7 +18247,7 @@ export function CanvasChatPanel({ type="file" accept={ isClawdMode - ? `${CLAWD_IMAGE_ACCEPT},${OPENCLAW_CHAT_VIDEO_ACCEPT},${CLAWD_TABLE_ACCEPT}` + ? `${CLAWD_IMAGE_ACCEPT},${OPENCLAW_CHAT_VIDEO_ACCEPT},${OPENCLAW_CHAT_FILE_ACCEPT}` : supportsAtlasVideoReference ? "image/*,video/mp4,video/quicktime,video/webm" : "image/*" diff --git a/components/canvas/chat-panel/canvas-chat-media.ts b/components/canvas/chat-panel/canvas-chat-media.ts index 0c1abac96..2bb4d8a06 100644 --- a/components/canvas/chat-panel/canvas-chat-media.ts +++ b/components/canvas/chat-panel/canvas-chat-media.ts @@ -277,6 +277,10 @@ export function getMarkdownTableImageUrlKeys(text: string): Set { const key = normalizeImageIdentityUrl(url); if (key) urlKeys.add(key); }; + const addUrlsFromTableText = (tableText: string) => { + extractAssistantImageUrls(tableText).forEach(addUrl); + extractPlainImageUrls(tableText).forEach(addUrl); + }; for (let index = 0; index < lines.length - 1; index += 1) { const line = lines[index] || ""; @@ -297,12 +301,36 @@ export function getMarkdownTableImageUrlKeys(text: string): Set { endIndex += 1; } - const tableText = lines.slice(index, endIndex).join("\n"); - extractAssistantImageUrls(tableText).forEach(addUrl); - extractPlainImageUrls(tableText).forEach(addUrl); + addUrlsFromTableText(lines.slice(index, endIndex).join("\n")); index = endIndex - 1; } + // Claw may emit pipe tables without a separator row while streaming. Treat a + // contiguous block of at least two pipe rows as a table for media deduping. + for (let index = 0; index < lines.length; ) { + if (!isMarkdownTableRow(lines[index] || "")) { + index += 1; + continue; + } + let endIndex = index + 1; + while ( + endIndex < lines.length && + isMarkdownTableRow(lines[endIndex] || "") + ) { + endIndex += 1; + } + if (endIndex - index >= 2) { + addUrlsFromTableText(lines.slice(index, endIndex).join("\n")); + } + index = endIndex; + } + + const htmlTablePattern = /]*>[\s\S]*?<\/table>/gi; + let htmlTableMatch: RegExpExecArray | null = null; + while ((htmlTableMatch = htmlTablePattern.exec(String(text || ""))) !== null) { + addUrlsFromTableText(htmlTableMatch[0] || ""); + } + return urlKeys; } diff --git a/components/elements/response.tsx b/components/elements/response.tsx index e2d1cbcb9..820dc2976 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -459,7 +459,7 @@ function MarkdownTableImagePreview({ <>