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 ? (