feat(claw): support resumable 2GB file uploads
This commit is contained in:
parent
3f2b18d18d
commit
1dd489100d
@ -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,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -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/*"
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -5201,6 +5201,22 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("dedupes images in nonstandard pipe tables and HTML tables", () => {
|
||||||
|
const markdown = [
|
||||||
|
"| 商品 | 主图 |",
|
||||||
|
"| A |  |",
|
||||||
|
"",
|
||||||
|
'<table><tr><td>商品 B</td><td><img src="https://cdn.example.com/html-table.webp?token=secret" /></td></tr></table>',
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].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 = [
|
||||||
"生成完成",
|
"生成完成",
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user