feat(claw): add secure forms and folder uploads

This commit is contained in:
134 2026-08-11 10:04:33 +08:00
parent f3f1fdafbd
commit 8a075045f0
8 changed files with 1023 additions and 101 deletions

View File

@ -0,0 +1,141 @@
import JSZip from "jszip";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getAllStorageConfigs } from "@/lib/db/queries";
import {
countOpenClawArchiveRootFolders,
sanitizeOpenClawArchiveEntryPath,
sanitizeOpenClawArchiveName,
} from "@/lib/openclaw-chat/folder-archive";
import {
OPENCLAW_CHAT_FOLDER_MAX_FILES,
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
} from "@/lib/openclaw-chat/media-attachments";
import { createLogger } from "@/lib/observability/logger";
import {
toDbS3UploadConfig,
uploadFileWithS3Config,
} from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 600;
const logger = createLogger("openclaw-folder-upload");
function isUploadedFile(value: FormDataEntryValue): value is File {
return (
typeof value === "object" &&
value !== null &&
"arrayBuffer" in value &&
"name" in value &&
"size" in value
);
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
try {
const formData = await request.formData();
const files = formData
.getAll("files")
.filter((entry): entry is File => isUploadedFile(entry));
const relativePaths = formData
.getAll("relativePaths")
.map((entry) => (typeof entry === "string" ? entry : ""));
if (files.length === 0) {
return NextResponse.json(
{ error: "所选文件夹中没有可上传文件" },
{ status: 400 }
);
}
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
return NextResponse.json(
{ error: `单次最多打包 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件` },
{ status: 413 }
);
}
const originalSize = files.reduce((sum, file) => sum + file.size, 0);
if (originalSize > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
return NextResponse.json(
{ error: "文件夹总大小不能超过 300MB" },
{ status: 413 }
);
}
const archivePaths = files.map((file, index) =>
sanitizeOpenClawArchiveEntryPath(
relativePaths[index] || file.name,
file.name,
index
)
);
const zip = new JSZip();
for (const [index, file] of files.entries()) {
zip.file(archivePaths[index], await file.arrayBuffer(), {
date: new Date(file.lastModified || Date.now()),
});
}
const archiveBaseName = sanitizeOpenClawArchiveName(
String(formData.get("archiveName") || archivePaths[0]?.split("/")[0] || "")
);
const archiveName = `${archiveBaseName.replace(/\.zip$/i, "")}.zip`;
const archiveBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
platform: "UNIX",
});
const storageConfigs = await getAllStorageConfigs();
const qiniuStorage = storageConfigs
.filter((config) => config.type === "qiniu-kodo" && config.isEnabled)
.sort((left, right) => Number(right.isDefault) - Number(left.isDefault))
.find((config) => toDbS3UploadConfig(config));
const qiniuConfig = toDbS3UploadConfig(qiniuStorage);
if (!qiniuConfig) {
return NextResponse.json(
{ error: "系统尚未配置可用的七牛云储存桶" },
{ status: 503 }
);
}
const uploaded = await uploadFileWithS3Config(
archiveName,
toArrayBuffer(archiveBuffer),
qiniuConfig
);
if (!/^https?:\/\//i.test(uploaded.url)) {
throw new Error("七牛云未返回可访问的公网地址");
}
return NextResponse.json({
url: uploaded.url,
name: archiveName,
contentType: "application/zip",
size: archiveBuffer.byteLength,
originalSize,
fileCount: files.length,
folderCount: countOpenClawArchiveRootFolders(archivePaths),
});
} catch (error) {
logger.error("Failed to package and upload Claw folders", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "文件夹上传失败" },
{ status: 500 }
);
}
}

View File

