From cd8f23e4b342cd7a7461b6c18db42c16b9ab2663 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 24 Jun 2026 00:30:55 +0800 Subject: [PATCH] fix: unify Clawd attachment picker --- app/api/canvas/clawd-chat/route.ts | 4 +- components/canvas/CanvasChatPanel.tsx | 278 +++++++++++++++++--------- 2 files changed, 181 insertions(+), 101 deletions(-) diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index 3bff5448c..7d606b067 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -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({ diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 76c2a5597..fd29dba36 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -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 (
+
( ))} @@ -1384,7 +1433,6 @@ export function CanvasChatPanel({ const [messageViewportKey, setMessageViewportKey] = useState(0); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); - const clawdTableInputRef = useRef(null); const videoSlotInputRefs = useRef>([]); const agentSlotInputRefs = useRef>([]); const detailSlotInputRefs = useRef>([]); @@ -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) => { 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) => { - 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({ - {videoFrameSlots.map((_, index) => ( ))} - {!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && activeComposerMode !== "clawd" && (!isVideoModel || showVideoAttachmentButton) && ( + {!isWorkspacePresentation && !isDetailGenerationMode && activeComposerMode !== "image" && (isClawdMode || !isVideoModel || showVideoAttachmentButton) && ( )} - {!isWorkspacePresentation && !isDetailGenerationMode && isClawdMode ? ( - - ) : null} {!isWorkspacePresentation ? (