From 9e0dfdfb303e12ddb91961c60813ce7973ddfdd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E5=A7=93=E5=90=8D?= <你的邮箱@example.com> Date: Sat, 1 Aug 2026 12:45:54 +0800 Subject: [PATCH] fix: restore images when editing messages --- components/ChatInput.test.mjs | 30 ++++++++++++++++++++++- components/ChatInput.tsx | 45 +++++++++++++++++++++++++++++++++++ components/ChatWindow.tsx | 6 ++--- components/MessageView.tsx | 6 ++--- 4 files changed, 80 insertions(+), 7 deletions(-) diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs index 7d1e6757..8b78bc60 100644 --- a/components/ChatInput.test.mjs +++ b/components/ChatInput.test.mjs @@ -8,7 +8,7 @@ const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true, }); -const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, filterModelOptions } = await jiti.import("./ChatInput.tsx"); +const { ChatInput, ModelErrorBanner, ModelScopeWarningBanner, filterModelOptions, getUserMessageText, getUserMessageDraftImages } = await jiti.import("./ChatInput.tsx"); const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx"); test("renders the upstream model error", () => { @@ -75,6 +75,34 @@ test("filters model options by name and id", () => { assert.equal(filterModelOptions(options, " "), options); }); +test("restores text and base64 images when editing a user message", () => { + const message = { + role: "user", + content: [ + { type: "text", text: "Review this image @src/example.ts " }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AQID" } }, + ], + }; + + assert.equal(getUserMessageText(message), "Review this image @src/example.ts "); + assert.deepEqual(getUserMessageDraftImages(message), [ + { data: "AQID", mimeType: "image/png" }, + ]); +}); + +test("restores legacy flat image entries when editing a user message", () => { + const message = { + role: "user", + content: [ + { type: "image", data: "AQID", mimeType: "image/jpeg" }, + ], + }; + + assert.deepEqual(getUserMessageDraftImages(message), [ + { data: "AQID", mimeType: "image/jpeg" }, + ]); +}); + test("renders compact errors above the input as a wrapping alert", () => { const error = "Compaction failed: OpenAI API error (403): request forbidden"; const html = renderToStaticMarkup( diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bd..a65a948a 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -3,6 +3,7 @@ import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; import type { SkillsResponse } from "@/lib/api-types"; +import type { TextContent, UserMessage } from "@/lib/types"; import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store"; import { MAX_ATTACHED_IMAGE_BYTES, @@ -74,6 +75,7 @@ interface Props { export interface ChatInputHandle { insertText: (text: string) => void; insertIfEmpty: (text: string) => void; + replaceMessage: (message: UserMessage) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; } @@ -211,6 +213,30 @@ function draftImagesToAttachedImages(images: ChatDraftImage[] | undefined): Atta .map(draftImageToAttachedImage); } +export function getUserMessageText(message: UserMessage): string { + if (typeof message.content === "string") return message.content; + return message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +export function getUserMessageDraftImages(message: UserMessage): ChatDraftImage[] { + if (typeof message.content === "string") return []; + return message.content.flatMap((block) => { + if (block.type !== "image") return []; + + // Support both the current nested image format and older flat pi-ai entries. + const flat = block as unknown as { data?: unknown; mimeType?: unknown }; + const data = block.source?.type === "base64" ? block.source.data : flat.data; + const mimeType = block.source?.type === "base64" ? block.source.media_type : flat.mimeType; + if (typeof data !== "string" || typeof mimeType !== "string") return []; + + const image = { data, mimeType }; + return isBase64ImageWithinLimits(image) ? [image] : []; + }); +} + function revokeImagePreview(image: AttachedImage): void { if (image.previewUrl.startsWith("blob:")) { URL.revokeObjectURL(image.previewUrl); @@ -393,6 +419,25 @@ export const ChatInput = forwardRef(function ChatInput({ ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, + replaceMessage(message: UserMessage) { + const ta = textareaRef.current; + const current = ta ? ta.value : value; + if (current.trim() || attachedImagesRef.current.length > 0) return; + + setValue(getUserMessageText(message)); + setAtQuery(null); + setHistoryMenuOpen(false); + setAttachedImages((prev) => { + prev.forEach(revokeImagePreview); + return draftImagesToAttachedImages(getUserMessageDraftImages(message)); + }); + requestAnimationFrame(() => { + if (!ta) return; + ta.focus(); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + }); + }, prependText(text: string) { if (!text.trim()) return; const ta = textareaRef.current; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf..909dd046 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -1,7 +1,7 @@ "use client"; import { registerAbortHandler } from "@/hooks/useKeyboardShortcuts"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage } from "@/lib/types"; +import type { AgentMessage, AssistantContentBlock, AssistantMessage, BashExecutionMessage, CustomMessage, ExtensionUiRequest, SessionInfo, SessionTreeNode, ToolResultMessage, UserMessage } from "@/lib/types"; import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi"; import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display"; @@ -192,8 +192,8 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate }, [onAgentEnd]); // 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染 - const handleEditContent = useCallback((content: string) => { - chatInputRef?.current?.insertIfEmpty(content); + const handleEditContent = useCallback((message: UserMessage) => { + chatInputRef?.current?.replaceMessage(message); }, [chatInputRef]); const { diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d73bee37..b86cfdfb 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -65,7 +65,7 @@ interface Props { forking?: boolean; onNavigate?: (entryId: string) => void; prevAssistantEntryId?: string; - onEditContent?: (content: string) => void; + onEditContent?: (message: UserMessage) => void; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; @@ -146,7 +146,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o forking?: boolean; onNavigate?: (entryId: string) => void; prevAssistantEntryId?: string; - onEditContent?: (content: string) => void; + onEditContent?: (message: UserMessage) => void; }) { const { t } = useI18n(); const [hovered, setHovered] = useState(false); @@ -278,7 +278,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o }}> {canNavigate && (