diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index fa7d1002e..b021b4b2c 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -1239,6 +1239,15 @@ type CanvasUploadProgressOptions = {
target?: CanvasComposerUploadProgress["target"];
};
+type ComposerUploadedImage = {
+ imageUrl: string;
+ name: string;
+ contentType: string;
+ assetId?: string;
+ width?: number;
+ height?: number;
+};
+
function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
switch (phase) {
case "preparing":
@@ -3723,7 +3732,7 @@ export function CanvasChatPanel({
// 🆕 辅助函数:在 contenteditable 中插入图片标签
const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
- if (!editableDiv || !editableDiv.isContentEditable) return;
+ if (!editableDiv) return;
if (!isValidChatHttpUrl(url)) {
console.warn('[CanvasChat] 跳过非 HTTP 图片标签:', String(url || '').slice(0, 40));
return;
@@ -3742,6 +3751,7 @@ export function CanvasChatPanel({
tagSpan.contentEditable = 'false';
tagSpan.setAttribute('data-attachment-index', String(index));
tagSpan.setAttribute('data-attachment-url', url);
+ tagSpan.setAttribute('data-attachment-name', name);
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name;
@@ -3754,8 +3764,16 @@ export function CanvasChatPanel({
label.className = 'text-xs text-foreground/80';
label.textContent = shortName;
+ const removeButton = document.createElement('button');
+ removeButton.type = 'button';
+ removeButton.contentEditable = 'false';
+ removeButton.setAttribute('data-attachment-remove', 'true');
+ removeButton.className = 'ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-[11px] leading-none text-foreground/45 hover:bg-black/10 hover:text-foreground dark:hover:bg-white/15';
+ removeButton.textContent = '×';
+
tagSpan.appendChild(image);
tagSpan.appendChild(label);
+ tagSpan.appendChild(removeButton);
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
const selection = window.getSelection();
@@ -3810,7 +3828,7 @@ export function CanvasChatPanel({
index: number
) => {
const editableDiv = textareaRef.current as HTMLDivElement | null;
- if (!editableDiv || !editableDiv.isContentEditable) return;
+ if (!editableDiv) return;
// 检查 DOM 中是否已存在该物品的标签
const existingTags = editableDiv.querySelectorAll('[data-picked-object="true"]');
@@ -3829,6 +3847,7 @@ export function CanvasChatPanel({
tagSpan.setAttribute('data-picked-object', 'true');
tagSpan.setAttribute('data-object-name', objectName);
tagSpan.setAttribute('data-object-position', objectPosition);
+ tagSpan.setAttribute('data-attachment-name', objectName);
// 样式与普通图片标签一致
tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none';
// 使用图片缩略图,不使用 emoji 或图标
@@ -3837,6 +3856,7 @@ export function CanvasChatPanel({
${objectName}
·
${objectPosition}
+
`;
// 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入
@@ -4072,6 +4092,7 @@ export function CanvasChatPanel({
const url = tagSpan.getAttribute('data-attachment-url');
const isPickedObject = tagSpan.getAttribute('data-picked-object') === 'true';
const objectName = tagSpan.getAttribute('data-object-name');
+ const attachmentName = tagSpan.getAttribute('data-attachment-name');
if (url) {
const rect = tagSpan.getBoundingClientRect();
@@ -4096,7 +4117,7 @@ export function CanvasChatPanel({
setTagPreview({
url,
- name: objectName || tagSpan.textContent || '图片',
+ name: objectName || attachmentName || tagSpan.textContent || '图片',
x: rect.left + rect.width / 2,
y: rect.top,
containerBounds,
@@ -11631,10 +11652,64 @@ export function CanvasChatPanel({
}
}, [chatId]);
+ const addUploadedImageAttachmentToComposer = useCallback(
+ (uploaded: ComposerUploadedImage) => {
+ setAttachments((prev) => {
+ if (prev.some((attachment) => attachment.url === uploaded.imageUrl)) {
+ return prev;
+ }
+ const newIndex = prev.length;
+ setTimeout(() => {
+ insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
+ (textareaRef.current as HTMLDivElement | null)?.focus();
+ }, 0);
+
+ return [
+ ...prev,
+ {
+ url: uploaded.imageUrl,
+ name: uploaded.name,
+ contentType: uploaded.contentType,
+ assetId: uploaded.assetId,
+ width: uploaded.width,
+ height: uploaded.height,
+ },
+ ];
+ });
+ },
+ [insertImageTagInEditor]
+ );
+
+ const updateUploadedImageAttachmentDimensions = useCallback(
+ (
+ imageUrl: string,
+ dimensions: {
+ width?: number;
+ height?: number;
+ originalWidth?: number;
+ originalHeight?: number;
+ }
+ ) => {
+ setAttachments((prev) =>
+ prev.map((attachment) =>
+ attachment.url === imageUrl
+ ? {
+ ...attachment,
+ width: dimensions.originalWidth || dimensions.width || attachment.width,
+ height: dimensions.originalHeight || dimensions.height || attachment.height,
+ }
+ : attachment
+ )
+ );
+ },
+ []
+ );
+
const uploadImageFileAndAddToCanvas = useCallback(async (
file: File,
nodeIndex: number,
- progressOptions?: CanvasUploadProgressOptions
+ progressOptions?: CanvasUploadProgressOptions,
+ onUploaded?: (uploaded: ComposerUploadedImage) => void
): Promise<{
imageUrl: string;
name: string;
@@ -11680,6 +11755,13 @@ export function CanvasChatPanel({
const imageUrl = data.url as string;
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
+ const uploadedBase: ComposerUploadedImage = {
+ imageUrl,
+ name: fileName,
+ contentType: file.type || 'image/jpeg',
+ assetId,
+ };
+ onUploaded?.(uploadedBase);
clearComposerUploadProgress(250);
const dimensions = await getImageDimensions(imageUrl);
const spacing = 20;
@@ -11705,10 +11787,7 @@ export function CanvasChatPanel({
});
return {
- imageUrl,
- name: fileName,
- contentType: file.type || 'image/jpeg',
- assetId,
+ ...uploadedBase,
width: dimensions.originalWidth || dimensions.width,
height: dimensions.originalHeight || dimensions.height,
};
@@ -11769,15 +11848,12 @@ export function CanvasChatPanel({
? data.assetId.trim()
: undefined;
clearComposerUploadProgress(250);
- const dimensions = await getImageDimensions(imageUrl);
return {
imageUrl,
name: fileName,
contentType: file.type || "image/jpeg",
assetId,
- width: dimensions.originalWidth || dimensions.width,
- height: dimensions.originalHeight || dimensions.height,
};
} catch (_error) {
toast.error("图片上传失败");
@@ -11814,25 +11890,7 @@ export function CanvasChatPanel({
if (!uploaded) continue;
handledCount += 1;
- setAttachments((prev) => {
- const newIndex = prev.length;
- setTimeout(() => {
- insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
- (textareaRef.current as HTMLDivElement | null)?.focus();
- }, 0);
-
- return [
- ...prev,
- {
- url: uploaded.imageUrl,
- name: uploaded.name,
- contentType: uploaded.contentType,
- assetId: uploaded.assetId,
- width: uploaded.width,
- height: uploaded.height,
- },
- ];
- });
+ addUploadedImageAttachmentToComposer(uploaded);
}
} finally {
clearComposerUploadProgress(450);
@@ -11962,6 +12020,7 @@ export function CanvasChatPanel({
let handledCount = 0;
try {
for (const [index, file] of files.entries()) {
+ let addedToComposer = false;
const uploaded = await uploadImageFileAndAddToCanvas(
file,
baseNodeCount + uploadIndex,
@@ -11974,6 +12033,10 @@ export function CanvasChatPanel({
fileIndex: index,
fileTotal: files.length,
target: "composer-reference",
+ },
+ (instantUploaded) => {
+ addedToComposer = true;
+ addUploadedImageAttachmentToComposer(instantUploaded);
}
);
if (!uploaded) continue;
@@ -11984,25 +12047,10 @@ export function CanvasChatPanel({
firstImageUrl = uploaded.imageUrl;
}
- setAttachments((prev) => {
- const newIndex = prev.length;
- setTimeout(() => {
- insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
- (textareaRef.current as HTMLDivElement | null)?.focus();
- }, 0);
-
- return [
- ...prev,
- {
- url: uploaded.imageUrl,
- name: uploaded.name,
- contentType: uploaded.contentType,
- assetId: uploaded.assetId,
- width: uploaded.width,
- height: uploaded.height,
- },
- ];
- });
+ if (!addedToComposer) {
+ addUploadedImageAttachmentToComposer(uploaded);
+ }
+ updateUploadedImageAttachmentDimensions(uploaded.imageUrl, uploaded);
}
} finally {
clearComposerUploadProgress(450);
@@ -12014,17 +12062,18 @@ export function CanvasChatPanel({
void maybeAutoSetCanvasTitle(firstImageUrl);
}, [
+ addUploadedImageAttachmentToComposer,
agentUploadSlots,
attachments,
clearComposerUploadProgress,
hasAgentUploadSlots,
- insertImageTagInEditor,
isClawdMode,
isVideoModel,
maybeAutoSetCanvasTitle,
nodes.length,
updateAttachmentAtAgentSlot,
updateAttachmentAtVideoSlot,
+ updateUploadedImageAttachmentDimensions,
uploadImageFileForComposerAttachment,
uploadImageFileAndAddToCanvas,
videoFrameSlots,
@@ -12127,25 +12176,7 @@ export function CanvasChatPanel({
if (!uploaded) continue;
handledImageCount += 1;
- setAttachments((prev) => {
- const newIndex = prev.length;
- setTimeout(() => {
- insertImageTagInEditor(uploaded.imageUrl, uploaded.name, newIndex);
- (textareaRef.current as HTMLDivElement | null)?.focus();
- }, 0);
-
- return [
- ...prev,
- {
- url: uploaded.imageUrl,
- name: uploaded.name,
- contentType: uploaded.contentType,
- assetId: uploaded.assetId,
- width: uploaded.width,
- height: uploaded.height,
- },
- ];
- });
+ addUploadedImageAttachmentToComposer(uploaded);
}
} finally {
clearComposerUploadProgress(450);
@@ -12280,30 +12311,21 @@ export function CanvasChatPanel({
const assetId =
typeof data.assetId === "string" && data.assetId.trim() ? data.assetId.trim() : undefined;
clearComposerUploadProgress(250);
- const dimensions = await getImageDimensions(imageUrl);
+ const uploadedAttachment: ComposerUploadedImage = {
+ imageUrl,
+ name: file.name,
+ contentType: file.type,
+ assetId,
+ };
+ addUploadedImageAttachmentToComposer(uploadedAttachment);
// 记录第一张图片的 URL(用于自动命名和缩略图)
if (!firstImageUrl) {
firstImageUrl = imageUrl;
}
-
- // 添加到附件列表并在编辑器中插入标签
- setAttachments(prev => {
- const newIndex = prev.length;
- // 在编辑器中插入图片标签
- setTimeout(() => {
- insertImageTagInEditor(imageUrl, file.name, newIndex);
- (textareaRef.current as HTMLDivElement | null)?.focus();
- }, 0);
- return [...prev, {
- url: imageUrl,
- name: file.name,
- contentType: file.type,
- assetId,
- width: dimensions.originalWidth || dimensions.width,
- height: dimensions.originalHeight || dimensions.height,
- }];
- });
+
+ const dimensions = await getImageDimensions(imageUrl);
+ updateUploadedImageAttachmentDimensions(imageUrl, dimensions);
// 获取图片原始尺寸后添加到画布
// 使用 baseNodeCount + uploadIndex 计算位置,确保多张图片不重叠
@@ -15564,44 +15586,19 @@ export function CanvasChatPanel({
) : null}
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
-
- {attachments.map((attachment, index) => (
-
-

{
- if (event.currentTarget.src !== attachment.url) {
- event.currentTarget.src = attachment.url;
- }
- }}
- />
-
- ))}
+