fix: unify Clawd attachment picker

This commit is contained in:
134 2026-06-24 00:30:55 +08:00
parent 16f734e9c5
commit cd8f23e4b3
2 changed files with 181 additions and 101 deletions

View File

@ -22,6 +22,7 @@ import { generateUUID } from "@/lib/utils";
const requestSchema = z.object({
id: z.string().uuid(),
message: z.string().trim().max(8000).optional().default(""),
displayMessage: z.string().trim().max(8000).optional(),
userMessageId: z.string().uuid().optional(),
metadata: z.record(z.unknown()).optional(),
});
@ -80,6 +81,7 @@ async function parseClawdChatRequest(
const parsed = requestSchema.safeParse({
id: formData.get("id"),
message: formData.get("message") || "",
displayMessage: formData.get("displayMessage") || undefined,
userMessageId: formData.get("userMessageId") || undefined,
metadata,
});
@ -176,7 +178,7 @@ export async function POST(request: Request) {
}
const displayMessage = buildOpenClawDisplayMessage({
message: parsed.message,
message: parsed.displayMessage || parsed.message,
files: spreadsheetMarkdownResult?.files || [],
});
const upstreamMessage = buildOpenClawMessageWithSpreadsheetMarkdown({

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 } 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, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
@ -361,6 +361,43 @@ function buildClawdTableDisplayText(message: string, files: File[]) {
return `${prompt}\n\n已上传表格:\n${lines.join("\n")}`;
}
function buildClawdImageDisplayText(attachments: PickedObjectAttachment[]) {
if (attachments.length === 0) {
return "";
}
const lines = attachments.map(
(attachment, index) =>
`- ${normalizeChatAttachmentName(attachment.name) || `图片${index + 1}`}`
);
return `已添加图片:\n${lines.join("\n")}`;
}
function buildClawdImageReferenceText(attachments: PickedObjectAttachment[]) {
if (attachments.length === 0) {
return "";
}
const lines = attachments.map((attachment, index) => {
const name = normalizeChatAttachmentName(attachment.name) || `图片${index + 1}`;
return `- ${name}: ${attachment.url}`;
});
return `用户还添加了以下图片作为参考,请按图片链接理解:\n${lines.join("\n")}`;
}
function getClawdDefaultPrompt(hasTables: boolean, hasImages: boolean) {
if (hasTables && hasImages) {
return "请分析上传的表格和图片内容";
}
if (hasTables) {
return "请分析上传的表格内容";
}
if (hasImages) {
return "请分析上传的图片内容";
}
return "请分析";
}
type VideoStoryboardStreamData =
| {
stage: "detecting";
@ -952,6 +989,18 @@ function CanvasTypewriterText({
function CanvasAssistantTypingPlaceholder() {
return (
<div className="flex w-full justify-start">
<style>{`
@keyframes clawdTypingDotBounce {
0%, 80%, 100% {
transform: translateY(0) scale(0.92);
opacity: 0.5;
}
40% {
transform: translateY(-5px) scale(1);
opacity: 1;
}
}
`}</style>
<div
aria-label="Clawd 正在处理"
className="inline-flex h-7 items-center gap-1 rounded-[14px] bg-[#f4f5f7] px-3 text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
@ -959,10 +1008,10 @@ function CanvasAssistantTypingPlaceholder() {
{[0, 1, 2].map((index) => (
<span
key={index}
className="h-1.5 w-1.5 animate-bounce rounded-full bg-current opacity-75"
className="h-1.5 w-1.5 rounded-full bg-current"
style={{
animationDelay: `${index * 120}ms`,
animationDuration: "0.9s",
animation: "clawdTypingDotBounce 0.9s ease-in-out infinite",
animationDelay: `${index * 140}ms`,
}}
/>
))}
@ -1384,7 +1433,6 @@ export function CanvasChatPanel({
const [messageViewportKey, setMessageViewportKey] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const clawdTableInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
@ -8794,20 +8842,30 @@ export function CanvasChatPanel({
.trim();
const tableFilesSnapshot = [...clawdTableFiles];
const hasTableFiles = tableFilesSnapshot.length > 0;
const attachmentsSnapshot = [...attachments];
const imageAttachmentsSnapshot = attachmentsSnapshot
.map((attachment) => sanitizeChatAttachment(attachment))
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
const hasImageAttachments = imageAttachmentsSnapshot.length > 0;
if (!textContent && !hasTableFiles) {
return;
}
if (attachments.length > 0) {
toast.error("Clawd 当前仅支持文本和表格文件");
if (!textContent && !hasTableFiles && !hasImageAttachments) {
return;
}
const requestMessage = textContent || "请分析上传的表格内容";
const optimisticMessageText = buildClawdTableDisplayText(
requestMessage,
tableFilesSnapshot
const baseRequestMessage =
textContent || getClawdDefaultPrompt(hasTableFiles, hasImageAttachments);
const imageReferenceText = buildClawdImageReferenceText(
imageAttachmentsSnapshot
);
const requestMessage = [baseRequestMessage, imageReferenceText]
.filter(Boolean)
.join("\n\n");
const optimisticMessageText = [
buildClawdTableDisplayText(baseRequestMessage, tableFilesSnapshot),
buildClawdImageDisplayText(imageAttachmentsSnapshot),
]
.filter(Boolean)
.join("\n\n");
const sendSignature = JSON.stringify({
chatId: activeChatId,
mode: "clawd",
@ -8815,6 +8873,10 @@ export function CanvasChatPanel({
tableFiles: tableFilesSnapshot
.map((file) => `${file.name}:${file.size}:${file.lastModified}`)
.sort(),
imageAttachments: imageAttachmentsSnapshot
.map((attachment) => normalizeMediaUrl(attachment.url))
.filter(Boolean)
.sort(),
});
if (shouldBlockDuplicateSend(activeChatId, sendSignature)) {
@ -8865,11 +8927,13 @@ export function CanvasChatPanel({
formData.append("id", rawChatId);
formData.append("userMessageId", userMessageId);
formData.append("message", requestMessage);
formData.append("displayMessage", optimisticMessageText);
formData.append(
"metadata",
JSON.stringify({
composerMode: "clawd",
activeChatId,
imageAttachmentCount: imageAttachmentsSnapshot.length || undefined,
})
);
tableFilesSnapshot.forEach((file) => {
@ -8911,6 +8975,7 @@ export function CanvasChatPanel({
);
toast.error(error instanceof Error ? error.message : "Clawd 请求失败");
setClawdTableFiles(tableFilesSnapshot);
setAttachments(attachmentsSnapshot);
const currentEditorText = (editableDiv?.textContent || "")
.replace(/\u200b/g, " ")
.replace(/\s+/g, " ")
@ -8919,6 +8984,16 @@ export function CanvasChatPanel({
setInput(textContent);
if (editableDiv) {
editableDiv.textContent = textContent;
window.setTimeout(() => {
attachmentsSnapshot.forEach((attachment, index) => {
if (!attachment.url) return;
insertImageTagInEditor(
attachment.url,
normalizeChatAttachmentName(attachment.name),
index
);
});
}, 0);
}
}
} finally {
@ -9227,6 +9302,7 @@ export function CanvasChatPanel({
selectedAgentPreset,
selectedModelId,
sendMessage,
insertImageTagInEditor,
clearSelection,
clearLatestGeneratedImageHighlights,
buildAutoContext,
@ -9526,6 +9602,30 @@ export function CanvasChatPanel({
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
let selectedFiles = Array.from(files);
if (isClawdMode) {
const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file));
const imageFiles = selectedFiles.filter((file) =>
file.type.startsWith("image/")
);
const unsupportedFiles = selectedFiles.filter(
(file) => !isClawdTableFile(file) && !file.type.startsWith("image/")
);
unsupportedFiles.forEach((file) => {
toast.error(`${file.name} 格式不支持,请上传图片或 .xlsx/.csv/.tsv`);
});
if (tableFiles.length > 0) {
addClawdTableFiles(tableFiles);
}
selectedFiles = imageFiles;
if (selectedFiles.length === 0) {
e.target.value = "";
return;
}
}
let firstImageUrl: string | null = null;
// 记录本次上传开始时的节点数量,用于计算新图片位置
@ -9537,7 +9637,7 @@ export function CanvasChatPanel({
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of Array.from(files)) {
for (const file of selectedFiles) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
@ -9575,7 +9675,7 @@ export function CanvasChatPanel({
: `已添加 ${handledCount} 张图片到风格化槽位`
);
}
if (handledCount < files.length) {
if (handledCount < selectedFiles.length) {
toast.info("风格化需要的图片槽位已满,剩余图片未添加");
}
@ -9584,7 +9684,7 @@ export function CanvasChatPanel({
return;
}
for (const file of Array.from(files)) {
for (const file of selectedFiles) {
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
continue;
@ -9678,62 +9778,57 @@ export function CanvasChatPanel({
e.target.value = '';
};
const handleClawdTableFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.currentTarget.files || []);
event.currentTarget.value = "";
if (selectedFiles.length === 0) return;
function addClawdTableFiles(selectedFiles: File[]) {
if (selectedFiles.length === 0) return;
const remainingSlots = CLAWD_TABLE_MAX_FILES - clawdTableFiles.length;
if (remainingSlots <= 0) {
toast.error(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格`);
return;
const remainingSlots = CLAWD_TABLE_MAX_FILES - clawdTableFiles.length;
if (remainingSlots <= 0) {
toast.error(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格`);
return;
}
const acceptedFiles: File[] = [];
for (const file of selectedFiles) {
if (!isClawdTableFile(file)) {
toast.error(`${file.name} 格式不支持,请上传 .xlsx、.csv 或 .tsv`);
continue;
}
if (file.size <= 0) {
toast.error(`${file.name} 是空文件`);
continue;
}
if (file.size > CLAWD_TABLE_MAX_BYTES) {
toast.error(`${file.name} 超过 10MB,请先拆分或压缩`);
continue;
}
acceptedFiles.push(file);
}
const acceptedFiles: File[] = [];
for (const file of selectedFiles) {
if (!isClawdTableFile(file)) {
toast.error(`${file.name} 格式不支持,请上传 .xlsx、.csv 或 .tsv`);
if (acceptedFiles.length === 0) return;
setClawdTableFiles((currentFiles) => {
const existingSignatures = new Set(
currentFiles.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`
)
);
const nextFiles = [...currentFiles];
for (const file of acceptedFiles) {
if (nextFiles.length >= CLAWD_TABLE_MAX_FILES) break;
const signature = `${file.name}:${file.size}:${file.lastModified}`;
if (existingSignatures.has(signature)) {
continue;
}
if (file.size <= 0) {
toast.error(`${file.name} 是空文件`);
continue;
}
if (file.size > CLAWD_TABLE_MAX_BYTES) {
toast.error(`${file.name} 超过 10MB,请先拆分或压缩`);
continue;
}
acceptedFiles.push(file);
existingSignatures.add(signature);
nextFiles.push(file);
}
return nextFiles;
});
if (acceptedFiles.length === 0) return;
setClawdTableFiles((currentFiles) => {
const existingSignatures = new Set(
currentFiles.map(
(file) => `${file.name}:${file.size}:${file.lastModified}`
)
);
const nextFiles = [...currentFiles];
for (const file of acceptedFiles) {
if (nextFiles.length >= CLAWD_TABLE_MAX_FILES) break;
const signature = `${file.name}:${file.size}:${file.lastModified}`;
if (existingSignatures.has(signature)) {
continue;
}
existingSignatures.add(signature);
nextFiles.push(file);
}
return nextFiles;
});
if (acceptedFiles.length > remainingSlots) {
toast.info(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格,已自动保留前 ${remainingSlots} 个`);
}
},
[clawdTableFiles.length]
);
if (acceptedFiles.length > remainingSlots) {
toast.info(`一次最多上传 ${CLAWD_TABLE_MAX_FILES} 个表格,已自动保留前 ${remainingSlots} 个`);
}
}
const removeClawdTableFile = useCallback((index: number) => {
setClawdTableFiles((currentFiles) =>
@ -9815,7 +9910,7 @@ export function CanvasChatPanel({
const hasEnoughAgentUploads = !hasAgentUploadSlots || attachments.length >= agentRequiredUploadCount;
const composerPlaceholder = isWorkspacePresentation
? isClawdMode
? "输入要交给 Clawd 的问题,可上传表格"
? "输入要交给 Clawd 的问题,可上传图片或表格"
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
@ -9826,7 +9921,7 @@ export function CanvasChatPanel({
? "可选补充本次生图要求,例如:高级感、暖灰色、近景特写、生成3张。"
: "可选补充本次生成要求,例如:更高级、更生活化、暖调灯光。"
: isClawdMode
? "输入要交给 Clawd 的问题,可上传表格"
? "输入要交给 Clawd 的问题,可上传图片或表格"
: isDetailGenerationMode
? "可选补充:风格、情绪氛围、禁用元素、期望生成张数(如:生成8张)"
: isVideoWorkspace
@ -9851,7 +9946,7 @@ export function CanvasChatPanel({
? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100))
: 0;
const canSend = isClawdMode
? Boolean(input.trim() || clawdTableFiles.length > 0) && !isClawdSubmitting
? Boolean(input.trim() || attachments.length > 0 || clawdTableFiles.length > 0) && !isClawdSubmitting
: isDetailGenerationMode
? detailValidationIssues.length === 0 && !isDetailGenerating
: hasInputOrAttachments &&
@ -12521,6 +12616,8 @@ export function CanvasChatPanel({
? "min-h-[38px] text-[19px] leading-7 sm:text-[22px] sm:leading-8"
: activeComposerMode === "image"
? "min-h-[44px] text-[16px] leading-7 sm:text-[16px] sm:leading-7"
: isClawdMode
? "min-h-[64px] text-[12px] leading-5 sm:text-[12px] sm:leading-5"
: "min-h-[64px] text-[16px] leading-7 sm:text-[17px] sm:leading-7",
isVideoWorkspace
? "text-foreground empty:before:text-muted-foreground/70"
@ -12736,19 +12833,11 @@ export function CanvasChatPanel({
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept={isClawdMode ? `image/*,${CLAWD_TABLE_ACCEPT}` : "image/*"}
multiple
className="hidden"
onChange={handleFileSelect}
/>
<input
ref={clawdTableInputRef}
type="file"
accept={CLAWD_TABLE_ACCEPT}
multiple
className="hidden"
onChange={handleClawdTableFileSelect}
/>
{videoFrameSlots.map((_, index) => (
<input
key={`canvas-video-slot-input-${index}`}
@ -12777,7 +12866,7 @@ export function CanvasChatPanel({
}}
/>
))}
{!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && activeComposerMode !== "clawd" && (!isVideoModel || showVideoAttachmentButton) && (
{!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && (isClawdMode || !isVideoModel || showVideoAttachmentButton) && (
<Button
variant="ghost"
size="icon"
@ -12789,30 +12878,19 @@ export function CanvasChatPanel({
)}
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={getToolbarItem("chatAttach").label}
title={isClawdMode ? "上传附件" : getToolbarItem("chatAttach").label}
>
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={getToolbarItem("chatAttach")}
/>
{isClawdMode ? (
<Paperclip className="h-4.5 w-4.5" />
) : (
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={getToolbarItem("chatAttach")}
/>
)}
</Button>
)}
{!isWorkspacePresentation && !isDetailGenerationMode && isClawdMode ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full border border-transparent bg-[#eeeeef] text-[#20242a] transition-colors hover:bg-[#e7e7e8] hover:text-[#111418] disabled:opacity-50 dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14 dark:hover:text-white"
onClick={() => clawdTableInputRef.current?.click()}
disabled={
isSubmitting ||
clawdTableFiles.length >= CLAWD_TABLE_MAX_FILES
}
title="上传表格"
>
<FileSpreadsheet className="h-4.5 w-4.5" />
</Button>
) : null}
{!isWorkspacePresentation ? (
<DropdownMenu
open={isComposerModeMenuOpen}