feat(claw): add secure forms and folder uploads
This commit is contained in:
parent
f3f1fdafbd
commit
8a075045f0
141
app/api/canvas/clawd-chat/folder-upload/route.ts
Normal file
141
app/api/canvas/clawd-chat/folder-upload/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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([
|
||||
|
||||
49
lib/openclaw-chat/folder-archive.ts
Normal file
49
lib/openclaw-chat/folder-archive.ts
Normal 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;
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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/,
|
||||
|
||||
77
tests/routes/openclaw-folder-attachments.test.ts
Normal file
77
tests/routes/openclaw-folder-attachments.test.ts
Normal 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("解压");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user