diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 883c34cb2..eed1ee1a0 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -271,12 +271,6 @@ interface PickedObjectAttachment extends Attachment { }; } -interface CanvasImageAttachment { - url: string; - name: string; - contentType: string; -} - interface CanvasChatSession { id: string; title: string; @@ -2172,81 +2166,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF // 标记是否已经为当前画布设置过标题(避免重复调用) const hasSetCanvasTitle = useRef(false); - // 获取最近的图片附件(支持多张图片与名称同步) - const getRecentImageAttachments = useCallback((): CanvasImageAttachment[] => { - const result: CanvasImageAttachment[] = []; - const addedUrls = new Set(); - - const addAttachment = (item: CanvasImageAttachment) => { - if (!item.url || addedUrls.has(item.url)) return; - addedUrls.add(item.url); - result.push(item); - }; - - const getNodeName = (node: any, index: number) => { - return ( - node?.data?.title || - node?.data?.prompt || - `图片 ${index + 1}` - ); - }; - - // 1. 优先使用画布中选中的图片(支持多选) - if (selectedImageNodes.length > 0) { - selectedImageNodes.forEach((node, index) => { - const src = (node.data as any)?.src; - if (src) { - addAttachment({ - url: src, - name: getNodeName(node, index), - contentType: "image/jpeg", - }); - } - }); - if (result.length > 0) { - console.log("[CanvasChat] 使用选中的图片:", result.length, "张"); - return result; - } - } - - // 2. 历史对话最近一轮图片 - for (let i = messages.length - 1; i >= 0; i -= 1) { - const msg = messages[i]; - const msgAttachments: CanvasImageAttachment[] = []; - if (msg.role === "user" && (msg as any).parts) { - for (const part of (msg as any).parts) { - if (part.type === "file" && part.url && part.mediaType?.startsWith("image/")) { - msgAttachments.push({ - url: part.url, - name: part.name || "图片", - contentType: part.mediaType || "image/jpeg", - }); - } - } - } - if (msgAttachments.length > 0) { - msgAttachments.forEach(addAttachment); - console.log("[CanvasChat] 使用历史对话图片:", result.length, "张"); - return result; - } - } - - // 3. 画布中最近图片 - const imageNodes = nodes.filter((n) => n.type === "image" && (n.data as any)?.src); - if (imageNodes.length > 0) { - imageNodes.slice(-5).forEach((node, index) => { - addAttachment({ - url: (node.data as any).src, - name: getNodeName(node, index), - contentType: "image/jpeg", - }); - }); - console.log("[CanvasChat] 使用画布图片:", result.length, "张"); - } - - return result; - }, [messages, nodes, selectedImageNodes]); - // 🆕 Agent模式上下文(画布图片布局) const buildAutoContext = useCallback(() => { const imageNodes = nodes.filter(n => n.type === 'image' && (n.data as any)?.src); @@ -3517,32 +3436,11 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF // 构建 parts 数组:文件在前,文本在后(与 multimodal-input 一致) const parts: any[] = []; - // 🆕 如果没有附件,自动使用最近的图片(支持多张) let effectiveAttachments = [...attachments]; const videoImageLimit = isVideoModel ? getVideoImageLimit(videoParams, currentModel?.providerType) : null; - if ( - effectiveAttachments.length === 0 && - input.trim() && - (!videoImageLimit || videoImageLimit.max > 0) && - videoFrameSlots.length === 0 - ) { - const recentAttachments = getRecentImageAttachments(); - if (recentAttachments.length > 0) { - // 自动添加所有最近的图片作为附件 - recentAttachments.forEach((attachment) => { - effectiveAttachments.push({ - url: attachment.url, - name: attachment.name, - contentType: attachment.contentType, - }); - }); - console.log('[CanvasChat] 自动使用最近图片:', recentAttachments.length, '张'); - } - } - if (videoImageLimit) { if (videoImageLimit.max === 0) { effectiveAttachments = []; @@ -3669,7 +3567,6 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF attachments, selectedModelId, sendMessage, - getRecentImageAttachments, clearSelection, buildAutoContext, isVideoModel, @@ -3909,13 +3806,9 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF detailProgress.total > 0 ? Math.min(100, Math.round((detailProgress.current / detailProgress.total) * 100)) : 0; - const hasDetailFallbackAttachment = - !isDetailGenerationMode && - !hasInputOrAttachments && - getRecentImageAttachments().length > 0; const canSend = isDetailGenerationMode ? detailValidationIssues.length === 0 && !isDetailGenerating - : hasInputOrAttachments || hasDetailFallbackAttachment; + : hasInputOrAttachments; const currentVideoImageLimit = isVideoModel ? getVideoImageLimit(videoParams, currentModel?.providerType) : null; diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 7665fe7b7..145a6d9a9 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -46,36 +46,45 @@ function sanitizeDownloadFileName(value: string, fallback: string): string { return cleaned || fallback; } -async function triggerImageDownload(url: string, filename: string): Promise { - if (!url) throw new Error("missing_url"); - const clickLink = (href: string) => { - const link = document.createElement("a"); - link.href = href; - link.download = filename; - link.rel = "noopener noreferrer"; - link.target = "_blank"; - document.body.appendChild(link); - link.click(); - link.remove(); - }; +function inferFileExtension(url: string, contentType?: string): string { + const byType = String(contentType || "").toLowerCase(); + if (byType.includes("jpeg")) return "jpg"; + if (byType.includes("png")) return "png"; + if (byType.includes("webp")) return "webp"; + if (byType.includes("gif")) return "gif"; + if (byType.includes("bmp")) return "bmp"; + if (byType.includes("tiff")) return "tiff"; - try { - const response = await fetch(url, { - method: "GET", - credentials: "omit", - mode: "cors", - }); - if (!response.ok) { - throw new Error(`http_${response.status}`); - } - const blob = await response.blob(); - const objectUrl = URL.createObjectURL(blob); - clickLink(objectUrl); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500); - return; - } catch (_error) { - clickLink(url); + const cleanUrl = String(url || "").split("?")[0] || ""; + const matched = cleanUrl.match(/\.([a-zA-Z0-9]{2,6})$/); + if (matched?.[1]) { + return matched[1].toLowerCase(); } + return "png"; +} + +function downloadBlob(blob: Blob, filename: string): void { + const link = document.createElement("a"); + const objectUrl = URL.createObjectURL(blob); + link.href = objectUrl; + link.download = filename; + link.rel = "noopener noreferrer"; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500); +} + +async function fetchImageBlob(url: string): Promise { + const response = await fetch(url, { + method: "GET", + credentials: "omit", + mode: "cors", + }); + if (!response.ok) { + throw new Error(`http_${response.status}`); + } + return response.blob(); } export function CanvasWorkspace() { @@ -557,7 +566,15 @@ export function CanvasWorkspace() { toast.error("请先框选要下载的图片"); return; } + if (selectedImageNodes.length > 50) { + toast.warning("当前选择图片较多,正在打包,请稍候"); + } + const { default: JSZip } = await import("jszip"); + const zip = new JSZip(); + const now = new Date(); + const folderName = `canvas-images-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${String(now.getSeconds()).padStart(2, "0")}`; + const folder = zip.folder(folderName) || zip; let successCount = 0; let failedCount = 0; @@ -574,21 +591,31 @@ export function CanvasWorkspace() { String(nodeData?.title || "").trim() || String(nodeData?.prompt || "").trim() || `canvas-image-${index + 1}`; - const filename = `${sanitizeDownloadFileName(titleSeed, `canvas-image-${index + 1}`)}.png`; try { - await triggerImageDownload(imageUrl, filename); + const blob = await fetchImageBlob(imageUrl); + const ext = inferFileExtension(imageUrl, blob.type); + const filename = `${sanitizeDownloadFileName(titleSeed, `canvas-image-${index + 1}`)}.${ext}`; + folder.file(filename, blob); successCount += 1; } catch (_error) { failedCount += 1; } } - if (failedCount > 0) { - toast.warning(`已下载 ${successCount} 张,失败 ${failedCount} 张`); + if (successCount === 0) { + toast.error("打包失败,未获取到可下载图片"); return; } - toast.success(`已开始下载 ${successCount} 张图片`); + + const zipBlob = await zip.generateAsync({ type: "blob" }); + downloadBlob(zipBlob, `${folderName}.zip`); + + if (failedCount > 0) { + toast.warning(`已打包 ${successCount} 张到文件夹,失败 ${failedCount} 张`); + return; + } + toast.success(`已下载压缩包,包含 ${successCount} 张图片`); }, [selectedImageNodes]); // 渲染节点 @@ -805,10 +832,10 @@ export function CanvasWorkspace() { size="sm" className="h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60" onClick={handleDownloadSelectedImages} - title="下载当前框选图片" + title="下载为文件夹压缩包" > - 下载选中({selectedImageNodes.length}) + 下载文件夹({selectedImageNodes.length}) )} diff --git a/package.json b/package.json index 3cfbb57e9..08d766e1f 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "framer-motion": "^11.18.2", "geist": "^1.3.1", "immer": "^11.1.3", + "jszip": "^3.10.1", "katex": "^0.16.25", "lucide-react": "^0.446.0", "matter-js": "^0.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902323462..720cb344b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: immer: specifier: ^11.1.3 version: 11.1.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 katex: specifier: ^0.16.25 version: 0.16.25 @@ -3431,6 +3434,9 @@ packages: console-table-printer@2.15.0: resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cose-base@1.0.3: resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} @@ -4083,6 +4089,9 @@ packages: import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -4136,6 +4145,9 @@ packages: is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -4166,6 +4178,9 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + katex@0.16.25: resolution: {integrity: sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==} hasBin: true @@ -4658,6 +4673,9 @@ packages: package-manager-detector@1.5.0: resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + papaparse@5.5.2: resolution: {integrity: sha512-PZXg8UuAc4PcVwLosEEDYjPyfWnTEhOrUfdv+3Bx+NuAb+5NhDmXzg5fHWmdCh1mP5p7JAZfFr3IMQfcntNAdA==} @@ -4796,6 +4814,9 @@ packages: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + promise-worker-transferable@1.0.4: resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} @@ -4972,6 +4993,9 @@ packages: resolution: {integrity: sha512-RYkYXmX7Iia56DQJaNK0Ribh5uF/sDBNirtNNHNNqqa3wpIlcFOkRvPTW3RSwAXmBVnEZlKbeaMhlBT9p52lqA==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + redis@5.9.0: resolution: {integrity: sha512-E8dQVLSyH6UE/C9darFuwq4usOPrqfZ1864kI4RFbr5Oj9ioB9qPF0oJMwX7s8mf6sPYrz84x/Dx1PGF3/0EaQ==} engines: {node: '>= 18'} @@ -5055,6 +5079,9 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -5072,6 +5099,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -5142,6 +5172,9 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -8863,6 +8896,8 @@ snapshots: dependencies: simple-wcswidth: 1.1.2 + core-util-is@1.0.3: {} + cose-base@1.0.3: dependencies: layout-base: 1.0.2 @@ -9552,6 +9587,8 @@ snapshots: cjs-module-lexer: 1.4.3 module-details-from-path: 1.0.4 + inherits@2.0.4: {} + inline-style-parser@0.2.4: {} internmap@1.0.1: {} @@ -9592,6 +9629,8 @@ snapshots: is-promise@2.2.2: {} + isarray@1.0.0: {} + isexe@2.0.0: {} its-fine@2.0.0(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021): @@ -9617,6 +9656,13 @@ snapshots: jsonc-parser@3.3.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + katex@0.16.25: dependencies: commander: 8.3.0 @@ -10310,6 +10356,8 @@ snapshots: package-manager-detector@1.5.0: {} + pako@1.0.11: {} + papaparse@5.5.2: {} parse-entities@2.0.0: @@ -10454,6 +10502,8 @@ snapshots: prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} + promise-worker-transferable@1.0.4: dependencies: is-promise: 2.2.2 @@ -10748,6 +10798,16 @@ snapshots: react@19.0.0-rc-45804af1-20241021: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + redis@5.9.0: dependencies: '@redis/bloom': 5.9.0(@redis/client@5.9.0) @@ -10898,6 +10958,8 @@ snapshots: rw@1.3.3: {} + safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} scheduler@0.25.0-rc-45804af1-20241021: {} @@ -10908,6 +10970,8 @@ snapshots: server-only@0.0.1: {} + setimmediate@1.0.5: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -11013,6 +11077,10 @@ snapshots: - '@types/react' - supports-color + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0