fix: disable implicit image carryover and zip selected canvas downloads
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-19 13:26:13 +08:00
parent 81c1e955cb
commit d68928233a
4 changed files with 132 additions and 143 deletions

View File

@ -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<string>();
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;

View File

@ -46,36 +46,45 @@ function sanitizeDownloadFileName(value: string, fallback: string): string {
return cleaned || fallback;
}
async function triggerImageDownload(url: string, filename: string): Promise<void> {
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<Blob> {
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="下载为文件夹压缩包"
>
<Download className="h-3.5 w-3.5" />
下载选中({selectedImageNodes.length})
下载文件夹({selectedImageNodes.length})
</Button>
</div>
)}

View File

@ -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",

68
pnpm-lock.yaml generated
View File

@ -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