feat(claw): support resumable 2GB file uploads

This commit is contained in:
134 2026-08-11 15:02:07 +08:00
parent 3f2b18d18d
commit 1dd489100d
12 changed files with 1350 additions and 291 deletions

View File

@ -3,6 +3,7 @@ import {
CompleteMultipartUploadCommand, CompleteMultipartUploadCommand,
CreateMultipartUploadCommand, CreateMultipartUploadCommand,
HeadObjectCommand, HeadObjectCommand,
ListPartsCommand,
PutObjectCommand, PutObjectCommand,
UploadPartCommand, UploadPartCommand,
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
@ -21,13 +22,22 @@ import {
maybeEnqueueStorageMigrationTask, maybeEnqueueStorageMigrationTask,
resolveS3UploadTarget, resolveS3UploadTarget,
} from "@/lib/storage/service"; } 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 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 MULTIPART_THRESHOLD_BYTES = 50 * 1024 * 1024;
const DEFAULT_PART_SIZE_BYTES = 8 * 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"; const DIRECT_UPLOAD_PREFIX = "uploads/direct";
function elapsedMs(startedAt: number) { function elapsedMs(startedAt: number) {
@ -76,6 +86,18 @@ const CompleteSchema = z.object({
clientUploadId: z.string().max(128).optional(), 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({ const AbortSchema = z.object({
action: z.literal("abort"), action: z.literal("abort"),
key: z.string().min(1).max(512), key: z.string().min(1).max(512),
@ -85,6 +107,7 @@ const AbortSchema = z.object({
const RequestSchema = z.discriminatedUnion("action", [ const RequestSchema = z.discriminatedUnion("action", [
InitiateSchema, InitiateSchema,
ResumeSchema,
CompleteSchema, CompleteSchema,
AbortSchema, AbortSchema,
]); ]);
@ -112,23 +135,49 @@ function assertUserOwnedKey(key: string, userId: string) {
const normalized = String(key || "").replace(/^\/+/, ""); const normalized = String(key || "").replace(/^\/+/, "");
const prefix = `${userUploadPrefix(userId)}/`; const prefix = `${userUploadPrefix(userId)}/`;
if (!normalized.startsWith(prefix)) { 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; return normalized;
} }
function isAllowedDirectUpload(contentType: string, purpose?: string) { function isAllowedDirectUpload(
contentType: string,
purpose: string | undefined,
filename: string
) {
return ( return (
allowedDirectUploadTypes.has(contentType) || allowedDirectUploadTypes.has(contentType) ||
(purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE && (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) { function computePartSize(size: number) {
if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES; if (size <= 100 * 1024 * 1024) return DEFAULT_PART_SIZE_BYTES;
if (size <= 300 * 1024 * 1024) return 12 * 1024 * 1024; if (size <= 500 * 1024 * 1024) return 16 * 1024 * 1024;
return 16 * 1024 * 1024; if (size <= 1024 * 1024 * 1024) return 32 * 1024 * 1024;
return 64 * 1024 * 1024;
} }
async function createPartUrls(params: { async function createPartUrls(params: {
@ -136,24 +185,24 @@ async function createPartUrls(params: {
bucket: string; bucket: string;
key: string; key: string;
uploadId: string; uploadId: string;
partCount: number; partNumbers: number[];
}) { }) {
const urls = []; return Promise.all(
for (let partNumber = 1; partNumber <= params.partCount; partNumber += 1) { params.partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({ const command = new UploadPartCommand({
Bucket: params.bucket, Bucket: params.bucket,
Key: params.key, Key: params.key,
UploadId: params.uploadId, UploadId: params.uploadId,
PartNumber: partNumber, PartNumber: partNumber,
}); });
urls.push({ return {
partNumber, partNumber,
url: await getSignedUrl(params.client, command, { url: await getSignedUrl(params.client, command, {
expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS,
}), }),
}); };
} })
return urls; );
} }
export async function POST(request: Request) { export async function POST(request: Request) {
@ -216,7 +265,7 @@ export async function POST(request: Request) {
payload.filename payload.filename
); );
if (!isAllowedDirectUpload(contentType, payload.purpose)) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
logger.warn("unsupported_direct_upload_type", { logger.warn("unsupported_direct_upload_type", {
contentType, contentType,
filenameExtension: payload.filename.toLowerCase().split(".").pop() || null, filenameExtension: payload.filename.toLowerCase().split(".").pop() || null,
@ -227,11 +276,17 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频", "直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
}, },
{ status: 400 } { 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 displayName = sanitizeDisplayName(payload.filename);
const key = buildStorageObjectKey( const key = buildStorageObjectKey(
@ -239,7 +294,13 @@ export async function POST(request: Request) {
userUploadPrefix(session.user.id) 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 presignStartedAt = Date.now();
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: bucket, Bucket: bucket,
@ -299,7 +360,7 @@ export async function POST(request: Request) {
bucket, bucket,
key, key,
uploadId, uploadId,
partCount, partNumbers: Array.from({ length: partCount }, (_, index) => index + 1),
}); });
logger.info("direct_upload_multipart_prepared", { logger.info("direct_upload_multipart_prepared", {
elapsedMs: elapsedMs(multipartStartedAt), 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") { if (payload.action === "abort") {
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const abortStartedAt = Date.now(); const abortStartedAt = Date.now();
@ -343,15 +496,18 @@ export async function POST(request: Request) {
const key = assertUserOwnedKey(payload.key, session.user.id); const key = assertUserOwnedKey(payload.key, session.user.id);
const contentType = normalizeContentType(payload.contentType, payload.filename); const contentType = normalizeContentType(payload.contentType, payload.filename);
if (!isAllowedDirectUpload(contentType, payload.purpose)) { if (!isAllowedDirectUpload(contentType, payload.purpose, payload.filename)) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"直传仅支持 JPG、PNG、WebP 图片以及 Claw 对话的 MP4、MOV、WebM、M4V、MKV 视频", "直传仅支持图片、Claw 视频、压缩包、表格和文件夹清单",
}, },
{ status: 400 } { status: 400 }
); );
} }
if (payload.size > getPurposeMaxBytes(payload.purpose)) {
return NextResponse.json({ error: "文件超过允许的上传大小" }, { status: 413 });
}
if (payload.uploadId) { if (payload.uploadId) {
const completeMultipartStartedAt = Date.now(); const completeMultipartStartedAt = Date.now();
@ -397,8 +553,10 @@ export async function POST(request: Request) {
const actualSize = const actualSize =
typeof head.ContentLength === "number" ? head.ContentLength : null; typeof head.ContentLength === "number" ? head.ContentLength : null;
if (actualSize !== null && actualSize <= 0) { if (actualSize !== null && actualSize !== payload.size) {
throw new Error("Uploaded object is empty"); throw new Error(
`Uploaded object size mismatch: expected ${payload.size}, received ${actualSize}`
);
} }
const url = buildS3PublicUrl(target.config, key); const url = buildS3PublicUrl(target.config, key);
@ -407,21 +565,43 @@ export async function POST(request: Request) {
const assetStartedAt = Date.now(); const assetStartedAt = Date.now();
const asset = await upsertMediaAsset({ const asset = await upsertMediaAsset({
userId: session.user.id, 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: sourceType:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload" ? "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", status: "ready",
originalUrl: url, originalUrl: url,
previewUrl: url, previewUrl:
thumbnailUrl: url, 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, mimeType: resolvedContentType,
displayName: payload.filename, displayName: payload.filename,
sourceTable: sourceTable:
payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE payload.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE
? "openclaw_chat_video_upload" ? "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: { metadata: {
pathname: key, pathname: key,
size: actualSize ?? payload.size, size: actualSize ?? payload.size,
@ -468,7 +648,13 @@ export async function POST(request: Request) {
error: "Direct upload failed", error: "Direct upload failed",
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
}, },
{ status: 500 } {
status:
error instanceof Error &&
error.name === "InvalidUploadObjectKeyError"
? 403
: 500,
}
); );
} }
} }

View File

@ -9,7 +9,6 @@ import {
} from "@/lib/openclaw-chat/folder-archive"; } from "@/lib/openclaw-chat/folder-archive";
import { import {
OPENCLAW_CHAT_FOLDER_MAX_FILES, OPENCLAW_CHAT_FOLDER_MAX_FILES,
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
} from "@/lib/openclaw-chat/media-attachments"; } from "@/lib/openclaw-chat/media-attachments";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { import {
@ -21,6 +20,7 @@ export const runtime = "nodejs";
export const maxDuration = 600; export const maxDuration = 600;
const logger = createLogger("openclaw-folder-upload"); const logger = createLogger("openclaw-folder-upload");
const LEGACY_FOLDER_UPLOAD_MAX_BYTES = 300 * 1024 * 1024;
function isUploadedFile(value: FormDataEntryValue): value is File { function isUploadedFile(value: FormDataEntryValue): value is File {
return ( return (
@ -68,9 +68,9 @@ export async function POST(request: Request) {
} }
const originalSize = files.reduce((sum, file) => sum + file.size, 0); 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( return NextResponse.json(
{ error: "文件夹总大小不能超过 300MB" }, { error: "请刷新页面后使用支持 2GB 和断点续传的新文件夹上传方式" },
{ status: 413 } { status: 413 }
); );
} }

View File

@ -785,7 +785,7 @@ export async function POST(request: Request) {
(attachment.type === "video" (attachment.type === "video"
? "视频附件" ? "视频附件"
: attachment.type === "file" : attachment.type === "file"
? "文件夹压缩包.zip" ? "文件附件"
: "图片附件"), : "图片附件"),
url: attachment.url, url: attachment.url,
contentType: contentType:
@ -793,7 +793,7 @@ export async function POST(request: Request) {
(attachment.type === "video" (attachment.type === "video"
? "video/mp4" ? "video/mp4"
: attachment.type === "file" : attachment.type === "file"
? "application/zip" ? "application/octet-stream"
: "image/jpeg"), : "image/jpeg"),
})); }));

View File

@ -309,18 +309,26 @@ import {
updateClawdRequestState, updateClawdRequestState,
} from "@/components/canvas/chat-panel/clawd-request-state"; } from "@/components/canvas/chat-panel/clawd-request-state";
import { import {
buildOpenClawArchiveDisplayText, buildOpenClawFileDisplayText,
buildOpenClawArchiveReferenceText, buildOpenClawFileReferenceText,
buildOpenClawVideoDisplayText, buildOpenClawVideoDisplayText,
buildOpenClawVideoReferenceText, buildOpenClawVideoReferenceText,
getOpenClawChatFileContentType,
getOpenClawChatVideoContentType, getOpenClawChatVideoContentType,
isOpenClawChatArchiveAttachment, isOpenClawChatFolderFileAllowed,
isOpenClawChatFile,
isOpenClawChatFileAttachment,
isOpenClawChatVideoAttachment, isOpenClawChatVideoAttachment,
isOpenClawChatVideoFile, isOpenClawChatVideoFile,
normalizeOpenClawChatMediaAttachments, 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_ARCHIVES,
OPENCLAW_CHAT_FOLDER_MAX_FILES, OPENCLAW_CHAT_FOLDER_MAX_FILES,
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES, OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE,
OPENCLAW_CHAT_VIDEO_ACCEPT, OPENCLAW_CHAT_VIDEO_ACCEPT,
OPENCLAW_CHAT_VIDEO_MAX_BYTES, OPENCLAW_CHAT_VIDEO_MAX_BYTES,
OPENCLAW_CHAT_VIDEO_MAX_FILES, OPENCLAW_CHAT_VIDEO_MAX_FILES,
@ -486,9 +494,6 @@ function isClawdChatMessage(message: unknown) {
return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd"; 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 = 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"; ".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([ const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
@ -502,8 +507,24 @@ const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
"tif", "tif",
"tiff", "tiff",
]); ]);
const CLAWD_TABLE_MAX_FILES = 3; function isClawdSpreadsheetAttachment(
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024; 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_PROCESSING_HINT =
"已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。"; "已收到,这个任务稍微繁重一些,处理时间可能会长一点,请耐心等候。";
const CLAWD_SUBMITTED_COMPOSER_RECOVERY_TTL_MS = 30 * 60 * 1000; 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 = const CLAWD_SUBMITTED_COMPOSER_STORAGE_KEY =
"ai-chatbot:clawd-submitted-composer"; "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<string, ClawdFolderUploadedEntry>;
};
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 = [ const CLAWD_SIMPLE_PROMPT_PATTERNS = [
/^(hi|hello|hey|你好|您好|哈喽|嗨|在吗|早上好|下午好|晚上好)[\s!.。!??~~]*$/i, /^(hi|hello|hey|你好|您好|哈喽|嗨|在吗|早上好|下午好|晚上好)[\s!.。!??~~]*$/i,
/^(你是谁|你叫什么|你叫什么名字|你能做什么|你可以做什么|有什么功能|介绍一下自己|帮我介绍一下你)[\s!.。!??~~]*$/, /^(你是谁|你叫什么|你叫什么名字|你能做什么|你可以做什么|有什么功能|介绍一下自己|帮我介绍一下你)[\s!.。!??~~]*$/,
]; ];
function getClawdTableFileExtension(fileName: string) { function getClawdFileExtension(fileName: string) {
return String(fileName || "") return String(fileName || "")
.split(".") .split(".")
.pop() .pop()
@ -525,19 +641,12 @@ function getClawdTableFileExtension(fileName: string) {
.trim() || ""; .trim() || "";
} }
function isClawdTableFile(file: File) {
return CLAWD_TABLE_ALLOWED_EXTENSIONS.has(
getClawdTableFileExtension(file.name)
);
}
function isClawdImageFile(file: File) { function isClawdImageFile(file: File) {
return ( return (
file.type.startsWith("image/") || file.type.startsWith("image/") ||
CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdTableFileExtension(file.name)) CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdFileExtension(file.name))
); );
} }
function normalizeClawdComposerText(value: string) { function normalizeClawdComposerText(value: string) {
return String(value || "") return String(value || "")
.replace(/\u200b/g, " ") .replace(/\u200b/g, " ")
@ -1711,6 +1820,8 @@ function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
switch (phase) { switch (phase) {
case "preparing": case "preparing":
return "正在准备上传"; return "正在准备上传";
case "resuming":
return "正在恢复上传断点";
case "direct": case "direct":
return "正在上传到云储存"; return "正在上传到云储存";
case "multipart": case "multipart":
@ -1718,11 +1829,11 @@ function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
case "server": case "server":
return "正在通过服务器上传"; return "正在通过服务器上传";
case "finalizing": case "finalizing":
return "正在确认图片地址"; return "正在确认文件地址";
case "complete": case "complete":
return "上传完成"; return "上传完成";
default: 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'; 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 shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
const isVideo = isOpenClawChatVideoAttachment(url, contentType); const isVideo = isOpenClawChatVideoAttachment(url, contentType);
const isArchive = isOpenClawChatArchiveAttachment(url, contentType); const isFile = isOpenClawChatFileAttachment(url, contentType);
let mediaPreview: HTMLElement; let mediaPreview: HTMLElement;
if (isArchive) { if (isFile) {
mediaPreview = document.createElement('span'); mediaPreview = document.createElement('span');
mediaPreview.className = 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'; '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.textContent = getClawdFileBadge(name, contentType);
mediaPreview.setAttribute('aria-label', '文件夹压缩包'); mediaPreview.setAttribute('aria-label', '文件附件');
} else if (isVideo) { } else if (isVideo) {
const videoPreview = document.createElement('video'); const videoPreview = document.createElement('video');
videoPreview.src = url; videoPreview.src = url;
@ -4992,7 +5103,7 @@ export function CanvasChatPanel({
tagSpan.getAttribute('data-attachment-content-type') || undefined; tagSpan.getAttribute('data-attachment-content-type') || undefined;
if (url) { if (url) {
if (isOpenClawChatArchiveAttachment(url, tagContentType)) { if (isOpenClawChatFileAttachment(url, tagContentType)) {
setTagPreview(null); setTagPreview(null);
return; return;
} }
@ -12080,7 +12191,7 @@ export function CanvasChatPanel({
); );
const folderAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter( const folderAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter(
(attachment) => (attachment) =>
isOpenClawChatArchiveAttachment( isOpenClawChatFileAttachment(
attachment.url, attachment.url,
attachment.contentType attachment.contentType
) )
@ -12091,7 +12202,7 @@ export function CanvasChatPanel({
attachment.url, attachment.url,
attachment.contentType attachment.contentType
) && ) &&
!isOpenClawChatArchiveAttachment( !isOpenClawChatFileAttachment(
attachment.url, attachment.url,
attachment.contentType attachment.contentType
) )
@ -12106,7 +12217,7 @@ export function CanvasChatPanel({
attachment.contentType attachment.contentType
) )
? "video" ? "video"
: isOpenClawChatArchiveAttachment( : isOpenClawChatFileAttachment(
attachment.url, attachment.url,
attachment.contentType attachment.contentType
) )
@ -12145,7 +12256,7 @@ export function CanvasChatPanel({
const videoReferenceText = buildOpenClawVideoReferenceText( const videoReferenceText = buildOpenClawVideoReferenceText(
openClawMediaAttachments openClawMediaAttachments
); );
const folderReferenceText = buildOpenClawArchiveReferenceText( const folderReferenceText = buildOpenClawFileReferenceText(
openClawMediaAttachments openClawMediaAttachments
); );
const requestMessage = [ const requestMessage = [
@ -12173,7 +12284,7 @@ export function CanvasChatPanel({
buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot), buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot),
buildClawdImageDisplayText(imageAttachmentsSnapshot), buildClawdImageDisplayText(imageAttachmentsSnapshot),
buildOpenClawVideoDisplayText(openClawMediaAttachments), buildOpenClawVideoDisplayText(openClawMediaAttachments),
buildOpenClawArchiveDisplayText(openClawMediaAttachments), buildOpenClawFileDisplayText(openClawMediaAttachments),
] ]
.filter(Boolean) .filter(Boolean)
.join("\n\n"); .join("\n\n");
@ -13157,8 +13268,8 @@ export function CanvasChatPanel({
return null; return null;
} }
if (file.size > 50 * 1024 * 1024) { if (file.size > OPENCLAW_CHAT_FILE_MAX_BYTES) {
toast.error("图片大小不能超过 50MB"); toast.error("图片大小不能超过 2GB");
return null; return null;
} }
@ -13281,7 +13392,7 @@ export function CanvasChatPanel({
return null; return null;
} }
if (file.size > OPENCLAW_CHAT_VIDEO_MAX_BYTES) { if (file.size > OPENCLAW_CHAT_VIDEO_MAX_BYTES) {
toast.error(`${file.name} 超过 500MB,请先压缩视频`); toast.error(`${file.name} 超过 2GB,请先压缩视频`);
return null; return null;
} }
@ -13332,6 +13443,81 @@ export function CanvasChatPanel({
} }
}, [clearComposerUploadProgress, updateComposerUploadProgress]); }, [clearComposerUploadProgress, updateComposerUploadProgress]);
const uploadClawdFileForComposerAttachment = useCallback(async (
file: File,
progressOptions: CanvasUploadProgressOptions,
options?: {
purpose?: string;
onProgress?: (progress: UploadProgress) => void;
clearProgress?: boolean;
}
): Promise<ComposerUploadedImage> => {
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 ( const uploadClawdFolderForComposerAttachment = useCallback(async (
files: File[] files: File[]
): Promise<ComposerUploadedImage | null> => { ): Promise<ComposerUploadedImage | null> => {
@ -13346,13 +13532,11 @@ export function CanvasChatPanel({
const totalBytes = files.reduce((sum, file) => sum + file.size, 0); const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
if (totalBytes > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) { if (totalBytes > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
toast.error("文件夹总大小不能超过 300MB"); toast.error("文件夹总大小不能超过 2GB");
return null; return null;
} }
const relativePaths = files.map( const relativePaths = files.map(getClawdFolderFilePath);
(file) => file.webkitRelativePath || file.name
);
const rootFolders = Array.from( const rootFolders = Array.from(
new Set( new Set(
relativePaths relativePaths
@ -13365,16 +13549,10 @@ export function CanvasChatPanel({
? rootFolders[0] ? rootFolders[0]
: `${rootFolders.length}个文件夹-${Date.now()}`; : `${rootFolders.length}个文件夹-${Date.now()}`;
const progressOptions: CanvasUploadProgressOptions = { const progressOptions: CanvasUploadProgressOptions = {
title: "正在打包并上传文件夹", title: "正在上传文件夹",
fileName: `${archiveBaseName}.zip`, fileName: archiveBaseName,
target: "composer-reference", 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, { updateComposerUploadProgress(progressOptions, {
loaded: 0, loaded: 0,
@ -13384,74 +13562,191 @@ export function CanvasChatPanel({
}); });
try { try {
const data = await new Promise<Record<string, unknown>>( const fingerprint = getClawdFolderFingerprint(files);
(resolve, reject) => { const resumeState = readClawdFolderUploadResumeState(files);
const xhr = new XMLHttpRequest(); const state: ClawdFolderUploadResumeState = resumeState || {
xhr.open("POST", "/api/canvas/clawd-chat/folder-upload"); version: 1,
xhr.timeout = 10 * 60 * 1000; fingerprint,
xhr.upload.onprogress = (event) => { updatedAt: Date.now(),
const total = event.lengthComputable ? event.total : totalBytes; entries: {},
const rawPercent = total > 0 ? (event.loaded / total) * 100 : 5; };
updateComposerUploadProgress(progressOptions, { const uploadedEntries: Array<ClawdFolderUploadedEntry | undefined> =
loaded: event.loaded, new Array(files.length);
total, const loadedByIndex = new Map<number, number>();
percent: Math.max(1, Math.min(92, Math.round(rawPercent))), let aggregateLoaded = 0;
phase: "server",
}); 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, { updateComposerUploadProgress(progressOptions, {
loaded: totalBytes, loaded: totalBytes,
total: totalBytes, total: totalBytes,
percent: 94, percent: Math.max(97, Math.min(99, progress.percent)),
phase: "finalizing", phase: "finalizing",
}); });
}; },
xhr.onerror = () => reject(new Error("文件夹上传网络异常"));
xhr.ontimeout = () => reject(new Error("文件夹打包上传超时"));
xhr.onload = () => {
let payload: Record<string, unknown> = {};
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, { updateComposerUploadProgress(progressOptions, {
loaded: totalBytes, loaded: totalBytes,
total: totalBytes, total: totalBytes,
percent: 100, percent: 100,
phase: "complete", phase: "complete",
}); });
writeClawdFolderUploadResumeState(files, null);
clearComposerUploadProgress(350); clearComposerUploadProgress(350);
return { return {
imageUrl: url, ...uploadedManifest,
name: String(data.name || `${archiveBaseName}.zip`), name: manifestName,
contentType: "application/zip", contentType: "application/vnd.yedir.folder-manifest+json",
}; };
} catch (error) { } catch (error) {
clearComposerUploadProgress(); clearComposerUploadProgress();
toast.error(error instanceof Error ? error.message : "文件夹上传失败"); toast.error(error instanceof Error ? error.message : "文件夹上传失败");
return null; return null;
} }
}, [clearComposerUploadProgress, updateComposerUploadProgress]); }, [
clearComposerUploadProgress,
updateComposerUploadProgress,
uploadClawdFileForComposerAttachment,
]);
const handleClawdFolderSelect = useCallback(async ( const handleClawdFolderSelect = useCallback(async (
event: React.ChangeEvent<HTMLInputElement> event: React.ChangeEvent<HTMLInputElement>
@ -13461,14 +13756,14 @@ export function CanvasChatPanel({
if (files.length === 0) return; if (files.length === 0) return;
const existingArchiveCount = attachments.filter((attachment) => const existingArchiveCount = attachments.filter((attachment) =>
isOpenClawChatArchiveAttachment( isOpenClawChatFileAttachment(
attachment.url, attachment.url,
attachment.contentType attachment.contentType
) )
).length; ).length;
if (existingArchiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) { if (existingArchiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) {
toast.info( toast.info(
`一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个文件夹压缩包` `一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个表格、压缩包或文件夹`
); );
return; return;
} }
@ -13476,7 +13771,7 @@ export function CanvasChatPanel({
const uploaded = await uploadClawdFolderForComposerAttachment(files); const uploaded = await uploadClawdFolderForComposerAttachment(files);
if (!uploaded) return; if (!uploaded) return;
addUploadedImageAttachmentToComposer(uploaded); addUploadedImageAttachmentToComposer(uploaded);
toast.success("文件夹已打包上传到七牛云,可继续添加其他文件夹"); toast.success("文件夹已上传并保留目录结构,可继续添加其他文件夹");
}, [ }, [
addUploadedImageAttachmentToComposer, addUploadedImageAttachmentToComposer,
attachments, attachments,
@ -13761,31 +14056,31 @@ export function CanvasChatPanel({
let selectedFiles = Array.from(files); let selectedFiles = Array.from(files);
if (isClawdMode) { if (isClawdMode) {
const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file));
const imageFiles = selectedFiles.filter((file) => const imageFiles = selectedFiles.filter((file) =>
file.type.startsWith("image/") file.type.startsWith("image/")
); );
const videoFiles = selectedFiles.filter((file) => const videoFiles = selectedFiles.filter((file) =>
isOpenClawChatVideoFile(file) isOpenClawChatVideoFile(file)
); );
const genericFiles = selectedFiles.filter((file) =>
isOpenClawChatFile(file)
);
const unsupportedFiles = selectedFiles.filter( const unsupportedFiles = selectedFiles.filter(
(file) => (file) =>
!isClawdTableFile(file) &&
!file.type.startsWith("image/") && !file.type.startsWith("image/") &&
!isOpenClawChatVideoFile(file) !isOpenClawChatVideoFile(file) &&
!isOpenClawChatFile(file)
); );
unsupportedFiles.forEach((file) => { unsupportedFiles.forEach((file) => {
toast.error( toast.error(
`${file.name} 格式不支持,请上传图片、视频或 .xlsx/.csv/.tsv` `${file.name} 格式不支持,请上传图片、视频、表格或压缩包`
); );
}); });
if (tableFiles.length > 0) {
addClawdTableFiles(tableFiles);
}
let handledImageCount = 0; let handledImageCount = 0;
let handledVideoCount = 0; let handledVideoCount = 0;
let handledFileCount = 0;
if (imageFiles.length > 0) { if (imageFiles.length > 0) {
try { try {
for (const [index, file] of imageFiles.entries()) { 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) { if (handledImageCount > 0) {
toast.success( toast.success(
handledImageCount === 1 handledImageCount === 1
@ -13871,6 +14213,13 @@ export function CanvasChatPanel({
: `已添加 ${handledVideoCount} 个视频为 ${CLAW_MODE_LABEL} 附件` : `已添加 ${handledVideoCount} 个视频为 ${CLAW_MODE_LABEL} 附件`
); );
} }
if (handledFileCount > 0) {
toast.success(
handledFileCount === 1
? "文件已上传并添加到 Claw 对话"
: `已上传并添加 ${handledFileCount} 个文件`
);
}
e.target.value = ""; e.target.value = "";
return; return;
} }
@ -14105,58 +14454,6 @@ export function CanvasChatPanel({
e.target.value = ''; 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) => { const removeClawdTableFile = useCallback((index: number) => {
setClawdTableFiles((currentFiles) => setClawdTableFiles((currentFiles) =>
currentFiles.filter((_, fileIndex) => fileIndex !== index) currentFiles.filter((_, fileIndex) => fileIndex !== index)
@ -15626,7 +15923,7 @@ export function CanvasChatPanel({
message.role === "user" message.role === "user"
? collectMessageVideoAttachments(message as any) ? collectMessageVideoAttachments(message as any)
: []; : [];
const userArchiveAttachments: Array<{ const userFileAttachments: Array<{
url: string; url: string;
name: string; name: string;
contentType?: string; contentType?: string;
@ -15651,15 +15948,20 @@ export function CanvasChatPanel({
).trim(); ).trim();
return { return {
url, url,
name: String(part?.name || "Folder.zip").trim() || "Folder.zip", name: String(part?.name || "文件").trim() || "文件",
contentType: contentType || undefined, contentType: contentType || undefined,
}; };
}) })
.filter((attachment) => .filter(
isOpenClawChatArchiveAttachment( (attachment) =>
attachment.url, isOpenClawChatFileAttachment(
attachment.contentType attachment.url,
) attachment.contentType
) &&
!isClawdSpreadsheetAttachment(
attachment.url,
attachment.contentType
)
) )
.map((attachment) => [attachment.url, attachment]) .map((attachment) => [attachment.url, attachment])
).values() ).values()
@ -15883,10 +16185,10 @@ export function CanvasChatPanel({
); );
}); });
userArchiveAttachments.forEach((attachment, idx) => { userFileAttachments.forEach((attachment, idx) => {
renderedUserParts.push( renderedUserParts.push(
<a <a
key={`archive-${attachment.url}-${idx}`} key={`file-${attachment.url}-${idx}`}
href={attachment.url} href={attachment.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@ -15896,7 +16198,7 @@ export function CanvasChatPanel({
? "border border-border/60 bg-background" ? "border border-border/60 bg-background"
: "border border-amber-200/80 bg-amber-50/90 text-amber-950 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-100" : "border border-amber-200/80 bg-amber-50/90 text-amber-950 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-100"
)} )}
title="打开文件夹压缩包" title="打开文件"
> >
<FileArchive className="h-4 w-4 shrink-0" /> <FileArchive className="h-4 w-4 shrink-0" />
<span className="max-w-[120px] truncate text-current"> <span className="max-w-[120px] truncate text-current">
@ -16009,9 +16311,11 @@ export function CanvasChatPanel({
const shouldDeferAssistantMedia = const shouldDeferAssistantMedia =
(isClawAssistantMessage || shouldTypeAgentAssistantMessage) && (isClawAssistantMessage || shouldTypeAgentAssistantMessage) &&
(shouldAnimateAnalysis || shouldAnimatePlainText); (shouldAnimateAnalysis || shouldAnimatePlainText);
const standaloneImageUrls = isClawAssistantMessage const standaloneImageUrls =
? filterStandaloneAssistantImageUrls(rawText, imageUrls) filterStandaloneAssistantImageUrls(
: imageUrls; rawText,
imageUrls
);
const visibleImageUrls = shouldDeferAssistantMedia const visibleImageUrls = shouldDeferAssistantMedia
? [] ? []
: standaloneImageUrls; : standaloneImageUrls;
@ -17943,7 +18247,7 @@ export function CanvasChatPanel({
type="file" type="file"
accept={ accept={
isClawdMode isClawdMode
? `${CLAWD_IMAGE_ACCEPT},${OPENCLAW_CHAT_VIDEO_ACCEPT},${CLAWD_TABLE_ACCEPT}` ? `${CLAWD_IMAGE_ACCEPT},${OPENCLAW_CHAT_VIDEO_ACCEPT},${OPENCLAW_CHAT_FILE_ACCEPT}`
: supportsAtlasVideoReference : supportsAtlasVideoReference
? "image/*,video/mp4,video/quicktime,video/webm" ? "image/*,video/mp4,video/quicktime,video/webm"
: "image/*" : "image/*"

View File

@ -277,6 +277,10 @@ export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
const key = normalizeImageIdentityUrl(url); const key = normalizeImageIdentityUrl(url);
if (key) urlKeys.add(key); 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) { for (let index = 0; index < lines.length - 1; index += 1) {
const line = lines[index] || ""; const line = lines[index] || "";
@ -297,12 +301,36 @@ export function getMarkdownTableImageUrlKeys(text: string): Set<string> {
endIndex += 1; endIndex += 1;
} }
const tableText = lines.slice(index, endIndex).join("\n"); addUrlsFromTableText(lines.slice(index, endIndex).join("\n"));
extractAssistantImageUrls(tableText).forEach(addUrl);
extractPlainImageUrls(tableText).forEach(addUrl);
index = endIndex - 1; 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 = /<table\b[^>]*>[\s\S]*?<\/table>/gi;
let htmlTableMatch: RegExpExecArray | null = null;
while ((htmlTableMatch = htmlTablePattern.exec(String(text || ""))) !== null) {
addUrlsFromTableText(htmlTableMatch[0] || "");
}
return urlKeys; return urlKeys;
} }

View File

@ -459,7 +459,7 @@ function MarkdownTableImagePreview({
<> <>
<button <button
type="button" type="button"
className="group/table-image relative block h-24 w-24 overflow-hidden rounded-lg border border-border/70 bg-muted/40" className="group/table-image relative block h-16 w-16 overflow-hidden rounded-md border border-border/70 bg-muted/40"
data-no-drag="true" data-no-drag="true"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();

View File

@ -1,20 +1,30 @@
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_UPLOAD_PURPOSE,
} from "@/lib/openclaw-chat/media-attachments";
const DEFAULT_MAX_RETRIES = 3; const DEFAULT_MAX_RETRIES = 3;
const MAX_BACKOFF_MS = 8000; const MAX_BACKOFF_MS = 8000;
const MAX_CONCURRENT_UPLOADS = 3; const MAX_CONCURRENT_UPLOADS = 3;
const DIRECT_UPLOAD_MIN_BYTES = 1; const DIRECT_UPLOAD_MIN_BYTES = 1;
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 SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; const SERVER_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const DIRECT_UPLOAD_PART_CONCURRENCY = 4; const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000; const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
const CLIENT_UPLOAD_ID_FIELD = "clientUploadId"; const CLIENT_UPLOAD_ID_FIELD = "clientUploadId";
const DIRECT_UPLOAD_INITIATE_TIMEOUT_MS = 3 * 1000; const DIRECT_UPLOAD_INITIATE_TIMEOUT_MS = 30 * 1000;
const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 15 * 1000; const DIRECT_UPLOAD_ACTION_TIMEOUT_MS = 60 * 1000;
const DIRECT_UPLOAD_ABORT_TIMEOUT_MS = 8 * 1000; const DIRECT_UPLOAD_PART_TOTAL_TIMEOUT_MS = 30 * 60 * 1000;
const DIRECT_UPLOAD_STALL_TIMEOUT_MS = 10 * 1000; const DIRECT_UPLOAD_STALL_TIMEOUT_MS = 45 * 1000;
const XHR_UPLOAD_TOTAL_TIMEOUT_MS = 6 * 60 * 1000; const XHR_UPLOAD_TOTAL_TIMEOUT_MS = 6 * 60 * 1000;
const XHR_UPLOAD_STALL_TIMEOUT_MS = 30 * 1000; const XHR_UPLOAD_STALL_TIMEOUT_MS = 30 * 1000;
const DIRECT_UPLOAD_SESSION_PREFIX = "yedir:direct-upload:v1:";
const DIRECT_UPLOAD_SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
const RETRYABLE_UPLOAD_STATUSES = new Set([ const RETRYABLE_UPLOAD_STATUSES = new Set([
408, 408,
@ -53,6 +63,7 @@ let directUploadSuppressedUntil = 0;
export type UploadProgressPhase = export type UploadProgressPhase =
| "preparing" | "preparing"
| "resuming"
| "direct" | "direct"
| "multipart" | "multipart"
| "server" | "server"
@ -74,6 +85,7 @@ type UploadFormFile = {
contentType: string; contentType: string;
purpose: string; purpose: string;
clientUploadId: string; clientUploadId: string;
lastModified: number;
}; };
type DirectUploadPart = { type DirectUploadPart = {
@ -82,6 +94,10 @@ type DirectUploadPart = {
}; };
type DirectUploadInitiateResponse = type DirectUploadInitiateResponse =
| {
mode: "completed";
key: string;
}
| { | {
mode: "single"; mode: "single";
key: string; key: string;
@ -95,8 +111,37 @@ type DirectUploadInitiateResponse =
partSize: number; partSize: number;
partCount: number; partCount: number;
parts: DirectUploadPart[]; parts: DirectUploadPart[];
uploadedParts?: Array<{
ETag: string;
PartNumber: number;
size?: number;
}>;
resumed?: boolean;
}; };
type StoredDirectUploadSession = {
version: 1;
fingerprint: string;
key: string;
uploadId: string;
partSize: number;
partCount: number;
clientUploadId: string;
updatedAt: number;
};
class DirectUploadActionError extends Error {
code?: string;
status: number;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "DirectUploadActionError";
this.status = status;
this.code = code;
}
}
function createAbortError() { function createAbortError() {
const error = new Error("Upload aborted"); const error = new Error("Upload aborted");
error.name = "AbortError"; error.name = "AbortError";
@ -250,6 +295,105 @@ function normalizeBrowserImageType(contentType: string, filename: string) {
return BROWSER_IMAGE_TYPES_BY_EXTENSION[extension] || normalized; return BROWSER_IMAGE_TYPES_BY_EXTENSION[extension] || normalized;
} }
function normalizeUploadContentType(
contentType: string,
filename: string,
purpose: string
) {
if (purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE) {
return (
getOpenClawChatFileContentType({ name: filename, type: contentType }) ||
contentType.trim().toLowerCase() ||
"application/octet-stream"
);
}
if (purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE) {
return contentType.trim().toLowerCase() || "application/octet-stream";
}
return normalizeBrowserImageType(contentType, filename);
}
function hashUploadFingerprint(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 getUploadFingerprint(file: UploadFormFile) {
return [
file.purpose,
file.filename,
file.contentType,
file.blob.size,
file.lastModified,
].join("\u0000");
}
function getUploadSessionStorageKey(file: UploadFormFile) {
return `${DIRECT_UPLOAD_SESSION_PREFIX}${hashUploadFingerprint(
getUploadFingerprint(file)
)}`;
}
function clearStoredUploadSession(file: UploadFormFile) {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(getUploadSessionStorageKey(file));
} catch {}
}
function readStoredUploadSession(file: UploadFormFile) {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(getUploadSessionStorageKey(file));
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredDirectUploadSession;
const isValid =
parsed.version === 1 &&
parsed.fingerprint === getUploadFingerprint(file) &&
Boolean(parsed.key && parsed.uploadId && parsed.clientUploadId) &&
Number.isFinite(parsed.partSize) &&
Number.isFinite(parsed.partCount) &&
Date.now() - parsed.updatedAt <= DIRECT_UPLOAD_SESSION_TTL_MS;
if (!isValid) {
clearStoredUploadSession(file);
return null;
}
return parsed;
} catch {
clearStoredUploadSession(file);
return null;
}
}
function saveStoredUploadSession(
file: UploadFormFile,
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>,
clientUploadId = file.clientUploadId
) {
if (typeof window === "undefined") return;
const session: StoredDirectUploadSession = {
version: 1,
fingerprint: getUploadFingerprint(file),
key: upload.key,
uploadId: upload.uploadId,
partSize: upload.partSize,
partCount: upload.partCount,
clientUploadId,
updatedAt: Date.now(),
};
try {
window.localStorage.setItem(
getUploadSessionStorageKey(file),
JSON.stringify(session)
);
} catch {}
}
function createClientUploadId() { function createClientUploadId() {
if ( if (
typeof crypto !== "undefined" && typeof crypto !== "undefined" &&
@ -281,37 +425,62 @@ function getUploadFormFile(formData: FormData): UploadFormFile | null {
typeof File !== "undefined" && value instanceof File && value.name typeof File !== "undefined" && value instanceof File && value.name
? value.name ? value.name
: `upload-${Date.now()}`; : `upload-${Date.now()}`;
const contentType = normalizeBrowserImageType(
value.type || "application/octet-stream",
filename
);
const purposeValue = formData.get("purpose"); const purposeValue = formData.get("purpose");
const purpose = typeof purposeValue === "string" ? purposeValue : "";
const contentType = normalizeUploadContentType(
value.type || "application/octet-stream",
filename,
purpose
);
const clientUploadId = ensureClientUploadId(formData); const clientUploadId = ensureClientUploadId(formData);
return { return {
blob: value, blob: value,
filename, filename,
contentType, contentType,
purpose: typeof purposeValue === "string" ? purposeValue : "", purpose,
clientUploadId, clientUploadId,
lastModified:
typeof File !== "undefined" && value instanceof File
? value.lastModified
: 0,
}; };
} }
function shouldTryDirectUpload(file: UploadFormFile) { function shouldTryDirectUpload(file: UploadFormFile) {
if ( if (
file.purpose !== OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
file.purpose !== OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
Date.now() < directUploadSuppressedUntil && Date.now() < directUploadSuppressedUntil &&
file.blob.size <= SERVER_UPLOAD_MAX_BYTES file.blob.size <= SERVER_UPLOAD_MAX_BYTES
) { ) {
return false; return false;
} }
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) { const maxBytes =
file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE
? DIRECT_UPLOAD_MAX_BYTES
: DIRECT_UPLOAD_MEDIA_MAX_BYTES;
if (file.blob.size <= 0 || file.blob.size > maxBytes) {
return false; return false;
} }
const normalizedContentType = file.contentType.toLowerCase(); const normalizedContentType = file.contentType.toLowerCase();
const supportsDirectUpload = const supportsDirectUpload =
DIRECT_UPLOAD_IMAGE_TYPES.has(normalizedContentType) || DIRECT_UPLOAD_IMAGE_TYPES.has(normalizedContentType) ||
(file.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE && (file.purpose === OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE &&
DIRECT_UPLOAD_OPENCLAW_VIDEO_TYPES.has(normalizedContentType)); DIRECT_UPLOAD_OPENCLAW_VIDEO_TYPES.has(normalizedContentType)) ||
(file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE &&
Boolean(
getOpenClawChatFileContentType({
name: file.filename,
type: normalizedContentType,
})
)) ||
(file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE &&
isOpenClawChatFolderFileAllowed({
name: file.filename,
type: normalizedContentType,
}));
if (!supportsDirectUpload) { if (!supportsDirectUpload) {
return false; return false;
} }
@ -424,7 +593,16 @@ async function postDirectUploadAction<T>(
); );
if (!response.ok) { if (!response.ok) {
throw new Error(String(await readErrorPayload(response))); let code: string | undefined;
let message = response.statusText;
try {
const payload = await response.clone().json();
code = typeof payload?.code === "string" ? payload.code : undefined;
message = String(payload?.error || payload?.details || message);
} catch {
message = String(await readErrorPayload(response));
}
throw new DirectUploadActionError(message, response.status, code);
} }
return (await response.json()) as T; return (await response.json()) as T;
@ -578,7 +756,7 @@ async function fetchDirectPartWithRetry(
maxRetries: number, maxRetries: number,
signal?: AbortSignal, signal?: AbortSignal,
onProgress?: (loaded: number, total: number) => void, onProgress?: (loaded: number, total: number) => void,
options?: { stallTimeoutMs?: number } options?: { stallTimeoutMs?: number; timeoutMs?: number }
) { ) {
for (let attempt = 0; ; attempt += 1) { for (let attempt = 0; ; attempt += 1) {
try { try {
@ -589,6 +767,7 @@ async function fetchDirectPartWithRetry(
onProgress, onProgress,
signal, signal,
stallTimeoutMs: options?.stallTimeoutMs, stallTimeoutMs: options?.stallTimeoutMs,
timeoutMs: options?.timeoutMs,
} }
); );
@ -661,11 +840,38 @@ async function uploadMultipartParts(params: {
maxRetries: number; maxRetries: number;
signal?: AbortSignal; signal?: AbortSignal;
onProgress?: UploadProgressHandler; onProgress?: UploadProgressHandler;
onPartComplete?: () => void;
}) { }) {
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []; const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [
...(params.upload.uploadedParts || []).map((part) => ({
ETag: part.ETag,
PartNumber: part.PartNumber,
})),
];
const partLoadedBytes = new Map<number, number>(); const partLoadedBytes = new Map<number, number>();
let nextIndex = 0; let nextIndex = 0;
for (const part of params.upload.uploadedParts || []) {
const start = (part.PartNumber - 1) * params.upload.partSize;
const expectedSize = Math.max(
0,
Math.min(params.upload.partSize, params.file.blob.size - start)
);
partLoadedBytes.set(part.PartNumber, part.size || expectedSize);
}
if (partLoadedBytes.size > 0) {
emitUploadProgress(
params.onProgress,
Array.from(partLoadedBytes.values()).reduce(
(sum, value) => sum + value,
0
),
params.file.blob.size,
"resuming"
);
}
const reportPartProgress = (partNumber: number, loaded: number, total: number) => { const reportPartProgress = (partNumber: number, loaded: number, total: number) => {
partLoadedBytes.set(partNumber, Math.min(loaded, total || loaded)); partLoadedBytes.set(partNumber, Math.min(loaded, total || loaded));
const uploadedBytes = Array.from(partLoadedBytes.values()).reduce( const uploadedBytes = Array.from(partLoadedBytes.values()).reduce(
@ -700,7 +906,10 @@ async function uploadMultipartParts(params: {
(loaded, total) => { (loaded, total) => {
reportPartProgress(part.partNumber, loaded, total || body.size); reportPartProgress(part.partNumber, loaded, total || body.size);
}, },
{ stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS } {
stallTimeoutMs: DIRECT_UPLOAD_STALL_TIMEOUT_MS,
timeoutMs: DIRECT_UPLOAD_PART_TOTAL_TIMEOUT_MS,
}
); );
if (!response.ok) { if (!response.ok) {
@ -718,6 +927,7 @@ async function uploadMultipartParts(params: {
ETag: etag, ETag: etag,
PartNumber: part.partNumber, PartNumber: part.partNumber,
}); });
params.onPartComplete?.();
} }
} }
@ -728,7 +938,11 @@ async function uploadMultipartParts(params: {
) )
); );
return uploadedParts.sort((a, b) => a.PartNumber - b.PartNumber); return Array.from(
new Map(
uploadedParts.map((part) => [part.PartNumber, part] as const)
).values()
).sort((a, b) => a.PartNumber - b.PartNumber);
} }
async function fetchDirectUpload( async function fetchDirectUpload(
@ -745,23 +959,95 @@ async function fetchDirectUpload(
let multipartUpload: let multipartUpload:
| Extract<DirectUploadInitiateResponse, { mode: "multipart" }> | Extract<DirectUploadInitiateResponse, { mode: "multipart" }>
| null = null; | null = null;
let activeClientUploadId = file.clientUploadId;
try { try {
emitUploadProgress(onProgress, 0, file.blob.size, "preparing", { emitUploadProgress(onProgress, 0, file.blob.size, "preparing", {
percent: 1, percent: 1,
}); });
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>( const storedSession = readStoredUploadSession(file);
{ let upload: DirectUploadInitiateResponse | null = null;
action: "initiate",
filename: file.filename, if (storedSession) {
contentType: file.contentType, activeClientUploadId = storedSession.clientUploadId;
size: file.blob.size, emitUploadProgress(onProgress, 0, file.blob.size, "resuming", {
purpose: file.purpose, percent: 1,
clientUploadId: file.clientUploadId, });
}, try {
signal, upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS {
); action: "resume",
key: storedSession.key,
uploadId: storedSession.uploadId,
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
partSize: storedSession.partSize,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
},
signal
);
} catch (error) {
if (
error instanceof DirectUploadActionError &&
((error.status === 409 &&
error.code === "RESUME_SESSION_EXPIRED") ||
error.status === 403)
) {
clearStoredUploadSession(file);
activeClientUploadId = file.clientUploadId;
} else {
throw error;
}
}
}
if (!upload) {
upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
{
action: "initiate",
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
},
signal,
DIRECT_UPLOAD_INITIATE_TIMEOUT_MS
);
}
if (upload.mode === "completed") {
emitUploadProgress(
onProgress,
file.blob.size,
file.blob.size,
"finalizing",
{ percent: 99 }
);
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
action: "complete",
key: upload.key,
filename: file.filename,
contentType: file.contentType,
size: file.blob.size,
purpose: file.purpose,
clientUploadId: activeClientUploadId,
},
signal
);
clearStoredUploadSession(file);
emitUploadProgress(
onProgress,
file.blob.size,
file.blob.size,
"complete",
{ percent: 100 }
);
return createJsonResponse(payload);
}
if (upload.mode === "single") { if (upload.mode === "single") {
emitUploadProgress(onProgress, 0, file.blob.size, "direct", { emitUploadProgress(onProgress, 0, file.blob.size, "direct", {
@ -799,7 +1085,7 @@ async function fetchDirectUpload(
contentType: file.contentType, contentType: file.contentType,
size: file.blob.size, size: file.blob.size,
purpose: file.purpose, purpose: file.purpose,
clientUploadId: file.clientUploadId, clientUploadId: activeClientUploadId,
}, },
signal signal
); );
@ -811,15 +1097,21 @@ async function fetchDirectUpload(
} }
multipartUpload = upload; multipartUpload = upload;
emitUploadProgress(onProgress, 0, file.blob.size, "multipart", { saveStoredUploadSession(file, upload, activeClientUploadId);
percent: 2, if (!upload.resumed) {
}); emitUploadProgress(onProgress, 0, file.blob.size, "multipart", {
percent: 2,
});
}
const parts = await uploadMultipartParts({ const parts = await uploadMultipartParts({
file, file,
upload, upload,
maxRetries, maxRetries,
signal, signal,
onProgress, onProgress,
onPartComplete: () => {
saveStoredUploadSession(file, upload, activeClientUploadId);
},
}); });
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", { emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
percent: 99, percent: 99,
@ -834,37 +1126,33 @@ async function fetchDirectUpload(
size: file.blob.size, size: file.blob.size,
parts, parts,
purpose: file.purpose, purpose: file.purpose,
clientUploadId: file.clientUploadId, clientUploadId: activeClientUploadId,
}, },
signal signal
); );
clearStoredUploadSession(file);
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", { emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
percent: 100, percent: 100,
}); });
return createJsonResponse(payload); return createJsonResponse(payload);
} catch (error) { } catch (error) {
if (multipartUpload && !isAbortError(error, signal)) {
postDirectUploadAction(
{
action: "abort",
key: multipartUpload.key,
uploadId: multipartUpload.uploadId,
clientUploadId: file.clientUploadId,
},
signal,
DIRECT_UPLOAD_ABORT_TIMEOUT_MS
).catch(() => {});
}
if (!isAbortError(error, signal)) { if (!isAbortError(error, signal)) {
if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) { if (
multipartUpload ||
file.purpose === OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE ||
file.purpose === OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE ||
file.blob.size > SERVER_UPLOAD_MAX_BYTES
) {
return createJsonResponse( return createJsonResponse(
{ {
error: error:
"文件超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。", multipartUpload
? "上传已暂停,重新选择同一文件将从已完成的分片继续上传。"
: "文件直传失败,请重新选择同一文件后重试。",
details: error instanceof Error ? error.message : String(error), details: error instanceof Error ? error.message : String(error),
clientUploadId: file.clientUploadId, resumable: Boolean(multipartUpload),
clientUploadId: activeClientUploadId,
}, },
{ status: 502 } { status: 502 }
); );

View File

@ -1,13 +1,18 @@
export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video"; export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video";
export const OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE = "openclaw-chat-folder"; export const OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE = "openclaw-chat-folder";
export const OPENCLAW_CHAT_FILE_UPLOAD_PURPOSE = "openclaw-chat-file";
export const OPENCLAW_CHAT_FILE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2; export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2;
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = 500 * 1024 * 1024; export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_CHAT_FILE_MAX_FILES = 10;
export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5; export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5;
export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000; export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000;
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = 300 * 1024 * 1024; export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS = 16; export const OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS = 16;
export const OPENCLAW_CHAT_VIDEO_ACCEPT = export const OPENCLAW_CHAT_VIDEO_ACCEPT =
"video/mp4,video/quicktime,video/webm,video/x-m4v,video/x-matroska,.mp4,.mov,.webm,.m4v,.mkv"; "video/mp4,video/quicktime,video/webm,video/x-m4v,video/x-matroska,.mp4,.mov,.webm,.m4v,.mkv";
export const OPENCLAW_CHAT_FILE_ACCEPT =
".zip,.rar,.7z,.tar,.gz,.tgz,.pdf,.txt,.md,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.tsv,.json,.xml,.mp3,.wav,.m4a,.aac,.flac,.ogg,.psd,.ai,.dxf,.dwg,.step,.stp,application/zip,application/x-zip-compressed,application/vnd.rar,application/x-rar-compressed,application/x-7z-compressed,application/x-tar,application/gzip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/csv,text/tab-separated-values,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/flac,audio/ogg";
const VIDEO_CONTENT_TYPES = new Set([ const VIDEO_CONTENT_TYPES = new Set([
"video/mp4", "video/mp4",
@ -27,6 +32,51 @@ const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i; const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i; const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i;
const OPENCLAW_FILE_EXTENSION_PATTERN =
/\.(zip|rar|7z|tar|gz|tgz|pdf|txt|md|docx?|pptx?|xlsx?|csv|tsv|json|xml|mp3|wav|m4a|aac|flac|ogg|psd|ai|dxf|dwg|step|stp|folder\.json)(?:$|[?#])/i;
const BLOCKED_INLINE_FILE_EXTENSION_PATTERN =
/\.(html?|xhtml|svg|js|mjs|cjs|exe|dll|msi|bat|cmd|com|scr|ps1|sh|jar|apk|dmg|pkg|deb|rpm)(?:$|[?#])/i;
const BLOCKED_INLINE_FILE_CONTENT_TYPES = new Set([
"application/java-archive",
"application/javascript",
"application/vnd.android.package-archive",
"application/x-apple-diskimage",
"application/x-dosexec",
"application/x-msdownload",
"application/x-msi",
"application/x-sh",
"application/xhtml+xml",
"image/svg+xml",
"text/html",
"text/javascript",
]);
const FILE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
"7z": "application/x-7z-compressed",
csv: "text/csv",
gz: "application/gzip",
rar: "application/vnd.rar",
tar: "application/x-tar",
tgz: "application/gzip",
tsv: "text/tab-separated-values",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
zip: "application/zip",
};
const GENERIC_FILE_CONTENT_TYPES = new Set([
"application/gzip",
"application/octet-stream",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.rar",
"application/vnd.yedir.folder-manifest+json",
"application/x-7z-compressed",
"application/x-rar-compressed",
"application/x-tar",
"application/x-zip-compressed",
"application/zip",
"text/csv",
"text/tab-separated-values",
]);
export type OpenClawChatMediaAttachment = { export type OpenClawChatMediaAttachment = {
type: "image" | "video" | "file"; type: "image" | "video" | "file";
@ -105,6 +155,70 @@ export function isOpenClawChatArchiveAttachment(
); );
} }
export function getOpenClawChatFileContentType(file: {
name: string;
type?: string | null;
}) {
const normalized = normalizeContentType(file.type);
if (
normalized.startsWith("image/") ||
normalized.startsWith("video/") ||
BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized) ||
BLOCKED_INLINE_FILE_EXTENSION_PATTERN.test(file.name)
) {
return "";
}
if (
normalized &&
normalized !== "application/octet-stream" &&
!BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized)
) {
return normalized;
}
if (file.name.toLowerCase().endsWith(".folder.json")) {
return "application/vnd.yedir.folder-manifest+json";
}
return (
FILE_CONTENT_TYPE_BY_EXTENSION[getExtension(file.name)] ||
(OPENCLAW_FILE_EXTENSION_PATTERN.test(file.name)
? "application/octet-stream"
: "")
);
}
export function isOpenClawChatFile(file: {
name: string;
type?: string | null;
}) {
return Boolean(getOpenClawChatFileContentType(file));
}
export function isOpenClawChatFolderFileAllowed(file: {
name: string;
type?: string | null;
}) {
const normalized = normalizeContentType(file.type);
return !(
BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalized) ||
BLOCKED_INLINE_FILE_EXTENSION_PATTERN.test(file.name)
);
}
export function isOpenClawChatFileAttachment(
url: string,
contentType?: string | null
) {
const normalizedContentType = normalizeContentType(contentType);
return (
GENERIC_FILE_CONTENT_TYPES.has(normalizedContentType) ||
(Boolean(normalizedContentType) &&
!normalizedContentType.startsWith("image/") &&
!normalizedContentType.startsWith("video/") &&
!BLOCKED_INLINE_FILE_CONTENT_TYPES.has(normalizedContentType)) ||
OPENCLAW_FILE_EXTENSION_PATTERN.test(String(url || ""))
);
}
export function normalizeOpenClawChatMediaAttachments( export function normalizeOpenClawChatMediaAttachments(
value: unknown value: unknown
): OpenClawChatMediaAttachment[] { ): OpenClawChatMediaAttachment[] {
@ -135,40 +249,37 @@ export function normalizeOpenClawChatMediaAttachments(
const isVideo = const isVideo =
requestedType === "video" || requestedType === "video" ||
isOpenClawChatVideoAttachment(url, rawContentType); isOpenClawChatVideoAttachment(url, rawContentType);
const isArchive = const isFile =
!isVideo && !isVideo &&
(requestedType === "file" || (requestedType === "file" ||
requestedType === "archive" || requestedType === "archive" ||
isOpenClawChatArchiveAttachment(url, rawContentType)); isOpenClawChatFileAttachment(url, rawContentType));
if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) { if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) {
continue; continue;
} }
if (isArchive && archiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) { if (isFile && archiveCount >= OPENCLAW_CHAT_FILE_MAX_FILES) {
continue; continue;
} }
if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) { if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) {
break; break;
} }
const type = isVideo ? "video" : isArchive ? "file" : "image";
const fallbackContentType = isVideo
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
: 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 || "") const name = String(raw.name || "")
.trim() .trim()
.slice(0, 200); .slice(0, 200);
const type = isVideo ? "video" : isFile ? "file" : "image";
const fallbackContentType = isVideo
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
: isFile
? getOpenClawChatFileContentType({ name: name || url, type: rawContentType }) ||
"application/octet-stream"
: "image/jpeg";
const contentType = isFile
? rawContentType || fallbackContentType
: rawContentType.startsWith(`${type}/`)
? rawContentType
: fallbackContentType;
result.push({ result.push({
type, type,
url, url,
@ -179,7 +290,7 @@ export function normalizeOpenClawChatMediaAttachments(
if (isVideo) { if (isVideo) {
videoCount += 1; videoCount += 1;
} }
if (isArchive) { if (isFile) {
archiveCount += 1; archiveCount += 1;
} }
} }
@ -236,6 +347,20 @@ export function buildOpenClawArchiveDisplayText(
return `已上传文件夹:\n${lines.join("\n")}`; return `已上传文件夹:\n${lines.join("\n")}`;
} }
export function buildOpenClawFileDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const files = attachments.filter((attachment) => attachment.type === "file");
if (files.length === 0) {
return "";
}
const lines = files.map(
(attachment, index) => `- ${attachment.name || `文件附件${index + 1}`}`
);
return `已上传文件:\n${lines.join("\n")}`;
}
export function buildOpenClawArchiveReferenceText( export function buildOpenClawArchiveReferenceText(
attachments: OpenClawChatMediaAttachment[] attachments: OpenClawChatMediaAttachment[]
) { ) {
@ -255,3 +380,21 @@ export function buildOpenClawArchiveReferenceText(
lines.join("\n"), lines.join("\n"),
].join("\n"); ].join("\n");
} }
export function buildOpenClawFileReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const files = attachments.filter((attachment) => attachment.type === "file");
if (files.length === 0) {
return "";
}
const lines = files.map(
(attachment, index) =>
`- ${attachment.name || `文件附件${index + 1}`}: ${attachment.url} (${attachment.content_type || "application/octet-stream"})`
);
return [
"用户上传了以下文件。表格请下载后读取,压缩包请下载并解压;application/vnd.yedir.folder-manifest+json 是保留目录结构和各文件下载地址的文件夹清单:",
lines.join("\n"),
].join("\n");
}

View File

@ -1,7 +1,9 @@
import Papa from "papaparse"; import Papa from "papaparse";
import { OPENCLAW_CHAT_FILE_MAX_BYTES } from "@/lib/openclaw-chat/media-attachments";
export const OPENCLAW_SPREADSHEET_MAX_FILES = 3; export const OPENCLAW_SPREADSHEET_MAX_FILES = 3;
export const OPENCLAW_SPREADSHEET_MAX_BYTES = 10 * 1024 * 1024; export const OPENCLAW_SPREADSHEET_MAX_BYTES = OPENCLAW_CHAT_FILE_MAX_BYTES;
export const OPENCLAW_SPREADSHEET_INLINE_PARSE_MAX_BYTES = 10 * 1024 * 1024;
export const OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET = 200; export const OPENCLAW_SPREADSHEET_MAX_ROWS_PER_SHEET = 200;
export const OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET = 30; export const OPENCLAW_SPREADSHEET_MAX_COLS_PER_SHEET = 30;
export const OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE = 5; export const OPENCLAW_SPREADSHEET_MAX_SHEETS_PER_FILE = 5;
@ -330,8 +332,10 @@ export async function spreadsheetFileToOpenClawMarkdown(file: File) {
if (file.size <= 0) { if (file.size <= 0) {
throw new Error(`${getSafeFileName(file.name)} 是空文件`); throw new Error(`${getSafeFileName(file.name)} 是空文件`);
} }
if (file.size > OPENCLAW_SPREADSHEET_MAX_BYTES) { if (file.size > OPENCLAW_SPREADSHEET_INLINE_PARSE_MAX_BYTES) {
throw new Error(`${getSafeFileName(file.name)} 超过 10MB,请先拆分或压缩`); throw new Error(
`${getSafeFileName(file.name)} 超过 10MB,请使用 Claw 文件附件直传(最高 2GB)`
);
} }
const extension = getSpreadsheetExtension(file.name); const extension = getSpreadsheetExtension(file.name);

View File

@ -272,8 +272,26 @@ export const canvasService = {
return response; return response;
}; };
const response = await doSaveWithRateLimitRetry(buildRequestBody(options)); let response = await doSaveWithRateLimitRetry(buildRequestBody(options));
const data = await response.json().catch(() => ({})); let data = await response.json().catch(() => ({}));
const serverRevision = normalizeRevisionText(data?.canvasUpdatedAt);
if (
response.status === 409 &&
data?.code === "STALE_CANVAS_REVISION" &&
serverRevision
) {
response = await doSaveWithRateLimitRetry(
buildRequestBody(
{
baseCanvasRevision: serverRevision,
baseCanvasUpdatedAt: serverRevision,
},
false
)
);
data = await response.json().catch(() => ({}));
}
if (!response.ok) { if (!response.ok) {
return { return {

View File

@ -5201,6 +5201,22 @@ test.describe("canvas chat pure helpers", () => {
]); ]);
}); });
test("dedupes images in nonstandard pipe tables and HTML tables", () => {
const markdown = [
"| 商品 | 主图 |",
"| A | ![主图](https://cdn.example.com/pipe-table.png?token=secret) |",
"",
'<table><tr><td>商品 B</td><td><img src="https://cdn.example.com/html-table.webp?token=secret" /></td></tr></table>',
"",
"![补充](https://cdn.example.com/standalone.jpg?token=secret)",
].join("\n");
const imageUrls = extractAssistantImageUrls(markdown);
expect(filterStandaloneAssistantImageUrls(markdown, imageUrls)).toEqual([
"https://cdn.example.com/standalone.jpg?token=secret",
]);
});
test("extracts and strips video markup without removing surrounding text", () => { test("extracts and strips video markup without removing surrounding text", () => {
const text = [ const text = [
"生成完成", "生成完成",

View File

@ -7,8 +7,13 @@ import {
import { import {
buildOpenClawArchiveDisplayText, buildOpenClawArchiveDisplayText,
buildOpenClawArchiveReferenceText, buildOpenClawArchiveReferenceText,
buildOpenClawFileReferenceText,
getOpenClawChatFileContentType,
isOpenClawChatFolderFileAllowed,
isOpenClawChatArchiveAttachment, isOpenClawChatArchiveAttachment,
isOpenClawChatFileAttachment,
normalizeOpenClawChatMediaAttachments, normalizeOpenClawChatMediaAttachments,
OPENCLAW_CHAT_FILE_MAX_BYTES,
} from "@/lib/openclaw-chat/media-attachments"; } from "@/lib/openclaw-chat/media-attachments";
test.describe("OpenClaw folder attachments", () => { test.describe("OpenClaw folder attachments", () => {
@ -74,4 +79,71 @@ test.describe("OpenClaw folder attachments", () => {
); );
expect(buildOpenClawArchiveReferenceText(attachments)).toContain("解压"); expect(buildOpenClawArchiveReferenceText(attachments)).toContain("解压");
}); });
test("accepts 2GB spreadsheet and folder-manifest attachments", () => {
expect(OPENCLAW_CHAT_FILE_MAX_BYTES).toBe(2 * 1024 * 1024 * 1024);
expect(
getOpenClawChatFileContentType({
name: "清单.xlsx",
type: "application/octet-stream",
})
).toBe(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
expect(
getOpenClawChatFileContentType({
name: "产品资料.folder.json",
type: "application/vnd.yedir.folder-manifest+json",
})
).toBe("application/vnd.yedir.folder-manifest+json");
const attachments = normalizeOpenClawChatMediaAttachments([
{
type: "file",
url: "https://cdn.example.com/清单.xlsx",
name: "清单.xlsx",
contentType:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
type: "file",
url: "https://cdn.example.com/产品资料.folder.json",
name: "产品资料.folder.json",
contentType: "application/vnd.yedir.folder-manifest+json",
},
]);
expect(attachments).toHaveLength(2);
expect(
isOpenClawChatFileAttachment(
attachments[0].url,
attachments[0].content_type
)
).toBe(true);
expect(buildOpenClawFileReferenceText(attachments)).toContain("表格请下载");
expect(buildOpenClawFileReferenceText(attachments)).toContain(
"application/vnd.yedir.folder-manifest+json"
);
});
test("allows normal folder media while blocking executable web content", () => {
expect(
isOpenClawChatFolderFileAllowed({
name: "商品图/主图.png",
type: "image/png",
})
).toBe(true);
expect(
isOpenClawChatFolderFileAllowed({
name: "页面/index.html",
type: "text/html",
})
).toBe(false);
expect(
isOpenClawChatFolderFileAllowed({
name: "工具/setup.exe",
type: "application/octet-stream",
})
).toBe(false);
});
}); });