@ -55,6 +55,19 @@ const requestSchema = z.object({
displayMessage: z.string().trim().max(8000).optional(),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
sensitiveInput: z
.discriminatedUnion("type", [
z.object({
type: z.literal("taobao_credentials"),
username: z.string().min(1).max(160),
password: z.string().min(1).max(256),
}),
z.object({
type: z.literal("taobao_verification"),
verification_code: z.string().min(4).max(12),
}),
])
.optional(),
attachments: z
.array(z.unknown())
.max(OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS)
@ -102,6 +115,7 @@ type ExecuteClawdTaskParams = {
conversationId: string;
departmentName: string;
metadata: Record<string, unknown> | undefined;
sensitiveInput?: z.infer<typeof requestSchema>["sensitiveInput"];
mediaAttachments: OpenClawChatMediaAttachment[];
skillTagId: string;
skillTagName: string;
@ -168,8 +182,10 @@ async function parseClawdChatRequest(
let metadata: Record<string, unknown> | undefined;
let attachments: unknown[] = [];
let sensitiveInput: unknown;
const metadataValue = formData.get("metadata");
const attachmentsValue = formData.get("attachments");
const sensitiveInputValue = formData.get("sensitiveInput");
try {
if (typeof metadataValue === "string" && metadataValue.trim()) {
const parsedMetadata = JSON.parse(metadataValue);
@ -181,6 +197,9 @@ async function parseClawdChatRequest(
if (!Array.isArray(parsedAttachments)) return null;
attachments = parsedAttachments;
}
if (typeof sensitiveInputValue === "string" && sensitiveInputValue.trim()) {
sensitiveInput = JSON.parse(sensitiveInputValue);
}
} catch {
return null;
}
@ -192,6 +211,7 @@ async function parseClawdChatRequest(
userMessageId: formData.get("userMessageId") || undefined,
metadata,
attachments,
sensitiveInput,
});
if (!parsed.success) return null;
const normalizedAttachments = normalizeOpenClawChatMediaAttachments(
@ -499,15 +519,27 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
assistantMessageId: params.assistantMessageId,
});
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.config.token}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({
conversation_id: params.conversationId,
const isSensitiveSubmission = Boolean(params.sensitiveInput);
const upstreamBody = JSON.stringify({
conversation_id: params.conversationId,
user_id: params.userId,
user_name: params.userName,
username: params.userName,
department: params.departmentName,
department_name: params.departmentName,
user_department: params.departmentName,
skill_tag_id: params.skillTagId || undefined,
skill_tag_name: params.skillTagName || undefined,
message: params.upstreamMessage,
attachments:
params.mediaAttachments.length > 0
? params.mediaAttachments
: undefined,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
tenant: "default",
chat_id: params.chatId,
user_id: params.userId,
user_name: params.userName,
username: params.userName,
@ -516,38 +548,29 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
user_department: params.departmentName,
skill_tag_id: params.skillTagId || undefined,
skill_tag_name: params.skillTagName || undefined,
message: params.upstreamMessage,
attachments:
media_attachments:
params.mediaAttachments.length > 0
? params.mediaAttachments
: undefined,
metadata: {
source: "ai_chatbot_canvas",
page: "/canvas",
tenant: "default",
chat_id: params.chatId,
user_id: params.userId,
user_name: params.userName,
username: params.userName,
department: params.departmentName,
department_name: params.departmentName,
user_department: params.departmentName,
skill_tag_id: params.skillTagId || undefined,
skill_tag_name: params.skillTagName || undefined,
media_attachments:
params.mediaAttachments.length > 0
? params.mediaAttachments
: undefined,
spreadsheet_files:
params.spreadsheetFiles.length > 0
? params.spreadsheetFiles
: undefined,
spreadsheet_markdown_truncated:
params.spreadsheetMarkdownTruncated || undefined,
...params.metadata,
},
stream_progress: true,
}),
spreadsheet_files:
params.spreadsheetFiles.length > 0
? params.spreadsheetFiles
: undefined,
spreadsheet_markdown_truncated:
params.spreadsheetMarkdownTruncated || undefined,
...params.metadata,
},
stream_progress: !isSensitiveSubmission,
sensitive_input: params.sensitiveInput,
});
const response = await fetch(`${params.config.baseUrl}/api/chat`, {
method: "POST",
headers: {
Authorization: `Bearer ${params.config.token}`,
"Content-Type": "application/json",
Accept: isSensitiveSubmission ? "application/json" : "text/event-stream",
},
body: upstreamBody,
signal: controller.signal,
cache: "no-store",
});
@ -602,6 +625,13 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) {
error: getOpenClawTaskFailureMessage(error),
}).catch(() => null);
} finally {
if (params.sensitiveInput?.type === "taobao_credentials") {
params.sensitiveInput.username = "";
params.sensitiveInput.password = "";
} else if (params.sensitiveInput?.type === "taobao_verification") {
params.sensitiveInput.verification_code = "";
}
params.sensitiveInput = undefined;
clearTimeout(timeout);
unregisterActiveClawdRequest(params.cancelKey);
}
@ -752,11 +782,19 @@ export async function POST(request: Request) {
const messageAttachments = parsed.attachments.map((attachment) => ({
name:
attachment.name ||
(attachment.type === "video" ? "视频附件" : "图片附件"),
(attachment.type === "video"
? "视频附件"
: attachment.type === "file"
? "文件夹压缩包.zip"
: "图片附件"),
url: attachment.url,
contentType:
attachment.content_type ||
(attachment.type === "video" ? "video/mp4" : "image/jpeg"),
(attachment.type === "video"
? "video/mp4"
: attachment.type === "file"
? "application/zip"
: "image/jpeg"),
}));
await persistPendingClawdTask({
@ -794,6 +832,7 @@ export async function POST(request: Request) {
conversationId: createConversationId(session.user.id, parsed.id),
departmentName: String(userProfile?.department || "").trim(),
metadata: parsed.metadata,
sensitiveInput: parsed.sensitiveInput,
mediaAttachments: parsed.attachments,
skillTagId,
skillTagName,

View File

@ -25,7 +25,7 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, Paperclip, Copy, Square, Search, GripVertical, Volume2 } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -307,12 +307,18 @@ import {
updateClawdRequestState,
} from "@/components/canvas/chat-panel/clawd-request-state";
import {
buildOpenClawArchiveDisplayText,
buildOpenClawArchiveReferenceText,
buildOpenClawVideoDisplayText,
buildOpenClawVideoReferenceText,
getOpenClawChatVideoContentType,
isOpenClawChatArchiveAttachment,
isOpenClawChatVideoAttachment,
isOpenClawChatVideoFile,
normalizeOpenClawChatMediaAttachments,
OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES,
OPENCLAW_CHAT_FOLDER_MAX_FILES,
OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES,
OPENCLAW_CHAT_VIDEO_ACCEPT,
OPENCLAW_CHAT_VIDEO_MAX_BYTES,
OPENCLAW_CHAT_VIDEO_MAX_FILES,
@ -807,9 +813,15 @@ function shouldShowClawdProcessingHint(
message: string,
hasTableFiles: boolean,
hasImageAttachments: boolean,
hasVideoAttachments: boolean
hasVideoAttachments: boolean,
hasFolderAttachments: boolean
) {
if (hasTableFiles || hasImageAttachments || hasVideoAttachments) {
if (
hasTableFiles ||
hasImageAttachments ||
hasVideoAttachments ||
hasFolderAttachments
) {
return true;
}
@ -993,12 +1005,14 @@ function buildClawdImageReferenceText(attachments: PickedObjectAttachment[]) {
function getClawdDefaultPrompt(
hasTables: boolean,
hasImages: boolean,
hasVideos: boolean
hasVideos: boolean,
hasFolders: boolean
) {
const mediaNames = [
hasTables ? "表格" : "",
hasImages ? "图片" : "",
hasVideos ? "视频" : "",
hasFolders ? "文件夹" : "",
].filter(Boolean);
return mediaNames.length > 0
? `请分析上传的${mediaNames.join("、")}内容`
@ -1595,6 +1609,26 @@ type OpenClawActionsBlock = {
actions: OpenClawQuickAction[];
};
type OpenClawSecureField = {
name: "username" | "password" | "verification_code";
label: string;
inputType: "text" | "password";
inputMode?: "text" | "numeric";
autocomplete?: string;
maxLength: number;
};
type OpenClawSecureForm = {
formType: "taobao_credentials" | "taobao_verification";
title: string;
submitMessage: string;
fields: OpenClawSecureField[];
};
type OpenClawSensitiveInput =
| { type: "taobao_credentials"; username: string; password: string }
| { type: "taobao_verification"; verification_code: string };
type CanvasComposerUploadProgress = {
title: string;
message: string;
@ -1684,6 +1718,7 @@ function buildCanvasComposerUploadProgress(
const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX =
/```openclaw-actions\s*([\s\S]*?)```/gi;
const OPENCLAW_FORM_CODE_BLOCK_REGEX = /```openclaw-form\s*([\s\S]*?)```/gi;
function normalizeOpenClawActionText(value: unknown, maxLength: number) {
return String(value || "")
@ -1739,9 +1774,55 @@ function parseOpenClawActionsPayload(payload: unknown): OpenClawActionsBlock | n
};
}
function parseOpenClawSecureFormPayload(payload: unknown): OpenClawSecureForm | null {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const row = payload as Record<string, unknown>;
const formType = normalizeOpenClawActionText(row.form_type, 40);
if (
row.openclaw_form !== true ||
row.type !== "secure_form" ||
row.persist !== false ||
!["taobao_credentials", "taobao_verification"].includes(formType) ||
!Array.isArray(row.fields)
) {
return null;
}
const allowedNames =
formType === "taobao_credentials"
? new Set(["username", "password"])
: new Set(["verification_code"]);
const fields = row.fields
.map((item): OpenClawSecureField | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const field = item as Record<string, unknown>;
const name = normalizeOpenClawActionText(field.name, 40);
if (!allowedNames.has(name)) return null;
const inputType = field.input_type === "password" ? "password" : "text";
return {
name: name as OpenClawSecureField["name"],
label: normalizeOpenClawActionText(field.label, 40) || name,
inputType,
inputMode: field.input_mode === "numeric" ? "numeric" : "text",
autocomplete: normalizeOpenClawActionText(field.autocomplete, 40) || undefined,
maxLength: Math.max(4, Math.min(256, Number(field.max_length) || 160)),
};
})
.filter((item): item is OpenClawSecureField => Boolean(item));
if (fields.length !== allowedNames.size || new Set(fields.map((field) => field.name)).size !== fields.length) {
return null;
}
return {
formType: formType as OpenClawSecureForm["formType"],
title: normalizeOpenClawActionText(row.title, 60) || "安全登录",
submitMessage: normalizeOpenClawActionText(row.submit_message, 120),
fields,
};
}
function extractOpenClawActionsFromMarkdown(content: string) {
const blocks: OpenClawActionsBlock[] = [];
const markdown = String(content || "").replace(
const forms: OpenClawSecureForm[] = [];
const withoutActions = String(content || "").replace(
OPENCLAW_ACTIONS_CODE_BLOCK_REGEX,
(_match, rawJson) => {
try {
@ -1756,10 +1837,24 @@ function extractOpenClawActionsFromMarkdown(content: string) {
return "\n";
}
);
const markdown = withoutActions.replace(
OPENCLAW_FORM_CODE_BLOCK_REGEX,
(_match, rawJson) => {
try {
const parsed = JSON.parse(String(rawJson || "").trim());
const form = parseOpenClawSecureFormPayload(parsed);
if (form) forms.push(form);
} catch {
// Invalid secure-form JSON should not break normal Markdown rendering.
}
return "\n";
}
);
return {
markdown: markdown.replace(/\n{3,}/g, "\n\n").trim(),
blocks,
forms,
};
}
@ -2091,6 +2186,89 @@ function OpenClawQuickActions({
);
}
function OpenClawSecureForms({
forms,
disabled,
onSubmit,
}: {
forms: OpenClawSecureForm[];
disabled: boolean;
onSubmit: (form: OpenClawSecureForm, values: Record<string, string>) => Promise<void>;
}) {
const [valuesByForm, setValuesByForm] = useState<Record<number, Record<string, string>>>({});
const [submittingIndex, setSubmittingIndex] = useState<number | null>(null);
if (forms.length === 0) return null;
return (
<div className="mt-4 space-y-3">
{forms.map((form, formIndex) => {
const values = valuesByForm[formIndex] || {};
const complete = form.fields.every((field) => Boolean(values[field.name]));
const submitting = submittingIndex === formIndex;
return (
<form
key={`${form.formType}-${formIndex}`}
className="max-w-md rounded-md border border-[#d7dce6] bg-[#f8f9fb] p-3 dark:border-white/12 dark:bg-white/6"
autoComplete="off"
onSubmit={async (event) => {
event.preventDefault();
event.stopPropagation();
if (disabled || submitting || !complete) return;
setSubmittingIndex(formIndex);
try {
await onSubmit(form, { ...values });
} finally {
setValuesByForm((current) => ({ ...current, [formIndex]: {} }));
setSubmittingIndex(null);
}
}}
>
<div className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#29313d] dark:text-zinc-100">
<LockKeyhole className="h-4 w-4" />
<span>{form.title}</span>
</div>
<div className="space-y-2.5">
{form.fields.map((field) => (
<label key={field.name} className="block space-y-1">
<span className="text-[12px] font-medium text-[#596273] dark:text-zinc-300">{field.label}</span>
<input
type={field.inputType}
inputMode={field.inputMode}
autoComplete={field.autocomplete || "off"}
maxLength={field.maxLength}
required
disabled={disabled || submitting}
value={values[field.name] || ""}
className="h-9 w-full rounded-md border border-[#cfd5df] bg-white px-3 text-[14px] text-[#202631] outline-none focus:border-[#7d8798] focus:ring-2 focus:ring-[#7d8798]/15 disabled:opacity-55 dark:border-white/15 dark:bg-black/20 dark:text-zinc-100"
onChange={(event) => {
const value = event.currentTarget.value;
setValuesByForm((current) => ({
...current,
[formIndex]: { ...(current[formIndex] || {}), [field.name]: value },
}));
}}
/>
</label>
))}
</div>
<button
type="submit"
disabled={disabled || submitting || !complete}
className="mt-3 inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#202631] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#303846] disabled:cursor-not-allowed disabled:opacity-45 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <LockKeyhole className="h-4 w-4" />}
<span>{form.formType === "taobao_credentials" ? "安全登录" : "提交验证码"}</span>
</button>
<p className="mt-2 text-[11px] leading-5 text-[#858e9d] dark:text-zinc-400">
仅用于本次登录验证,不写入聊天记录。
</p>
</form>
);
})}
</div>
);
}
function CanvasAssistantTypingPlaceholder({
hint,
label = "分析中",
@ -2829,6 +3007,7 @@ export function CanvasChatPanel({
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
@ -4322,7 +4501,7 @@ export function CanvasChatPanel({
[]
);
// 在 contenteditable 中插入图片或视频附件标签
// 在 contenteditable 中插入图片、视频或文件夹压缩包附件标签
const insertImageTagInEditor = useCallback((
url: string,
name: string,
@ -4356,16 +4535,34 @@ export function CanvasChatPanel({
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
const isVideo = isOpenClawChatVideoAttachment(url, contentType);
const isArchive = isOpenClawChatArchiveAttachment(url, contentType);
const mediaPreview = isVideo
? document.createElement('video')
: document.createElement('img');
mediaPreview.src = isVideo ? url : buildPreviewImageUrl(url, 48);
mediaPreview.className =
'h-5 w-5 object-cover rounded-full border border-border/50 bg-black/5';
if (mediaPreview instanceof HTMLImageElement) {
mediaPreview.alt = '';
let mediaPreview: HTMLElement;
if (isArchive) {
mediaPreview = document.createElement('span');
mediaPreview.className =
'inline-flex h-5 min-w-7 items-center justify-center rounded border border-border/60 bg-background px-1 text-[9px] font-semibold text-muted-foreground';
mediaPreview.textContent = 'ZIP';
mediaPreview.setAttribute('aria-label', '文件夹压缩包');
} else if (isVideo) {
const videoPreview = document.createElement('video');
videoPreview.src = url;
videoPreview.className =
'h-5 w-5 object-cover rounded-full border border-border/50 bg-black/5';
videoPreview.muted = true;
videoPreview.preload = 'metadata';
videoPreview.playsInline = true;
videoPreview.setAttribute('aria-label', '视频附件');
mediaPreview = videoPreview;
} else {
const imagePreview = document.createElement('img');
imagePreview.src = buildPreviewImageUrl(url, 48);
imagePreview.className =
'h-5 w-5 object-cover rounded-full border border-border/50 bg-black/5';
imagePreview.alt = '';
mediaPreview = imagePreview;
}
if (mediaPreview instanceof HTMLVideoElement) {
mediaPreview.muted = true;
mediaPreview.preload = 'metadata';
mediaPreview.playsInline = true;
@ -4861,8 +5058,14 @@ export function CanvasChatPanel({
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
const objectName = tagSpan.getAttribute('data-object-name');
const attachmentName = tagSpan.getAttribute('data-attachment-name');
const tagContentType =
tagSpan.getAttribute('data-attachment-content-type') || undefined;
if (url) {
if (isOpenClawChatArchiveAttachment(url, tagContentType)) {
setTagPreview(null);
return;
}
const rect = tagSpan.getBoundingClientRect();
// 从 attachments 中获取物品信息
const attachment = attachments.find(a =>
@ -4888,7 +5091,7 @@ export function CanvasChatPanel({
name: objectName || attachmentName || tagSpan.textContent || '图片',
contentType:
attachment?.contentType ||
tagSpan.getAttribute('data-attachment-content-type') ||
tagContentType ||
undefined,
x: rect.left + rect.width / 2,
y: rect.top,
@ -11889,7 +12092,10 @@ export function CanvasChatPanel({
]);
// 处理发送(图片已在上传时添加到画布,这里只发送消息)
const handleSend = useCallback(async (overrideInput?: string) => {
const handleSend = useCallback(async (
overrideInput?: string,
sensitiveInput?: OpenClawSensitiveInput
) => {
const currentSendGuard = getSendGuard(activeChatId);
if (
currentSendGuard.inFlight ||
@ -11961,23 +12167,40 @@ export function CanvasChatPanel({
attachment.contentType
)
);
const folderAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter(
(attachment) =>
isOpenClawChatArchiveAttachment(
attachment.url,
attachment.contentType
)
);
const imageAttachmentsSnapshot = sanitizedAttachmentsSnapshot.filter(
(attachment) =>
!isOpenClawChatVideoAttachment(
attachment.url,
attachment.contentType
) &&
!isOpenClawChatArchiveAttachment(
attachment.url,
attachment.contentType
)
);
const hasImageAttachments = imageAttachmentsSnapshot.length > 0;
const hasVideoAttachments = videoAttachmentsSnapshot.length > 0;
const hasFolderAttachments = folderAttachmentsSnapshot.length > 0;
const openClawMediaAttachments = normalizeOpenClawChatMediaAttachments(
sanitizedAttachmentsSnapshot.map((attachment) => ({
type: isOpenClawChatVideoAttachment(
attachment.url,
attachment.contentType
)
attachment.url,
attachment.contentType
)
? "video"
: "image",
: isOpenClawChatArchiveAttachment(
attachment.url,
attachment.contentType
)
? "file"
: "image",
url: attachment.url,
name: normalizeChatAttachmentName(attachment.name),
content_type: attachment.contentType,
@ -11989,6 +12212,7 @@ export function CanvasChatPanel({
!hasTableFiles &&
!hasImageAttachments &&
!hasVideoAttachments &&
!hasFolderAttachments &&
!hasPendingClawdSkillTagPrompt
) {
return;
@ -12001,7 +12225,8 @@ export function CanvasChatPanel({
: getClawdDefaultPrompt(
hasTableFiles,
hasImageAttachments,
hasVideoAttachments
hasVideoAttachments,
hasFolderAttachments
));
const imageReferenceText = buildClawdImageReferenceText(
imageAttachmentsSnapshot
@ -12009,11 +12234,15 @@ export function CanvasChatPanel({
const videoReferenceText = buildOpenClawVideoReferenceText(
openClawMediaAttachments
);
const folderReferenceText = buildOpenClawArchiveReferenceText(
openClawMediaAttachments
);
const requestMessage = [
baseRequestMessage,
clawdSkillTagPrompt,
imageReferenceText,
videoReferenceText,
folderReferenceText,
]
.filter(Boolean)
.join("\n\n");
@ -12033,6 +12262,7 @@ export function CanvasChatPanel({
buildClawdTableDisplayText(optimisticBaseMessage, tableFilesSnapshot),
buildClawdImageDisplayText(imageAttachmentsSnapshot),
buildOpenClawVideoDisplayText(openClawMediaAttachments),
buildOpenClawArchiveDisplayText(openClawMediaAttachments),
]
.filter(Boolean)
.join("\n\n");
@ -12053,6 +12283,11 @@ export function CanvasChatPanel({
.map((attachment) => normalizeMediaUrl(attachment.url))
.filter(Boolean)
.sort(),
folderAttachments: folderAttachmentsSnapshot
.map((attachment) => normalizeMediaUrl(attachment.url))
.filter(Boolean)
.sort(),
sensitiveType: sensitiveInput?.type || null,
});
if (shouldBlockDuplicateSend(requestChatId, sendSignature)) {
@ -12064,7 +12299,8 @@ export function CanvasChatPanel({
[textContent, clawdSkillTagPrompt].filter(Boolean).join("\n\n"),
hasTableFiles,
hasImageAttachments,
hasVideoAttachments
hasVideoAttachments,
hasFolderAttachments
)
? CLAWD_PROCESSING_HINT
: null;
@ -12181,6 +12417,9 @@ export function CanvasChatPanel({
formData.append("userMessageId", userMessageId);
formData.append("message", requestMessage);
formData.append("displayMessage", optimisticMessageText);
if (sensitiveInput) {
formData.append("sensitiveInput", JSON.stringify(sensitiveInput));
}
formData.append(
"metadata",
JSON.stringify({
@ -12188,6 +12427,7 @@ export function CanvasChatPanel({
activeChatId: requestChatId,
imageAttachmentCount: imageAttachmentsSnapshot.length || undefined,
videoAttachmentCount: videoAttachmentsSnapshot.length || undefined,
folderAttachmentCount: folderAttachmentsSnapshot.length || undefined,
skillTagId: clawdSkillTagForRequest?.id || undefined,
skillTagName: clawdSkillTagForRequest?.name || undefined,
skillTagPromptIncluded: hasPendingClawdSkillTagPrompt || undefined,
@ -12832,6 +13072,28 @@ export function CanvasChatPanel({
[handleSend, isClawdMode]
);
const handleOpenClawSecureForm = useCallback(
async (form: OpenClawSecureForm, values: Record<string, string>) => {
if (!isClawdMode) {
toast.error(`请先切换到 ${CLAW_MODE_LABEL} 对话后再登录`);
return;
}
if (form.formType === "taobao_credentials") {
await handleSend(form.submitMessage, {
type: "taobao_credentials",
username: values.username || "",
password: values.password || "",
});
return;
}
await handleSend(form.submitMessage, {
type: "taobao_verification",
verification_code: values.verification_code || "",
});
},
[handleSend, isClawdMode]
);
const maybeAutoSetCanvasTitle = useCallback(async (firstImageUrl: string | null) => {
if (!firstImageUrl || hasSetCanvasTitle.current) {
return;
@ -13204,6 +13466,157 @@ export function CanvasChatPanel({
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
const uploadClawdFolderForComposerAttachment = useCallback(async (
files: File[]
): Promise<ComposerUploadedImage | null> => {
if (files.length === 0) {
toast.error("所选文件夹中没有文件");
return null;
}
if (files.length > OPENCLAW_CHAT_FOLDER_MAX_FILES) {
toast.error(`单次最多上传 ${OPENCLAW_CHAT_FOLDER_MAX_FILES} 个文件`);
return null;
}
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
if (totalBytes > OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES) {
toast.error("文件夹总大小不能超过 300MB");
return null;
}
const relativePaths = files.map(
(file) => file.webkitRelativePath || file.name
);
const rootFolders = Array.from(
new Set(
relativePaths
.map((path) => path.replace(/\\/g, "/").split("/")[0])
.filter(Boolean)
)
);
const archiveBaseName =
rootFolders.length === 1
? rootFolders[0]
: `${rootFolders.length}个文件夹-${Date.now()}`;
const progressOptions: CanvasUploadProgressOptions = {
title: "正在打包并上传文件夹",
fileName: `${archiveBaseName}.zip`,
target: "composer-reference",
};
const formData = new FormData();
files.forEach((file, index) => {
formData.append("files", file, file.name);
formData.append("relativePaths", relativePaths[index]);
});
formData.append("archiveName", archiveBaseName);
updateComposerUploadProgress(progressOptions, {
loaded: 0,
total: totalBytes,
percent: 1,
phase: "preparing",
});
try {
const data = await new Promise<Record<string, unknown>>(
(resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/canvas/clawd-chat/folder-upload");
xhr.timeout = 10 * 60 * 1000;
xhr.upload.onprogress = (event) => {
const total = event.lengthComputable ? event.total : totalBytes;
const rawPercent = total > 0 ? (event.loaded / total) * 100 : 5;
updateComposerUploadProgress(progressOptions, {
loaded: event.loaded,
total,
percent: Math.max(1, Math.min(92, Math.round(rawPercent))),
phase: "server",
});
};
xhr.upload.onload = () => {
updateComposerUploadProgress(progressOptions, {
loaded: totalBytes,
total: totalBytes,
percent: 94,
phase: "finalizing",
});
};
xhr.onerror = () => reject(new Error("文件夹上传网络异常"));
xhr.ontimeout = () => reject(new Error("文件夹打包上传超时"));
xhr.onload = () => {
let payload: Record<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, {
loaded: totalBytes,
total: totalBytes,
percent: 100,
phase: "complete",
});
clearComposerUploadProgress(350);
return {
imageUrl: url,
name: String(data.name || `${archiveBaseName}.zip`),
contentType: "application/zip",
};
} catch (error) {
clearComposerUploadProgress();
toast.error(error instanceof Error ? error.message : "文件夹上传失败");
return null;
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
const handleClawdFolderSelect = useCallback(async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length === 0) return;
const existingArchiveCount = attachments.filter((attachment) =>
isOpenClawChatArchiveAttachment(
attachment.url,
attachment.contentType
)
).length;
if (existingArchiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) {
toast.info(
`一次对话最多添加 ${OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES} 个文件夹压缩包`
);
return;
}
const uploaded = await uploadClawdFolderForComposerAttachment(files);
if (!uploaded) return;
addUploadedImageAttachmentToComposer(uploaded);
toast.success("文件夹已打包上传到七牛云,可继续添加其他文件夹");
}, [
addUploadedImageAttachmentToComposer,
attachments,
uploadClawdFolderForComposerAttachment,
]);
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
@ -13981,7 +14394,7 @@ export function CanvasChatPanel({
? activeClawdSkillTag
? activeClawdSkillTag.placeholder?.trim() ||
`继续向 ${CLAW_MODE_LABEL} 提问,当前使用「${activeClawdSkillTag.name}」`
: `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片或表格`
: `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频、表格或文件夹`
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
@ -13995,7 +14408,7 @@ export function CanvasChatPanel({
? activeClawdSkillTag
? activeClawdSkillTag.placeholder?.trim() ||
`继续向 ${CLAW_MODE_LABEL} 提问,当前使用「${activeClawdSkillTag.name}」`
: `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片或表格`
: `输入要交给 ${CLAW_MODE_LABEL} 的问题,可上传图片、视频、表格或文件夹`
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
@ -15536,22 +15949,31 @@ export function CanvasChatPanel({
: "";
if (part.type === 'file' && fileUrl) {
const attachmentContentType = String(
part.mediaType || part.contentType || ""
).toLowerCase();
const isVideoAttachment = isLikelyVideoAttachment(
fileUrl,
String(part.mediaType || part.contentType || "").toLowerCase()
attachmentContentType
);
renderedUserParts.push(
<span
key={`part-${idx}`}
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
const isArchiveAttachment =
isOpenClawChatArchiveAttachment(
fileUrl,
attachmentContentType
);
const attachmentLabel =
part.name ||
(isVideoAttachment
? 'Video'
: isArchiveAttachment
? 'Folder.zip'
: 'Image');
const attachmentContent = (
<>
{isVideoAttachment ? (
<Video className="h-5 w-5 shrink-0" />
) : isArchiveAttachment ? (
<FileArchive className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
) : (
<img
src={buildPreviewImageUrl(fileUrl, 48)}
@ -15566,10 +15988,41 @@ export function CanvasChatPanel({
}}
/>
)}
<span className="max-w-[80px] truncate text-current">
{part.name || (isVideoAttachment ? 'Video' : 'Image')}
<span className="max-w-[120px] truncate text-current">
{attachmentLabel}
</span>
</>
);
renderedUserParts.push(
isArchiveAttachment ? (
<a
key={`part-${idx}`}
href={fileUrl}
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs hover:underline",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
title="打开文件夹压缩包"
>
{attachmentContent}
</a>
) : (
<span
key={`part-${idx}`}
className={cn(
"inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
{attachmentContent}
</span>
)
);
return;
}
@ -15595,7 +16048,26 @@ export function CanvasChatPanel({
) {
(message as any).attachments.forEach((att: any, idx: number) => {
if (!att?.url) return;
const isArchiveAttachment =
isOpenClawChatArchiveAttachment(
att.url,
att.contentType
);
renderedUserParts.push(
isArchiveAttachment ? (
<a
key={`att-${idx}`}
href={att.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] hover:underline dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
>
<FileArchive className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-300" />
<span className="max-w-[120px] truncate text-current">
{att.name || 'Folder.zip'}
</span>
</a>
) : (
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
@ -15614,6 +16086,7 @@ export function CanvasChatPanel({
/>
<span className="max-w-[80px] truncate text-current">{att.name || 'Image'}</span>
</span>
)
);
});
}
@ -15675,13 +16148,14 @@ export function CanvasChatPanel({
);
const openClawActionsResult = isClawAssistantMessage
? extractOpenClawActionsFromMarkdown(textWithoutImages)
: { markdown: textWithoutImages, blocks: [] };
: { markdown: textWithoutImages, blocks: [], forms: [] };
const textWithoutActions = isClawAssistantMessage
? stripOpenClawLowValueDiagnosticTables(
openClawActionsResult.markdown
)
: openClawActionsResult.markdown;
const openClawActionBlocks = openClawActionsResult.blocks;
const openClawSecureForms = openClawActionsResult.forms;
const analysisSection = extractCanvasImageAnalysisSection(textWithoutActions);
const plainText = normalizeCanvasAgentText(textWithoutActions);
const analysisToggleKey = `${message.id}-${i}-analysis`;
@ -15991,6 +16465,18 @@ export function CanvasChatPanel({
onAction={handleOpenClawQuickAction}
/>
) : null}
{openClawSecureForms.length > 0 ? (
<OpenClawSecureForms
forms={openClawSecureForms}
disabled={
shouldAnimateAnalysis ||
shouldAnimatePlainText ||
isClawdSubmitting ||
isActiveChatPending
}
onSubmit={handleOpenClawSecureForm}
/>
) : null}
</div>
);
}
@ -17726,6 +18212,21 @@ export function CanvasChatPanel({
className="hidden"
onChange={handleFileSelect}
/>
<input
ref={(element) => {
clawdFolderInputRef.current = element;
if (element) {
element.setAttribute("webkitdirectory", "");
element.setAttribute("directory", "");
}
}}
type="file"
multiple
className="hidden"
onChange={(event) => {
void handleClawdFolderSelect(event);
}}
/>
{videoFrameSlots.map((_, index) => (
<input
key={`canvas-video-slot-input-${index}`}
@ -17755,29 +18256,67 @@ export function CanvasChatPanel({
/>
))}
{!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && (isClawdMode || !isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 shrink-0 rounded-full border border-transparent text-[#20242a] transition-colors hover:bg-[#f3f4f6] hover:text-[#111418] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33] hover:text-white"
: "dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={isClawdMode ? "上传附件" : getToolbarItem("chatAttach").label}
>
{isClawdMode ? (
<Paperclip className="h-4.5 w-4.5" />
) : (
isClawdMode ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 shrink-0 rounded-full border border-transparent text-[#20242a] transition-colors hover:bg-[#f3f4f6] hover:text-[#111418] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33] hover:text-white"
: "dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
disabled={isSubmitting}
title="上传附件"
>
<Paperclip className="h-4.5 w-4.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => fileInputRef.current?.click(), 0);
}}
>
<Paperclip className="mr-2 h-4 w-4" />
上传文件
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(
() => clawdFolderInputRef.current?.click(),
0
);
}}
>
<FolderUp className="mr-2 h-4 w-4" />
上传文件夹(可重复添加)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 shrink-0 rounded-full border border-transparent text-[#20242a] transition-colors hover:bg-[#f3f4f6] hover:text-[#111418] dark:hover:border-zinc-500 dark:hover:bg-zinc-700/70",
isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33] hover:text-white"
: "dark:bg-white/8 dark:text-zinc-200 dark:hover:bg-white/12 dark:hover:text-white"
)}
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={getToolbarItem("chatAttach").label}
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={getToolbarItem("chatAttach")}
/>
)}
</Button>
</Button>
)
)}
{!isWorkspacePresentation ? (
<DropdownMenu

View File

@ -21,6 +21,8 @@ const ALLOWED_CHAT_MEDIA_TYPES = new Set([
"video/quicktime",
"video/webm",
"video/x-matroska",
"application/zip",
"application/x-zip-compressed",
]);
const GENERIC_CANVAS_PROMPT_SET = new Set([

View File

@ -0,0 +1,49 @@
export function sanitizeOpenClawArchiveName(value: string) {
const normalized = String(value || "")
.normalize("NFKC")
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "-")
.replace(/\s+/g, " ")
.replace(/^\.+|\.+$/g, "")
.trim()
.slice(0, 120);
return normalized || `Claw文件夹-${Date.now()}`;
}
function sanitizeArchivePathSegment(value: string, fallback: string) {
const normalized = String(value || "")
.normalize("NFKC")
.replace(/[\\:*?"<>|\u0000-\u001f]/g, "-")
.replace(/\s+/g, " ")
.replace(/^\.+|\.+$/g, "")
.trim()
.slice(0, 160);
return normalized || fallback;
}
export function sanitizeOpenClawArchiveEntryPath(
relativePath: string,
fallbackFileName: string,
index: number
) {
const fallback = sanitizeArchivePathSegment(
fallbackFileName,
`文件-${index + 1}`
);
const segments = String(relativePath || fallback)
.replace(/\\/g, "/")
.split("/")
.filter((segment) => segment && segment !== "." && segment !== "..")
.slice(0, 30)
.map((segment, segmentIndex) =>
sanitizeArchivePathSegment(segment, `目录-${segmentIndex + 1}`)
);
return (segments.length > 0 ? segments : [fallback]).join("/");
}
export function countOpenClawArchiveRootFolders(paths: string[]) {
return new Set(
paths
.map((path) => String(path || "").replace(/\\/g, "/").split("/")[0])
.filter(Boolean)
).size;
}

View File

@ -1,6 +1,10 @@
export const OPENCLAW_CHAT_VIDEO_UPLOAD_PURPOSE = "openclaw-chat-video";
export const OPENCLAW_CHAT_FOLDER_UPLOAD_PURPOSE = "openclaw-chat-folder";
export const OPENCLAW_CHAT_VIDEO_MAX_FILES = 2;
export const OPENCLAW_CHAT_VIDEO_MAX_BYTES = 500 * 1024 * 1024;
export const OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES = 5;
export const OPENCLAW_CHAT_FOLDER_MAX_FILES = 2000;
export const OPENCLAW_CHAT_FOLDER_MAX_TOTAL_BYTES = 300 * 1024 * 1024;
export const OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS = 16;
export const OPENCLAW_CHAT_VIDEO_ACCEPT =
"video/mp4,video/quicktime,video/webm,video/x-m4v,video/x-matroska,.mp4,.mov,.webm,.m4v,.mkv";
@ -22,9 +26,10 @@ const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
};
const VIDEO_EXTENSION_PATTERN = /\.(mp4|mov|webm|m4v|mkv)(?:$|[?#])/i;
const ARCHIVE_EXTENSION_PATTERN = /\.zip(?:$|[?#])/i;
export type OpenClawChatMediaAttachment = {
type: "image" | "video";
type: "image" | "video" | "file";
url: string;
name?: string;
content_type?: string;
@ -88,6 +93,18 @@ export function isOpenClawChatVideoAttachment(
);
}
export function isOpenClawChatArchiveAttachment(
url: string,
contentType?: string | null
) {
const normalizedContentType = normalizeContentType(contentType);
return (
normalizedContentType === "application/zip" ||
normalizedContentType === "application/x-zip-compressed" ||
ARCHIVE_EXTENSION_PATTERN.test(String(url || ""))
);
}
export function normalizeOpenClawChatMediaAttachments(
value: unknown
): OpenClawChatMediaAttachment[] {
@ -98,6 +115,7 @@ export function normalizeOpenClawChatMediaAttachments(
const result: OpenClawChatMediaAttachment[] = [];
const seenUrls = new Set<string>();
let videoCount = 0;
let archiveCount = 0;
for (const item of value) {
if (!item || typeof item !== "object") {
@ -117,20 +135,36 @@ export function normalizeOpenClawChatMediaAttachments(
const isVideo =
requestedType === "video" ||
isOpenClawChatVideoAttachment(url, rawContentType);
const isArchive =
!isVideo &&
(requestedType === "file" ||
requestedType === "archive" ||
isOpenClawChatArchiveAttachment(url, rawContentType));
if (isVideo && videoCount >= OPENCLAW_CHAT_VIDEO_MAX_FILES) {
continue;
}
if (isArchive && archiveCount >= OPENCLAW_CHAT_FOLDER_MAX_ARCHIVES) {
continue;
}
if (result.length >= OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS) {
break;
}
const type = isVideo ? "video" : "image";
const type = isVideo ? "video" : isArchive ? "file" : "image";
const fallbackContentType = isVideo
? VIDEO_CONTENT_TYPE_BY_EXTENSION[getExtension(url)] || "video/mp4"
: "image/jpeg";
const contentType =
rawContentType.startsWith(`${type}/`) ? rawContentType : fallbackContentType;
: isArchive
? "application/zip"
: "image/jpeg";
const contentType = isArchive
? rawContentType === "application/zip" ||
rawContentType === "application/x-zip-compressed"
? rawContentType
: fallbackContentType
: rawContentType.startsWith(`${type}/`)
? rawContentType
: fallbackContentType;
const name = String(raw.name || "")
.trim()
.slice(0, 200);
@ -145,6 +179,9 @@ export function normalizeOpenClawChatMediaAttachments(
if (isVideo) {
videoCount += 1;
}
if (isArchive) {
archiveCount += 1;
}
}
return result;
@ -181,3 +218,40 @@ export function buildOpenClawVideoReferenceText(
lines.join("\n"),
].join("\n");
}
export function buildOpenClawArchiveDisplayText(
attachments: OpenClawChatMediaAttachment[]
) {
const archives = attachments.filter(
(attachment) => attachment.type === "file"
);
if (archives.length === 0) {
return "";
}
const lines = archives.map(
(attachment, index) =>
`- ${attachment.name || `文件夹压缩包${index + 1}.zip`}`
);
return `已上传文件夹:\n${lines.join("\n")}`;
}
export function buildOpenClawArchiveReferenceText(
attachments: OpenClawChatMediaAttachment[]
) {
const archives = attachments.filter(
(attachment) => attachment.type === "file"
);
if (archives.length === 0) {
return "";
}
const lines = archives.map(
(attachment, index) =>
`- ${attachment.name || `文件夹压缩包${index + 1}.zip`}: ${attachment.url}`
);
return [
"用户上传了以下文件夹压缩包,压缩包保留原目录结构,请下载并解压后处理:",
lines.join("\n"),
].join("\n");
}

View File

@ -51,6 +51,7 @@ export default defineConfig({
/routes\/openclaw-config\.test\.ts/,
/routes\/openclaw-durable-task-state\.test\.ts/,
/routes\/openclaw-request-state\.test\.ts/,
/routes\/openclaw-folder-attachments\.test\.ts/,
/routes\/openclaw-video-attachments\.test\.ts/,
/routes\/openapi-access-key\.test\.ts/,
/routes\/vip98k-reference-routing\.test\.ts/,

View File

@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
import {
countOpenClawArchiveRootFolders,
sanitizeOpenClawArchiveEntryPath,
sanitizeOpenClawArchiveName,
} from "@/lib/openclaw-chat/folder-archive";
import {
buildOpenClawArchiveDisplayText,
buildOpenClawArchiveReferenceText,
isOpenClawChatArchiveAttachment,
normalizeOpenClawChatMediaAttachments,
} from "@/lib/openclaw-chat/media-attachments";
test.describe("OpenClaw folder attachments", () => {
test("keeps safe folder structure while removing traversal segments", () => {
expect(
sanitizeOpenClawArchiveEntryPath(
"../../设计资料/主图:终稿.png",
"主图.png",
0
)
).toBe("设计资料/主图-终稿.png");
expect(sanitizeOpenClawArchiveName(" 商品/资料:* ")).toBe("商品-资料--");
});
test("counts multiple selected root folders", () => {
expect(
countOpenClawArchiveRootFolders([
"产品A/图片/1.png",
"产品A/文档/readme.md",
"产品B/图片/2.png",
])
).toBe(2);
});
test("normalizes ZIP URLs as file attachments", () => {
const attachments = normalizeOpenClawChatMediaAttachments([
{
type: "file",
url: "https://cdn.example.com/产品资料.zip",
name: "产品资料.zip",
contentType: "application/zip",
},
]);
expect(attachments).toEqual([
{
type: "file",
url: "https://cdn.example.com/产品资料.zip",
name: "产品资料.zip",
content_type: "application/zip",
},
]);
expect(
isOpenClawChatArchiveAttachment(
attachments[0].url,
attachments[0].content_type
)
).toBe(true);
});
test("adds the public archive URL to the OpenClaw fallback message", () => {
const attachments = normalizeOpenClawChatMediaAttachments([
{
type: "file",
url: "https://cdn.example.com/catalog.zip",
name: "catalog.zip",
},
]);
expect(buildOpenClawArchiveDisplayText(attachments)).toContain("catalog.zip");
expect(buildOpenClawArchiveReferenceText(attachments)).toContain(
"https://cdn.example.com/catalog.zip"
);
expect(buildOpenClawArchiveReferenceText(attachments)).toContain("解压");
});
});