From c7e4553c45485b3184291174510d023a78222764 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 24 Jun 2026 20:03:25 +0800 Subject: [PATCH] fix canvas chat exports and image upload state --- components/canvas/CanvasChatPanel.tsx | 530 ++++++++++++++---- components/canvas/nodes/ImageNode.tsx | 59 +- components/canvas/nodes/UiScreenNode.tsx | 2 + components/canvas/nodes/WorkflowNode.tsx | 3 + .../image-node/image-node-empty-state.tsx | 23 +- components/elements/openclaw-chart-card.tsx | 2 +- components/elements/response.tsx | 187 +++++- types/canvas/index.ts | 2 +- 8 files changed, 682 insertions(+), 126 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 99434f614..8ce64f5a2 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2,7 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent } from "react"; import { useSession } from "next-auth/react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { createPortal } from "react-dom"; @@ -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 } 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, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -1499,6 +1499,7 @@ interface CanvasChatSession { } const CANVAS_CHAT_QUERY_PARAM = "chat"; +const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload"; function normalizeCanvasChatSessionId(value?: string | null) { const raw = String(value || "").trim(); @@ -1508,6 +1509,110 @@ function normalizeCanvasChatSessionId(value?: string | null) { return isValidChatUuid(rawUuid) ? `canvas-${rawUuid}` : null; } +type CopiedUserMessagePayload = { + text: string; + attachments: PickedObjectAttachment[]; + createdAt: number; +}; + +const COPIED_USER_MESSAGE_TTL_MS = 10 * 60 * 1000; + +function getComposerPlainTextFromElement(element: HTMLElement) { + const clone = element.cloneNode(true) as HTMLElement; + clone.querySelectorAll("[data-attachment-url]").forEach((el) => el.remove()); + return (clone.textContent || "") + .replace(/\u200b/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function isLikelyVideoAttachment(url: string, contentType: string) { + return ( + contentType.startsWith("video/") || + /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url) + ); +} + +function buildCopiedMessageAttachment( + value: any, + fallbackName: string +): PickedObjectAttachment | null { + const rawUrl = + typeof value?.url === "string" && value.url.trim() + ? value.url.trim() + : typeof value?.data === "string" && value.data.trim() + ? value.data.trim() + : ""; + if (!isValidChatHttpUrl(rawUrl)) return null; + + const contentType = normalizeChatMediaType( + value?.contentType || value?.mediaType || value?.mimeType + ); + if (isLikelyVideoAttachment(rawUrl, contentType)) return null; + + const width = Number(value?.width); + const height = Number(value?.height); + return sanitizeChatAttachment({ + url: rawUrl, + name: normalizeChatAttachmentName(value?.name || fallbackName), + contentType, + assetId: + typeof value?.assetId === "string" && value.assetId.trim() + ? value.assetId.trim() + : undefined, + width: Number.isFinite(width) && width > 0 ? Math.round(width) : undefined, + height: Number.isFinite(height) && height > 0 ? Math.round(height) : undefined, + pickedObject: value?.pickedObject, + } as PickedObjectAttachment); +} + +function getUserMessageCopyPayload(message: ChatMessage): Omit { + const text = extractUserTextPrompt(message); + const attachments: PickedObjectAttachment[] = []; + const seenUrls = new Set(); + + const appendAttachment = (attachment: PickedObjectAttachment | null) => { + if (!attachment || seenUrls.has(attachment.url)) return; + seenUrls.add(attachment.url); + attachments.push(attachment); + }; + + ((message as any).parts || []).forEach((part: any, index: number) => { + if (!part || part.type === "text") return; + appendAttachment( + buildCopiedMessageAttachment(part, part?.name || `图片${index + 1}`) + ); + }); + + (((message as any).attachments || []) as any[]).forEach((attachment, index) => { + appendAttachment( + buildCopiedMessageAttachment(attachment, attachment?.name || `图片${index + 1}`) + ); + }); + + return { text, attachments }; +} + +async function writeTextToClipboard(text: string) { + const value = text || " "; + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + if (typeof document === "undefined") return; + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + export function CanvasChatPanel({ chatId, initialMessages = [], @@ -1652,6 +1757,7 @@ export function CanvasChatPanel({ const sessionMenuRef = useRef(null); const historyScrollTimerRef = useRef | null>(null); const forceHistoryHydrationRef = useRef(null); + const copiedUserMessageRef = useRef(null); const replaceChatSessionQuery = useCallback( (sessionId: string) => { @@ -2621,6 +2727,31 @@ export function CanvasChatPanel({ console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50)); }, []); + const handleCopyUserMessage = useCallback(async (message: ChatMessage) => { + const payload = getUserMessageCopyPayload(message); + if (!payload.text && payload.attachments.length === 0) { + toast.info("这条消息没有可复制的内容"); + return; + } + + copiedUserMessageRef.current = { + ...payload, + createdAt: Date.now(), + }; + + try { + await writeTextToClipboard(payload.text); + toast.success( + payload.attachments.length > 0 + ? `已复制文字和 ${payload.attachments.length} 张图片,可粘贴到输入框` + : "已复制文字,可粘贴到输入框" + ); + } catch (error) { + console.error("[CanvasChat] copy user message failed:", error); + toast.error("复制失败,请重试"); + } + }, []); + // 🆕 辅助函数:在 contenteditable 中插入物品标签(带图片缩略图,样式与普通图片标签一致) const insertPickedObjectTagInEditor = useCallback(( url: string, @@ -3608,6 +3739,75 @@ export function CanvasChatPanel({ activeComposerMode === "agent" && agentUploadSlots.length > 0; const agentRequiredUploadCount = hasAgentUploadSlots ? 3 : 0; + const addCopiedAttachmentsToComposer = useCallback((items: PickedObjectAttachment[]) => { + const safeItems = items + .map((item) => sanitizeChatAttachment(item)) + .filter((item): item is PickedObjectAttachment => Boolean(item)); + + if (safeItems.length === 0) return 0; + + if (hasAgentUploadSlots) { + const slotCount = agentUploadSlots.length; + const next: Array = Array.from( + { length: slotCount }, + (_, index) => attachments[index] ?? null + ); + const existingUrls = new Set(next.filter(Boolean).map((item) => item!.url)); + let inserted = 0; + for (const item of safeItems) { + const slotIndex = next.findIndex((slot) => !slot); + if (slotIndex < 0) break; + if (existingUrls.has(item.url)) continue; + next[slotIndex] = item; + existingUrls.add(item.url); + inserted += 1; + } + setAttachments(next.filter((item): item is PickedObjectAttachment => Boolean(item))); + return inserted; + } + + if (isVideoModel && videoFrameSlots.length > 0) { + const slotCount = videoFrameSlots.length; + const next: Array = Array.from( + { length: slotCount }, + (_, index) => attachments[index] ?? null + ); + const existingUrls = new Set(next.filter(Boolean).map((item) => item!.url)); + let inserted = 0; + for (const item of safeItems) { + const slotIndex = next.findIndex((slot) => !slot); + if (slotIndex < 0) break; + if (existingUrls.has(item.url)) continue; + next[slotIndex] = item; + existingUrls.add(item.url); + inserted += 1; + } + setAttachments(next.filter((item): item is PickedObjectAttachment => Boolean(item))); + return inserted; + } + + const existingUrls = new Set(attachments.map((item) => item.url)); + const toAdd = safeItems.filter((item) => !existingUrls.has(item.url)); + if (toAdd.length > 0) { + setTimeout(() => { + toAdd.forEach((item, index) => { + insertImageTagInEditor(item.url, item.name, attachments.length + index); + }); + (textareaRef.current as HTMLDivElement | null)?.focus(); + }, 0); + } + + setAttachments([...attachments, ...toAdd]); + return toAdd.length; + }, [ + agentUploadSlots.length, + attachments, + hasAgentUploadSlots, + insertImageTagInEditor, + isVideoModel, + videoFrameSlots.length, + ]); + // 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签 useEffect(() => { if (pendingChatAttachments.length === 0) return; @@ -4539,6 +4739,45 @@ export function CanvasChatPanel({ [deleteNode] ); + useEffect(() => { + if (typeof window === "undefined") return; + + const handleManualImageUpload = (event: Event) => { + const uploadNodeId = String( + (event as CustomEvent<{ nodeId?: string }>).detail?.nodeId || "" + ).trim(); + const placeholderId = canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; + + clearStreamImageGenerationStateForChat(activeChatId); + if (placeholderId) { + delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; + } + + useCanvasStore + .getState() + .nodes.filter((node) => { + if (node.type !== "image" || node.id === uploadNodeId) return false; + const metadata = ((node.data as any)?.metadata || {}) as Record; + return ( + metadata.canvasGenerationPlaceholder === true && + (!metadata.chatId || String(metadata.chatId) === activeChatId) + ); + }) + .forEach((node) => deleteNode(node.id)); + }; + + window.addEventListener( + CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT, + handleManualImageUpload + ); + return () => { + window.removeEventListener( + CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT, + handleManualImageUpload + ); + }; + }, [activeChatId, clearStreamImageGenerationStateForChat, deleteNode]); + const appendStreamMediaToCanvas = useCallback( async (payload: CanvasMediaStreamPayload) => { const imageUrls = Array.isArray(payload.imageUrls) @@ -9856,6 +10095,59 @@ export function CanvasChatPanel({ videoFrameSlots.length, ]); + const handleComposerPaste = useCallback((event: ReactClipboardEvent) => { + const clipboardText = event.clipboardData.getData("text/plain"); + const copiedPayload = copiedUserMessageRef.current; + const isRecentCopiedUserMessage = + copiedPayload && + Date.now() - copiedPayload.createdAt <= COPIED_USER_MESSAGE_TTL_MS; + const matchesCopiedUserMessage = + Boolean(isRecentCopiedUserMessage) && + copiedPayload!.attachments.length > 0 && + copiedPayload!.text.trim() === clipboardText.trim(); + + if (matchesCopiedUserMessage && copiedPayload) { + event.preventDefault(); + if (copiedPayload.text.trim()) { + document.execCommand("insertText", false, copiedPayload.text); + } + + const editableDiv = textareaRef.current as HTMLDivElement | null; + if (editableDiv) { + setInput(getComposerPlainTextFromElement(editableDiv)); + } + + const inserted = addCopiedAttachmentsToComposer(copiedPayload.attachments); + if (inserted > 0) { + toast.success( + inserted === 1 ? "已粘贴文字和图片" : `已粘贴文字和 ${inserted} 张图片` + ); + } else { + toast.info("这条消息的图片已在输入框中,未重复添加"); + } + return; + } + + const clipboardItems = Array.from(event.clipboardData?.items || []); + const imageFiles = clipboardItems + .filter((item) => item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + + if (imageFiles.length > 0) { + event.preventDefault(); + void handlePastedImages(imageFiles); + return; + } + + event.preventDefault(); + document.execCommand("insertText", false, clipboardText); + const editableDiv = textareaRef.current as HTMLDivElement | null; + if (editableDiv) { + setInput(getComposerPlainTextFromElement(editableDiv)); + } + }, [addCopiedAttachmentsToComposer, handlePastedImages]); + // 处理文件选择 - 上传后立即添加到画布 const handleFileSelect = async (e: React.ChangeEvent) => { const files = e.target.files; @@ -10373,6 +10665,11 @@ export function CanvasChatPanel({ .getState() .nodes.find((node) => node.id === placeholderId); if (existingNode) { + const existingMetadata = + (((existingNode.data as any)?.metadata || {}) as Record); + if (existingMetadata.canvasGenerationPlaceholder !== true) { + delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; + } else { updateNode(placeholderId, { data: { ...((existingNode.data as any) || {}), @@ -10390,6 +10687,7 @@ export function CanvasChatPanel({ }); focusCanvasOnNode({ position, size: placeholderSize }); return; + } } delete canvasGenerationPlaceholderByChatIdRef.current[activeChatId]; } @@ -11218,110 +11516,126 @@ export function CanvasChatPanel({ : "w-full max-w-full items-start" )}> {message.role === 'user' ? ( -
-
- {(() => { - const renderedUserParts: React.ReactNode[] = []; + <> +
+
+ {(() => { + const renderedUserParts: React.ReactNode[] = []; - message.parts?.forEach((part: any, idx: number) => { - const fileUrl = - typeof part?.url === "string" && part.url.trim() - ? part.url - : typeof part?.data === "string" && part.data.trim() - ? part.data - : ""; + message.parts?.forEach((part: any, idx: number) => { + const fileUrl = + typeof part?.url === "string" && part.url.trim() + ? part.url + : typeof part?.data === "string" && part.data.trim() + ? part.data + : ""; - if (part.type === 'file' && fileUrl) { - renderedUserParts.push( - - { - if (event.currentTarget.src !== fileUrl) { - event.currentTarget.src = fileUrl; - } - }} - /> - {part.name || 'Image'} - - ); - return; - } + if (part.type === 'file' && fileUrl) { + renderedUserParts.push( + + { + if (event.currentTarget.src !== fileUrl) { + event.currentTarget.src = fileUrl; + } + }} + /> + {part.name || 'Image'} + + ); + return; + } - if (part.type === 'text' && part.text) { - const text = String(part.text) - .replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '') - .trim(); - if (!text) return; - renderedUserParts.push( - {text} - ); - } - }); - - if ( - !message.parts?.some((p: any) => p.type === 'file') && - (message as any).attachments?.length > 0 - ) { - (message as any).attachments.forEach((att: any, idx: number) => { - if (!att?.url) return; - renderedUserParts.push( - - { - if (event.currentTarget.src !== att.url) { - event.currentTarget.src = att.url; - } - }} - /> - {att.name || 'Image'} - - ); + if (part.type === 'text' && part.text) { + const text = String(part.text) + .replace(/【注意:图片中有标注(箭头、圆圈、文字等)标记的区域是需要重点关注或修改的部分,请根据标注进行处理】\n?/g, '') + .trim(); + if (!text) return; + renderedUserParts.push( + {text} + ); + } }); - } - if (renderedUserParts.length === 0) { - const fallbackText = - extractUserTextPrompt(message as any) || - (isVideoModel ? "已发送视频生成请求" : "已发送请求"); - renderedUserParts.push( - - {fallbackText} - - ); - } + if ( + !message.parts?.some((p: any) => p.type === 'file') && + (message as any).attachments?.length > 0 + ) { + (message as any).attachments.forEach((att: any, idx: number) => { + if (!att?.url) return; + renderedUserParts.push( + + { + if (event.currentTarget.src !== att.url) { + event.currentTarget.src = att.url; + } + }} + /> + {att.name || 'Image'} + + ); + }); + } - return renderedUserParts; - })()} + if (renderedUserParts.length === 0) { + const fallbackText = + extractUserTextPrompt(message as any) || + (isVideoModel ? "已发送视频生成请求" : "已发送请求"); + renderedUserParts.push( + + {fallbackText} + + ); + } + + return renderedUserParts; + })()} +
-
+ + ) : ( <>
{ - const clipboardItems = Array.from(e.clipboardData?.items || []); - const imageFiles = clipboardItems - .filter((item) => item.type.startsWith("image/")) - .map((item) => item.getAsFile()) - .filter((file): file is File => Boolean(file)); - - if (imageFiles.length > 0) { - e.preventDefault(); - void handlePastedImages(imageFiles); - return; - } - - e.preventDefault(); - const text = e.clipboardData.getData('text/plain'); - document.execCommand('insertText', false, text); - }} + onPaste={handleComposerPaste} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }} />
diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 614d42e15..812c652d2 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -734,6 +734,26 @@ function getAiWashStatusLabel(status?: AiWashResultStatus) { } } +const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload"; + +function stripImageGenerationMetadata(metadata?: ImageNodeData["metadata"]) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return undefined; + } + + const next = { ...metadata }; + delete next.canvasGenerationPlaceholder; + delete next.detailProgressLabel; + delete next.generationProgress; + delete next.latestGeneratedHighlight; + delete next.recentlyGeneratedAt; + delete next.taskId; + delete next.taskRecoveredAt; + delete next.taskRecoveryStatus; + delete next.error; + return Object.keys(next).length > 0 ? next : undefined; +} + async function pollAiWashQuickTask(taskId: string) { for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) { const response = await fetch( @@ -6205,9 +6225,21 @@ export function ImageNode({ } setIsUploading(true); - // 先显示上传中状态 + window.dispatchEvent( + new CustomEvent(CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT, { + detail: { nodeId: node.id }, + }) + ); + + const cleanMetadata = stripImageGenerationMetadata(data.metadata); + // 先显示上传中状态。手动上传不是生图,不能进入 generating 占位态。 updateNode(node.id, { - data: { ...data, status: "generating" }, + data: { + ...data, + src: "", + status: "uploading", + metadata: cleanMetadata, + }, }); try { @@ -6223,19 +6255,38 @@ export function ImageNode({ assetId: result.data.assetId, status: "completed", title: normalizeImageTitle(data.title) || fileBaseName, + metadata: cleanMetadata, }, }); toast.success("图片上传成功"); } else { updateNode(node.id, { - data: { ...data, status: "failed" }, + data: { + ...data, + src: "", + status: "failed", + metadata: { + ...(cleanMetadata || {}), + uploadError: true, + detailProgressLabel: "上传失败", + }, + }, }); toast.error(result.error || "图片上传失败"); } } catch (error) { console.error("图片上传失败:", error); updateNode(node.id, { - data: { ...data, status: "failed" }, + data: { + ...data, + src: "", + status: "failed", + metadata: { + ...(cleanMetadata || {}), + uploadError: true, + detailProgressLabel: "上传失败", + }, + }, }); toast.error("图片上传失败,请重试"); } finally { diff --git a/components/canvas/nodes/UiScreenNode.tsx b/components/canvas/nodes/UiScreenNode.tsx index e6245a187..2f13d94db 100644 --- a/components/canvas/nodes/UiScreenNode.tsx +++ b/components/canvas/nodes/UiScreenNode.tsx @@ -36,6 +36,7 @@ interface UiScreenNodeProps { const STATUS_LABELS = { pending: "待生成", + uploading: "上传中", generating: "渲染中", completed: "可编辑 UI", failed: "失败", @@ -43,6 +44,7 @@ const STATUS_LABELS = { const STATUS_CLASS_MAP = { pending: "border-slate-200 bg-slate-50 text-slate-600", + uploading: "border-blue-200 bg-blue-50 text-blue-700", generating: "border-blue-200 bg-blue-50 text-blue-700", completed: "border-emerald-200 bg-emerald-50 text-emerald-700", failed: "border-rose-200 bg-rose-50 text-rose-700", diff --git a/components/canvas/nodes/WorkflowNode.tsx b/components/canvas/nodes/WorkflowNode.tsx index 29766c5d0..fd603382e 100644 --- a/components/canvas/nodes/WorkflowNode.tsx +++ b/components/canvas/nodes/WorkflowNode.tsx @@ -122,6 +122,7 @@ const GROUP_STYLE_MAP = { const STATUS_LABELS = { pending: "待配置", + uploading: "上传中", generating: "处理中", completed: "已完成", failed: "异常", @@ -129,6 +130,8 @@ const STATUS_LABELS = { const STATUS_CLASS_MAP = { pending: "border-slate-200 bg-white text-foreground/70 dark:border-zinc-800 dark:bg-zinc-950", + uploading: + "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-200", generating: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-200", completed: diff --git a/components/canvas/nodes/image-node/image-node-empty-state.tsx b/components/canvas/nodes/image-node/image-node-empty-state.tsx index f898f3656..b9423b460 100644 --- a/components/canvas/nodes/image-node/image-node-empty-state.tsx +++ b/components/canvas/nodes/image-node/image-node-empty-state.tsx @@ -57,6 +57,22 @@ export function ImageNodeEmptyState({ status, viewportScale, }: ImageNodeEmptyStateProps) { + if (status === "uploading") { + return ( +
+ + + + + 正在上传图片 + + + 上传完成后会自动显示 + +
+ ); + } + if (status === "generating") { return ( - 生成失败 + {failedTitle} + + + {failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"} - 请重新上传或再试一次
); } diff --git a/components/elements/openclaw-chart-card.tsx b/components/elements/openclaw-chart-card.tsx index 6d2848d2f..9fe48aa87 100644 --- a/components/elements/openclaw-chart-card.tsx +++ b/components/elements/openclaw-chart-card.tsx @@ -200,7 +200,7 @@ export function splitOpenClawChartBlocks(content: string): OpenClawResponseSegme return segments.length > 0 ? segments : [{ type: "text", value: content }]; } -function chartDataToCsv(chart: OpenClawChartSpec) { +export function chartDataToCsv(chart: OpenClawChartSpec) { const headers = [chart.xKey, ...chart.series.map((series) => series.name || series.key)]; const rows = chart.data.map((row) => [ diff --git a/components/elements/response.tsx b/components/elements/response.tsx index 4a9d33a4d..711617327 100644 --- a/components/elements/response.tsx +++ b/components/elements/response.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDownIcon, DownloadIcon } from "lucide-react"; +import { ChevronDownIcon, DownloadIcon, FileSpreadsheetIcon } from "lucide-react"; import type { ComponentProps, PointerEvent as ReactPointerEvent, @@ -10,6 +10,7 @@ import { useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { ImagePreview } from "@/components/image-preview"; import { + chartDataToCsv, OpenClawChartCard, splitOpenClawChartBlocks, } from "@/components/elements/openclaw-chart-card"; @@ -201,6 +202,141 @@ function downloadTextFile(filename: string, content: string, type: string) { URL.revokeObjectURL(url); } +type ReportCsvSection = { + title: string; + csv: string; +}; + +function stripMarkdownTableCell(value: string) { + return normalizeTableCellText( + String(value || "") + .replace(/\\\|/g, "|") + .replace(//gi, " ") + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/__(.*?)__/g, "$1") + .replace(/`([^`]+)`/g, "$1") + ); +} + +function splitMarkdownTableRow(line: string) { + const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, ""); + const cells: string[] = []; + let current = ""; + + for (let index = 0; index < trimmed.length; index += 1) { + const char = trimmed[index]; + const next = trimmed[index + 1]; + if (char === "\\" && next === "|") { + current += "|"; + index += 1; + continue; + } + if (char === "|") { + cells.push(stripMarkdownTableCell(current)); + current = ""; + continue; + } + current += char; + } + + cells.push(stripMarkdownTableCell(current)); + return cells; +} + +function isMarkdownTableSeparator(cells: string[]) { + return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); +} + +function getMarkdownTableTitle(lines: string[], tableStartIndex: number, fallback: string) { + for (let index = tableStartIndex - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim() || ""; + if (!line) continue; + if (line.includes("|")) break; + + const title = line + .replace(/^#{1,6}\s*/, "") + .replace(/^\d+\.\s*/, "") + .replace(/^[-*]\s*/, "") + .replace(/^\*\*(.*?)\*\*$/, "$1") + .replace(/[::]\s*$/, "") + .trim(); + if (title) return title; + break; + } + return fallback; +} + +function tableRowsToCsv(rows: string[][]) { + return rows + .map((row) => row.map((cell) => escapeCsvCell(cell)).join(",")) + .join("\r\n"); +} + +function extractMarkdownTableCsvSections(content: string): ReportCsvSection[] { + const lines = String(content || "").replace(/\r\n/g, "\n").split("\n"); + const sections: ReportCsvSection[] = []; + let inCodeFence = false; + + for (let index = 0; index < lines.length - 1; index += 1) { + const currentLine = lines[index] || ""; + if (/^\s*```/.test(currentLine)) { + inCodeFence = !inCodeFence; + continue; + } + if (inCodeFence || !currentLine.includes("|")) continue; + + const header = splitMarkdownTableRow(currentLine); + const separator = splitMarkdownTableRow(lines[index + 1] || ""); + if (header.length < 2 || !isMarkdownTableSeparator(separator)) continue; + + const rows: string[][] = [header]; + let cursor = index + 2; + while (cursor < lines.length) { + const rowLine = lines[cursor] || ""; + if (!rowLine.includes("|") || /^\s*```/.test(rowLine)) break; + const row = splitMarkdownTableRow(rowLine); + if (row.length < 2) break; + rows.push(row); + cursor += 1; + } + + sections.push({ + title: getMarkdownTableTitle(lines, index, `表格 ${sections.length + 1}`), + csv: tableRowsToCsv(rows), + }); + index = cursor - 1; + } + + return sections; +} + +function extractChartCsvSections(content: string): ReportCsvSection[] { + return splitOpenClawChartBlocks(content) + .filter((segment): segment is { type: "chart"; value: Parameters[0] } => + segment.type === "chart" + ) + .map((segment, index) => ({ + title: segment.value.title || `图表 ${index + 1}`, + csv: chartDataToCsv(segment.value), + })); +} + +function buildReportTablesCsv(content: string) { + const sections = [ + ...extractMarkdownTableCsvSections(content), + ...extractChartCsvSections(content), + ]; + + return sections + .map((section, index) => + [ + escapeCsvCell(`${index + 1}. ${section.title}`), + section.csv, + ].join("\r\n") + ) + .join("\r\n\r\n"); +} + function normalizeMarkdownContent(value: string) { return String(value || "") .replace(/\r\n/g, "\n") @@ -465,6 +601,49 @@ function MarkdownTableHeaderCell({ ); } +function getSafeCsvFileName(value?: string | null) { + const normalized = String(value || "Claw分析报告") + .replace(/\s+/g, " ") + .replace(/[\\/:*?"<>|]/g, "_") + .trim() + .slice(0, 80); + return normalized || "Claw分析报告"; +} + +function ResponseReportTableDownloadButton({ + content, + fileName, +}: { + content: string; + fileName?: string; +}) { + const handleDownload = () => { + const csv = buildReportTablesCsv(content); + if (!csv.trim()) { + console.warn("[Response] no report table data to export"); + return; + } + + downloadTextFile( + `${getSafeCsvFileName(fileName)}-表格.csv`, + `\uFEFF${csv}`, + "text/csv;charset=utf-8" + ); + }; + + return ( + + ); +} + // 注意:移除了 memo 的自定义比较函数,确保 isProcessing* 状态变化时组件能正确重新渲染 // 之前的 memo 比较函数可能导致 Streamdown 内部的 ImagePreview 组件没有正确更新 export const Response = ({ @@ -636,9 +815,13 @@ export const Response = ({
{content}
+