Repository: CherryHQ/cherry-studio
Pull request #5901: refactor: implement message editing and resend functionality 
Author: login: DeJeune
Labels: name: Models
description: Categorizes an issue or PR as relevant to SIG LLM
Created: 2025-05-12T07:22:41Z | Merged: 2025-05-19T07:38:00Z

<!-- Template from https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Consider creating this PR as draft: https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
-->

### What this PR does

Before this PR:

After this PR:

<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*: -->

Fixes #

### Why we need it and why it was done in this way

The following tradeoffs were made:

The following alternatives were considered:

Links to places where the discussion took place: <!-- optional: slack, other GH issue, mailinglist, ... -->

### Breaking changes

<!-- optional -->

If this PR introduces breaking changes, please describe the changes and the impact on users.

### Special notes for your reviewer

<!-- optional -->

### Checklist

This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future contributors
- [x] Code: [Write code that humans can understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans) and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [ ] Refactor: You have [left the code cleaner than you found it (Boy Scout Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and addressed if required
- [x] Documentation: A [user-guide update](https://docs.cherry-ai.com) was considered and is present (link) or not required. You want a user-guide update if it's a user facing feature.

### Release note

<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR requires additional action from users switching to the new release, include the string "action required".
2. If no release note is required, just write "NONE".
-->

```release-note

```



Diff:
diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts
index f847a408262..70b9cd70b06 100644
--- a/src/renderer/__tests__/setup.ts
+++ b/src/renderer/__tests__/setup.ts
@@ -18,3 +18,32 @@ vi.mock('electron-log/renderer', () => {
     }
   }
 })
+
+vi.stubGlobal('window', {
+  electron: {
+    ipcRenderer: {
+      on: vi.fn(), // Mocking ipcRenderer.on
+      send: vi.fn() // Mocking ipcRenderer.send
+    }
+  },
+  api: {
+    file: {
+      read: vi.fn().mockResolvedValue('[]'), // Mock file.read to return an empty array (you can customize this)
+      writeWithId: vi.fn().mockResolvedValue(undefined) // Mock file.writeWithId to do nothing
+    }
+  }
+})
+
+vi.mock('axios', () => ({
+  default: {
+    get: vi.fn().mockResolvedValue({ data: {} }), // Mocking axios GET request
+    post: vi.fn().mockResolvedValue({ data: {} }) // Mocking axios POST request
+    // You can add other axios methods like put, delete etc. as needed
+  }
+}))
+
+vi.stubGlobal('window', {
+  ...global.window, // Copy other global properties
+  addEventListener: vi.fn(), // Mock addEventListener
+  removeEventListener: vi.fn() // You can also mock removeEventListener if needed
+})
diff --git a/src/renderer/src/context/MessageEditingContext.tsx b/src/renderer/src/context/MessageEditingContext.tsx
new file mode 100644
index 00000000000..5c864908b66
--- /dev/null
+++ b/src/renderer/src/context/MessageEditingContext.tsx
@@ -0,0 +1,33 @@
+import { createContext, ReactNode, use, useState } from 'react'
+
+interface MessageEditingContextType {
+  editingMessageId: string | null
+  startEditing: (messageId: string) => void
+  stopEditing: () => void
+}
+
+const MessageEditingContext = createContext<MessageEditingContextType | null>(null)
+
+export function MessageEditingProvider({ children }: { children: ReactNode }) {
+  const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
+
+  const startEditing = (messageId: string) => {
+    setEditingMessageId(messageId)
+  }
+
+  const stopEditing = () => {
+    setEditingMessageId(null)
+  }
+
+  return (
+    <MessageEditingContext value={{ editingMessageId, startEditing, stopEditing }}>{children}</MessageEditingContext>
+  )
+}
+
+export function useMessageEditing() {
+  const context = use(MessageEditingContext)
+  if (!context) {
+    throw new Error('useMessageEditing must be used within a MessageEditingProvider')
+  }
+  return context
+}
diff --git a/src/renderer/src/hooks/useMCPServers.ts b/src/renderer/src/hooks/useMCPServers.ts
index 49fde29f60b..bc95bb2ff6c 100644
--- a/src/renderer/src/hooks/useMCPServers.ts
+++ b/src/renderer/src/hooks/useMCPServers.ts
@@ -4,13 +4,11 @@ import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@
 import { MCPServer } from '@renderer/types'
 import { IpcChannel } from '@shared/IpcChannel'
 
-const ipcRenderer = window.electron.ipcRenderer
-
 // Listen for server changes from main process
-ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
+window.electron.ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
   store.dispatch(setMCPServers(servers))
 })
-ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
+window.electron.ipcRenderer.on(IpcChannel.Mcp_AddServer, (_event, server: MCPServer) => {
   store.dispatch(addMCPServer(server))
 })
 
diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts
index c0c6112df37..54188934630 100644
--- a/src/renderer/src/hooks/useMessageOperations.ts
+++ b/src/renderer/src/hooks/useMessageOperations.ts
@@ -3,7 +3,7 @@ import Logger from '@renderer/config/logger'
 import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
 import { estimateUserPromptUsage } from '@renderer/services/TokenService'
 import store, { type RootState, useAppDispatch, useAppSelector } from '@renderer/store'
-import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
+import { updateOneBlock } from '@renderer/store/messageBlock'
 import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
 import {
   appendAssistantResponseThunk,
@@ -13,6 +13,7 @@ import {
   deleteSingleMessageThunk,
   initiateTranslationThunk,
   regenerateAssistantResponseThunk,
+  removeBlocksThunk,
   resendMessageThunk,
   resendUserMessageWithEditThunk,
   updateMessageAndBlocksThunk,
@@ -22,21 +23,8 @@ import type { Assistant, Model, Topic } from '@renderer/types'
 import type { Message, MessageBlock } from '@renderer/types/newMessage'
 import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
 import { abortCompletion } from '@renderer/utils/abortController'
-import { findFileBlocks } from '@renderer/utils/messageUtils/find'
 import { useCallback } from 'react'
 
-const findMainTextBlockId = (message: Message): string | undefined => {
-  if (!message || !message.blocks) return undefined
-  const state = store.getState()
-  for (const blockId of message.blocks) {
-    const block = messageBlocksSelectors.selectById(state, String(blockId))
-    if (block && block.type === MessageBlockType.MAIN_TEXT) {
-      return block.id
-    }
-  }
-  return undefined
-}
-
 const selectMessagesState = (state: RootState) => state.messages
 
 export const selectNewTopicLoading = createSelector(
@@ -113,36 +101,6 @@ export function useMessageOperations(topic: Topic) {
     [dispatch, topic.id]
   )
 
-  /**
-   * 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
-   * Dispatches resendUserMessageWithEditThunk.
-   */
-  const resendUserMessageWithEdit = useCallback(
-    async (message: Message, editedContent: string, assistant: Assistant) => {
-      const mainTextBlockId = findMainTextBlockId(message)
-      if (!mainTextBlockId) {
-        console.error('Cannot resend edited message: Main text block not found.')
-        return
-      }
-
-      const files = findFileBlocks(message).map((block) => block.file)
-
-      const usage = await estimateUserPromptUsage({ content: editedContent, files })
-      const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
-        id: message.id,
-        updatedAt: new Date().toISOString(),
-        usage
-      }
-
-      await dispatch(
-        newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
-      )
-      // 对于message的修改会在下面的thunk中保存
-      await dispatch(resendUserMessageWithEditThunk(topic.id, message, mainTextBlockId, editedContent, assistant))
-    },
-    [dispatch, topic.id]
-  )
-
   /**
    * 清除当前或指定主题的所有消息。 / Clears all messages for the current or specified topic.
    * Dispatches clearTopicMessagesThunk.
@@ -309,29 +267,127 @@ export function useMessageOperations(topic: Topic) {
   )
 
   /**
-   * Updates properties of specific message blocks (e.g., content).
-   * Uses the generalized thunk for persistence.
+   * Updates message blocks by comparing original and edited blocks.
+   * Handles adding, updating, and removing blocks in a single operation.
+   * @param messageId The ID of the message to update
+   * @param editedBlocks The complete set of blocks after editing
    */
   const editMessageBlocks = useCallback(
-    async (messageId: string, updates: Partial<MessageBlock>) => {
+    async (messageId: string, editedBlocks: MessageBlock[]) => {
       if (!topic?.id) {
         console.error('[editMessageBlocks] Topic prop is not valid.')
         return
       }
 
-      const blockUpdatesListProcessed = {
-        updatedAt: new Date().toISOString(),
-        ...updates
+      try {
+        // 1. Get the current state of the message and its blocks
+        const state = store.getState()
+        const message = state.messages.entities[messageId]
+        if (!message) {
+          console.error('[editMessageBlocks] Message not found:', messageId)
+          return
+        }
+
+        // 2. Get all original blocks
+        const originalBlocks = message.blocks
+          ? (message.blocks
+              .map((blockId) => state.messageBlocks.entities[blockId])
+              .filter((block) => block !== undefined) as MessageBlock[])
+          : []
+
+        // 3. Create sets for efficient comparison
+        const originalBlockIds = new Set(originalBlocks.map((block) => block.id))
+        const editedBlockIds = new Set(editedBlocks.map((block) => block.id))
+
+        // 4. Identify blocks to remove, update, and add
+        const blockIdsToRemove = originalBlocks
+          .filter((block) => !editedBlockIds.has(block.id))
+          .map((block) => block.id)
+
+        const blocksToUpdate = editedBlocks
+          .filter((block) => originalBlockIds.has(block.id))
+          .map((block) => ({
+            ...block,
+            updatedAt: new Date().toISOString()
+          }))
+
+        const blocksToAdd = editedBlocks
+          .filter((block) => !originalBlockIds.has(block.id))
+          .map((block) => ({
+            ...block,
+            updatedAt: new Date().toISOString()
+          }))
+
+        // 5. Prepare message update with new block IDs
+        const updatedBlockIds = editedBlocks.map((block) => block.id)
+        const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
+          id: messageId,
+          updatedAt: new Date().toISOString(),
+          blocks: updatedBlockIds
+        }
+
+        // 6. Log operations for debugging
+        console.log('[editMessageBlocks] Operations:', {
+          blocksToRemove: blockIdsToRemove.length,
+          blocksToUpdate: blocksToUpdate.length,
+          blocksToAdd: blocksToAdd.length
+        })
+
+        // 7. Update Redux state and database
+        // First update message and add/update blocks
+        if (blocksToAdd.length > 0) {
+          await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToAdd))
+        }
+
+        if (blocksToUpdate.length > 0) {
+          await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, blocksToUpdate))
+        }
+
+        // Then remove blocks if needed
+        if (blockIdsToRemove.length > 0) {
+          await dispatch(removeBlocksThunk(topic.id, messageId, blockIdsToRemove))
+        }
+      } catch (error) {
+        console.error('[editMessageBlocks] Failed to update message blocks:', error)
       }
+    },
+    [dispatch, topic?.id]
+  )
+
+  /**
+   * 在用户消息的主文本块被编辑后重新发送该消息。 / Resends a user message after its main text block has been edited.
+   * Dispatches resendUserMessageWithEditThunk.
+   */
+  const resendUserMessageWithEdit = useCallback(
+    async (message: Message, editedBlocks: MessageBlock[], assistant: Assistant) => {
+      await editMessageBlocks(message.id, editedBlocks)
 
+      const mainTextBlock = editedBlocks.find((block) => block.type === MessageBlockType.MAIN_TEXT)
+      if (!mainTextBlock) {
+        console.error('[resendUserMessageWithEdit] Main text block not found in edited blocks')
+        return
+      }
+
+      const fileBlocks = editedBlocks.filter(
+        (block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE
+      )
+
+      const files = fileBlocks.map((block) => block.file).filter((file) => file !== undefined)
+
+      const usage = await estimateUserPromptUsage({ content: mainTextBlock.content, files })
       const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
-        id: messageId,
-        updatedAt: new Date().toISOString()
+        id: message.id,
+        updatedAt: new Date().toISOString(),
+        usage
       }
 
-      await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed]))
+      await dispatch(
+        newMessagesActions.updateMessage({ topicId: topic.id, messageId: message.id, updates: messageUpdates })
+      )
+      // 对于message的修改会在下面的thunk中保存
+      await dispatch(resendUserMessageWithEditThunk(topic.id, message, assistant))
     },
-    [dispatch, topic.id]
+    [dispatch, editMessageBlocks, topic.id]
   )
 
   /**
diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx
index 956403e06d5..4f69ca78156 100644
--- a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx
+++ b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx
@@ -32,7 +32,55 @@ function truncateFileName(name: string, maxLength: number = MAX_FILENAME_DISPLAY
   return name.slice(0, maxLength - 3) + '...'
 }
 
-const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
+export const getFileIcon = (type?: string) => {
+  if (!type) return <FileUnknownFilled />
+
+  const ext = type.toLowerCase()
+
+  if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
+    return <FileImageFilled />
+  }
+
+  if (['.doc', '.docx'].includes(ext)) {
+    return <FileWordFilled />
+  }
+  if (['.xls', '.xlsx'].includes(ext)) {
+    return <FileExcelFilled />
+  }
+  if (['.ppt', '.pptx'].includes(ext)) {
+    return <FilePptFilled />
+  }
+  if (ext === '.pdf') {
+    return <FilePdfFilled />
+  }
+  if (['.md', '.markdown'].includes(ext)) {
+    return <FileMarkdownFilled />
+  }
+
+  if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
+    return <FileZipFilled />
+  }
+
+  if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
+    return <FileTextFilled />
+  }
+
+  if (['.url'].includes(ext)) {
+    return <LinkOutlined />
+  }
+
+  if (['.sitemap'].includes(ext)) {
+    return <GlobalOutlined />
+  }
+
+  if (['.folder'].includes(ext)) {
+    return <FolderOpenFilled />
+  }
+
+  return <FileUnknownFilled />
+}
+
+export const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
   const [visible, setVisible] = useState<boolean>(false)
   const isImage = (ext: string) => {
     return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext)
@@ -85,54 +133,6 @@ const FileNameRender: FC<{ file: FileType }> = ({ file }) => {
 }
 
 const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
-  const getFileIcon = (type?: string) => {
-    if (!type) return <FileUnknownFilled />
-
-    const ext = type.toLowerCase()
-
-    if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
-      return <FileImageFilled />
-    }
-
-    if (['.doc', '.docx'].includes(ext)) {
-      return <FileWordFilled />
-    }
-    if (['.xls', '.xlsx'].includes(ext)) {
-      return <FileExcelFilled />
-    }
-    if (['.ppt', '.pptx'].includes(ext)) {
-      return <FilePptFilled />
-    }
-    if (ext === '.pdf') {
-      return <FilePdfFilled />
-    }
-    if (['.md', '.markdown'].includes(ext)) {
-      return <FileMarkdownFilled />
-    }
-
-    if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
-      return <FileZipFilled />
-    }
-
-    if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) {
-      return <FileTextFilled />
-    }
-
-    if (['.url'].includes(ext)) {
-      return <LinkOutlined />
-    }
-
-    if (['.sitemap'].includes(ext)) {
-      return <GlobalOutlined />
-    }
-
-    if (['.folder'].includes(ext)) {
-      return <FolderOpenFilled />
-    }
-
-    return <FileUnknownFilled />
-  }
-
   if (isEmpty(files)) {
     return null
   }
diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
index 92bcaf5c6a9..f68f5fbbbf1 100644
--- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
+++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx
@@ -651,22 +651,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
     [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t, text]
   )
 
-  // 添加全局粘贴事件处理
-  useEffect(() => {
-    const handleGlobalPaste = (event: ClipboardEvent) => {
-      if (document.activeElement === textareaRef.current?.resizableTextArea?.textArea) {
-        return
-      }
-
-      onPaste(event)
-    }
-
-    document.addEventListener('paste', handleGlobalPaste)
-    return () => {
-      document.removeEventListener('paste', handleGlobalPaste)
-    }
-  }, [onPaste])
-
   const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
     e.preventDefault()
     e.stopPropagation()
diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx
index 2e79850a915..b1995a4c050 100644
--- a/src/renderer/src/pages/home/Messages/Message.tsx
+++ b/src/renderer/src/pages/home/Messages/Message.tsx
@@ -1,12 +1,14 @@
 import ContextMenu from '@renderer/components/ContextMenu'
+import { useMessageEditing } from '@renderer/context/MessageEditingContext'
 import { useAssistant } from '@renderer/hooks/useAssistant'
+import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
 import { useModel } from '@renderer/hooks/useModel'
 import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
 import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
 import { getMessageModelId } from '@renderer/services/MessagesService'
 import { getModelUniqId } from '@renderer/services/ModelService'
 import { Assistant, Topic } from '@renderer/types'
-import type { Message } from '@renderer/types/newMessage'
+import type { Message, MessageBlock } from '@renderer/types/newMessage'
 import { classNames } from '@renderer/utils'
 import { Divider } from 'antd'
 import React, { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useRef } from 'react'
@@ -14,6 +16,7 @@ import { useTranslation } from 'react-i18next'
 import styled from 'styled-components'
 
 import MessageContent from './MessageContent'
+import MessageEditor from './MessageEditor'
 import MessageErrorBoundary from './MessageErrorBoundary'
 import MessageHeader from './MessageHeader'
 import MessageMenubar from './MessageMenubar'
@@ -47,11 +50,54 @@ const MessageItem: FC<Props> = ({
   const model = useModel(getMessageModelId(message), message.model?.provider) || message.model
   const { isBubbleStyle } = useMessageStyle()
   const { showMessageDivider, messageFont, fontSize } = useSettings()
+  const { editMessageBlocks, resendUserMessageWithEdit } = useMessageOperations(topic)
   const messageContainerRef = useRef<HTMLDivElement>(null)
+  const { editingMessageId, stopEditing } = useMessageEditing()
+  const isEditing = editingMessageId === message.id
+
+  useEffect(() => {
+    if (isEditing && messageContainerRef.current) {
+      messageContainerRef.current.scrollIntoView({
+        behavior: 'smooth',
+        block: 'center'
+      })
+    }
+  }, [isEditing])
+
+  const handleEditSave = useCallback(
+    async (blocks: MessageBlock[]) => {
+      try {
+        console.log('after save blocks', blocks)
+        await editMessageBlocks(message.id, blocks)
+        stopEditing()
+      } catch (error) {
+        console.error('Failed to save message blocks:', error)
+      }
+    },
+    [message, editMessageBlocks, stopEditing]
+  )
+
+  const handleEditResend = useCallback(
+    async (blocks: MessageBlock[]) => {
+      try {
+        // 编辑后重新发送消息
+        console.log('after resend blocks', blocks)
+        await resendUserMessageWithEdit(message, blocks, assistant)
+        stopEditing()
+      } catch (error) {
+        console.error('Failed to resend message:', error)
+      }
+    },
+    [message, resendUserMessageWithEdit, assistant, stopEditing]
+  )
+
+  const handleEditCancel = useCallback(() => {
+    stopEditing()
+  }, [stopEditing])
 
   const isLastMessage = index === 0
   const isAssistantMessage = message.role === 'assistant'
-  const showMenubar = !isStreaming && !message.status.includes('ing')
+  const showMenubar = !isStreaming && !message.status.includes('ing') && !isEditing
 
   const messageBorder = showMessageDivider ? undefined : 'none'
   const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
@@ -114,9 +160,18 @@ const MessageItem: FC<Props> = ({
             background: messageBackground,
             overflowY: 'visible'
           }}>
-          <MessageErrorBoundary>
-            <MessageContent message={message} />
-          </MessageErrorBoundary>
+          {isEditing ? (
+            <MessageEditor
+              message={message}
+              onSave={handleEditSave}
+              onResend={handleEditResend}
+              onCancel={handleEditCancel}
+            />
+          ) : (
+            <MessageErrorBoundary>
+              <MessageContent message={message} />
+            </MessageErrorBoundary>
+          )}
           {showMenubar && (
             <MessageFooter
               className="MessageFooter"
diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx
index b01feb2be93..14be41e64d3 100644
--- a/src/renderer/src/pages/home/Messages/MessageContent.tsx
+++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx
@@ -20,17 +20,6 @@ const MessageContent: React.FC<Props> = ({ message }) => {
   )
 }
 
-// const SearchingContainer = styled.div`
-//   display: flex;
-//   flex-direction: row;
-//   align-items: center;
-//   background-color: var(--color-background-mute);
-//   padding: 10px;
-//   border-radius: 10px;
-//   margin-bottom: 10px;
-//   gap: 10px;
-// `
-
 const MentionTag = styled.span`
   color: var(--color-link);
 `
diff --git a/src/renderer/src/pages/home/Messages/MessageEditor.tsx b/src/renderer/src/pages/home/Messages/MessageEditor.tsx
new file mode 100644
index 00000000000..8b4cd326852
--- /dev/null
+++ b/src/renderer/src/pages/home/Messages/MessageEditor.tsx
@@ -0,0 +1,367 @@
+import CustomTag from '@renderer/components/CustomTag'
+import TranslateButton from '@renderer/components/TranslateButton'
+import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
+import { useAssistant } from '@renderer/hooks/useAssistant'
+import { useSettings } from '@renderer/hooks/useSettings'
+import FileManager from '@renderer/services/FileManager'
+import { FileType, FileTypes } from '@renderer/types'
+import { Message, MessageBlock, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
+import { classNames, getFileExtension } from '@renderer/utils'
+import { getFilesFromDropEvent } from '@renderer/utils/input'
+import { createFileBlock, createImageBlock } from '@renderer/utils/messageUtils/create'
+import { findAllBlocks } from '@renderer/utils/messageUtils/find'
+import { documentExts, imageExts, textExts } from '@shared/config/constant'
+import { Tooltip } from 'antd'
+import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
+import { Save, Send, X } from 'lucide-react'
+import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+import AttachmentButton, { AttachmentButtonRef } from '../Inputbar/AttachmentButton'
+import { FileNameRender, getFileIcon } from '../Inputbar/AttachmentPreview'
+import { ToolbarButton } from '../Inputbar/Inputbar'
+
+interface Props {
+  message: Message
+  onSave: (blocks: MessageBlock[]) => void
+  onResend: (blocks: MessageBlock[]) => void
+  onCancel: () => void
+}
+
+const MessageBlockEditor: FC<Props> = ({ message, onSave, onResend, onCancel }) => {
+  const allBlocks = findAllBlocks(message)
+  const [editedBlocks, setEditedBlocks] = useState<MessageBlock[]>(allBlocks)
+  const [files, setFiles] = useState<FileType[]>([])
+  const [isProcessing, setIsProcessing] = useState(false)
+  const [isFileDragging, setIsFileDragging] = useState(false)
+  const { assistant } = useAssistant(message.assistantId)
+  const model = assistant.model || assistant.defaultModel
+  const isVision = useMemo(() => isVisionModel(model), [model])
+  const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
+  const { pasteLongTextAsFile, pasteLongTextThreshold, fontSize } = useSettings()
+  const { t } = useTranslation()
+  const textareaRef = useRef<TextAreaRef>(null)
+  const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
+
+  useEffect(() => {
+    setTimeout(() => {
+      resizeTextArea()
+      if (textareaRef.current) {
+        textareaRef.current.focus({ cursor: 'end' })
+      }
+    }, 0)
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [])
+
+  const resizeTextArea = useCallback(() => {
+    const textArea = textareaRef.current?.resizableTextArea?.textArea
+    if (textArea) {
+      textArea.style.height = 'auto'
+      textArea.style.height = textArea?.scrollHeight > 400 ? '400px' : `${textArea?.scrollHeight}px`
+    }
+  }, [])
+
+  const handleTextChange = (blockId: string, content: string) => {
+    setEditedBlocks((prev) => prev.map((block) => (block.id === blockId ? { ...block, content } : block)))
+  }
+
+  const onTranslated = (translatedText: string) => {
+    const mainTextBlock = editedBlocks.find((b) => b.type === MessageBlockType.MAIN_TEXT)
+    if (mainTextBlock) {
+      handleTextChange(mainTextBlock.id, translatedText)
+    }
+    setTimeout(() => resizeTextArea(), 0)
+  }
+
+  // 处理文件删除
+  const handleFileRemove = async (blockId: string) => {
+    setEditedBlocks((prev) => prev.filter((block) => block.id !== blockId))
+  }
+
+  // 处理拖拽上传
+  const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsFileDragging(false)
+
+    const files = await getFilesFromDropEvent(e).catch((err) => {
+      console.error('[src/renderer/src/pages/home/Inputbar/Inputbar.tsx] handleDrop:', err)
+      return null
+    })
+    if (files) {
+      let supportedFiles = 0
+      files.forEach((file) => {
+        if (supportExts.includes(getFileExtension(file.path))) {
+          setFiles((prevFiles) => [...prevFiles, file])
+          supportedFiles++
+        }
+      })
+
+      // 如果有文件，但都不支持
+      if (files.length > 0 && supportedFiles === 0) {
+        window.message.info({
+          key: 'file_not_supported',
+          content: t('chat.input.file_not_supported')
+        })
+      }
+    }
+  }
+
+  const handleClick = async (withResend?: boolean) => {
+    if (isProcessing) return
+    setIsProcessing(true)
+    const updatedBlocks = [...editedBlocks]
+    if (files && files.length) {
+      const uploadedFiles = await FileManager.uploadFiles(files)
+      uploadedFiles.forEach((file) => {
+        if (file.type === FileTypes.IMAGE) {
+          const imgBlock = createImageBlock(message.id, { file, status: MessageBlockStatus.SUCCESS })
+          updatedBlocks.push(imgBlock)
+        } else {
+          const fileBlock = createFileBlock(message.id, file, { status: MessageBlockStatus.SUCCESS })
+          updatedBlocks.push(fileBlock)
+        }
+      })
+    }
+    if (withResend) {
+      onResend(updatedBlocks)
+    } else {
+      onSave(updatedBlocks)
+    }
+  }
+
+  const onPaste = useCallback(
+    async (event: ClipboardEvent) => {
+      // 1. 文本粘贴
+      const clipboardText = event.clipboardData?.getData('text')
+      if (clipboardText) {
+        if (pasteLongTextAsFile && clipboardText.length > pasteLongTextThreshold) {
+          // 长文本直接转文件，阻止默认粘贴
+          event.preventDefault()
+
+          const tempFilePath = await window.api.file.create('pasted_text.txt')
+          await window.api.file.write(tempFilePath, clipboardText)
+          const selectedFile = await window.api.file.get(tempFilePath)
+          selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
+          setTimeout(() => resizeTextArea(), 50)
+          return
+        }
+        // 短文本走默认粘贴行为，直接返回
+        return
+      }
+
+      // 2. 文件/图片粘贴
+      if (event.clipboardData?.files && event.clipboardData.files.length > 0) {
+        event.preventDefault()
+        for (const file of event.clipboardData.files) {
+          const filePath = window.api.file.getPathForFile(file)
+          if (!filePath) {
+            // 图像生成也支持图像编辑
+            if (file.type.startsWith('image/') && (isVisionModel(model) || isGenerateImageModel(model))) {
+              const tempFilePath = await window.api.file.create(file.name)
+              const arrayBuffer = await file.arrayBuffer()
+              const uint8Array = new Uint8Array(arrayBuffer)
+              await window.api.file.write(tempFilePath, uint8Array)
+              const selectedFile = await window.api.file.get(tempFilePath)
+              selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
+              break
+            } else {
+              window.message.info({
+                key: 'file_not_supported',
+                content: t('chat.input.file_not_supported')
+              })
+            }
+          }
+
+          if (supportExts.includes(getFileExtension(filePath))) {
+            const selectedFile = await window.api.file.get(filePath)
+            selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
+          } else {
+            window.message.info({
+              key: 'file_not_supported',
+              content: t('chat.input.file_not_supported')
+            })
+          }
+        }
+        return
+      }
+
+      // 短文本走默认粘贴行为
+    },
+    [model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportExts, t]
+  )
+
+  const autoResizeTextArea = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+    const textarea = e.target
+    textarea.style.height = 'auto'
+    textarea.style.height = `${textarea.scrollHeight}px`
+  }
+
+  return (
+    <>
+      <EditorContainer onDragOver={(e) => e.preventDefault()} onDrop={handleDrop}>
+        {editedBlocks
+          .filter((block) => block.type === MessageBlockType.MAIN_TEXT)
+          .map((block) => (
+            <Textarea
+              className={classNames(isFileDragging && 'file-dragging')}
+              key={block.id}
+              ref={textareaRef}
+              variant="borderless"
+              value={block.content}
+              onChange={(e) => {
+                handleTextChange(block.id, e.target.value)
+                autoResizeTextArea(e)
+              }}
+              autoFocus
+              contextMenu="true"
+              spellCheck={false}
+              onPaste={(e) => onPaste(e.nativeEvent)}
+              style={{
+                fontSize,
+                padding: '0px 15px 8px 15px'
+              }}>
+              <TranslateButton onTranslated={onTranslated} />
+            </Textarea>
+          ))}
+        {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) ||
+          files.length > 0) && (
+          <FileBlocksContainer>
+            {editedBlocks
+              .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE)
+              .map(
+                (block) =>
+                  block.file && (
+                    <CustomTag
+                      key={block.id}
+                      icon={getFileIcon(block.file.ext)}
+                      color="#37a5aa"
+                      closable
+                      onClose={() => handleFileRemove(block.id)}>
+                      <FileNameRender file={block.file} />
+                    </CustomTag>
+                  )
+              )}
+
+            {files.map((file) => (
+              <CustomTag
+                key={file.id}
+                icon={getFileIcon(file.ext)}
+                color="#37a5aa"
+                closable
+                onClose={() => setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}>
+                <FileNameRender file={file} />
+              </CustomTag>
+            ))}
+          </FileBlocksContainer>
+        )}
+
+        <ActionBar>
+          <ActionBarLeft>
+            <AttachmentButton
+              ref={attachmentButtonRef}
+              model={model}
+              files={files}
+              setFiles={setFiles}
+              ToolbarButton={ToolbarButton}
+            />
+          </ActionBarLeft>
+          <ActionBarMiddle />
+          <ActionBarRight>
+            <Tooltip title={t('common.cancel')}>
+              <ToolbarButton type="text" onClick={onCancel}>
+                <X size={16} />
+              </ToolbarButton>
+            </Tooltip>
+            <Tooltip title={t('common.save')}>
+              <ToolbarButton type="text" onClick={() => handleClick()}>
+                <Save size={16} />
+              </ToolbarButton>
+            </Tooltip>
+            <Tooltip title={t('chat.resend')}>
+              <ToolbarButton type="text" onClick={() => handleClick(true)}>
+                <Send size={16} />
+              </ToolbarButton>
+            </Tooltip>
+          </ActionBarRight>
+        </ActionBar>
+      </EditorContainer>
+    </>
+  )
+}
+
+const FileBlocksContainer = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  padding: 0 15px;
+  margin: 8px 0;
+  background: transplant;
+  border-radius: 4px;
+`
+
+const EditorContainer = styled.div`
+  padding: 8px 0;
+  border: 1px solid var(--color-border);
+  transition: all 0.2s ease;
+  border-radius: 15px;
+  margin-top: 0;
+  background-color: var(--color-background-opacity);
+
+  &.file-dragging {
+    border: 2px dashed #2ecc71;
+
+    &::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background-color: rgba(46, 204, 113, 0.03);
+      border-radius: 14px;
+      z-index: 5;
+      pointer-events: none;
+    }
+  }
+`
+
+const Textarea = styled(TextArea)`
+  padding: 0;
+  border-radius: 0;
+  display: flex;
+  flex: 1;
+  font-family: Ubuntu;
+  resize: none !important;
+  overflow: auto;
+  width: 100%;
+  box-sizing: border-box;
+  &.ant-input {
+    line-height: 1.4;
+  }
+`
+
+const ActionBar = styled.div`
+  display: flex;
+  padding: 0 8px;
+  justify-content: space-between;
+  margin-top: 8px;
+`
+
+const ActionBarLeft = styled.div`
+  display: flex;
+  align-items: center;
+`
+
+const ActionBarMiddle = styled.div`
+  flex: 1;
+`
+
+const ActionBarRight = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 8px;
+`
+
+export default memo(MessageBlockEditor)
diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx
index b1c1a5d5778..3d8c1de1fd6 100644
--- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx
+++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx
@@ -1,4 +1,5 @@
 import Scrollbar from '@renderer/components/Scrollbar'
+import { MessageEditingProvider } from '@renderer/context/MessageEditingContext'
 import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
 import { useSettings } from '@renderer/hooks/useSettings'
 import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@@ -213,34 +214,36 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
   )
 
   return (
-    <GroupContainer
-      id={`message-group-${messages[0].askId}`}
-      $isGrouped={isGrouped}
-      $layout={multiModelMessageStyle}
-      className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
-      <GridContainer
-        $count={messageLength}
+    <MessageEditingProvider>
+      <GroupContainer
+        id={`message-group-${messages[0].askId}`}
+        $isGrouped={isGrouped}
         $layout={multiModelMessageStyle}
-        $gridColumns={gridColumns}
-        className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
-        {messages.map(renderMessage)}
-      </GridContainer>
-      {isGrouped && (
-        <MessageGroupMenuBar
-          multiModelMessageStyle={multiModelMessageStyle}
-          setMultiModelMessageStyle={(style) => {
-            setMultiModelMessageStyle(style)
-            messages.forEach((message) => {
-              editMessage(message.id, { multiModelMessageStyle: style })
-            })
-          }}
-          messages={messages}
-          selectMessageId={selectedMessageId}
-          setSelectedMessage={setSelectedMessage}
-          topic={topic}
-        />
-      )}
-    </GroupContainer>
+        className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
+        <GridContainer
+          $count={messageLength}
+          $layout={multiModelMessageStyle}
+          $gridColumns={gridColumns}
+          className={classNames([isGrouped && 'group-grid-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
+          {messages.map(renderMessage)}
+        </GridContainer>
+        {isGrouped && (
+          <MessageGroupMenuBar
+            multiModelMessageStyle={multiModelMessageStyle}
+            setMultiModelMessageStyle={(style) => {
+              setMultiModelMessageStyle(style)
+              messages.forEach((message) => {
+                editMessage(message.id, { multiModelMessageStyle: style })
+              })
+            }}
+            messages={messages}
+            selectMessageId={selectedMessageId}
+            setSelectedMessage={setSelectedMessage}
+            topic={topic}
+          />
+        )}
+      </GroupContainer>
+    </MessageEditingProvider>
   )
 }
 
diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx
index 7a3ca793ff6..eac6e4c9d2d 100644
--- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx
+++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx
@@ -1,8 +1,8 @@
 import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } from '@ant-design/icons'
 import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
 import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
-import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
 import { TranslateLanguageOptions } from '@renderer/config/translate'
+import { useMessageEditing } from '@renderer/context/MessageEditingContext'
 import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
 import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
 import { getMessageTitle } from '@renderer/services/MessagesService'
@@ -23,13 +23,8 @@ import {
 } from '@renderer/utils/export'
 // import { withMessageThought } from '@renderer/utils/formats'
 import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
-import {
-  findImageBlocks,
-  findMainTextBlocks,
-  findTranslationBlocks,
-  getMainTextContent
-} from '@renderer/utils/messageUtils/find'
-import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
+import { findMainTextBlocks, findTranslationBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
+import { Dropdown, Popconfirm, Tooltip } from 'antd'
 import dayjs from 'dayjs'
 import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react'
 import { FilePenLine } from 'lucide-react'
@@ -65,10 +60,8 @@ const MessageMenubar: FC<Props> = (props) => {
     deleteMessage,
     resendMessage,
     regenerateAssistantMessage,
-    resendUserMessageWithEdit,
     getTranslationUpdater,
     appendAssistantResponse,
-    editMessageBlocks,
     removeMessageBlock
   } = useMessageOperations(topic)
   const loading = useTopicLoading(topic)
@@ -119,92 +112,11 @@ const MessageMenubar: FC<Props> = (props) => {
     [assistant, loading, message, resendMessage]
   )
 
-  const onEdit = useCallback(async () => {
-    // 禁用了助手消息的编辑，现在都是用户消息的编辑
-    let resendMessage = false
-
-    let textToEdit = ''
-
-    const imageBlocks = findImageBlocks(message)
-    // 如果是包含图片的消息，添加图片的 markdown 格式
-    if (imageBlocks.length > 0) {
-      const imageMarkdown = imageBlocks
-        .map((image, index) => `![image-${index}](file://${image?.file?.path})`)
-        .join('\n')
-      textToEdit = `${textToEdit}\n\n${imageMarkdown}`
-    }
-    textToEdit += mainTextContent
-    // if (message.role === 'assistant' && message.model && isReasoningModel(message.model)) {
-    //   //   const processedMessage = withMessageThought(clone(message))
-    //   //   textToEdit = getMainTextContent(processedMessage)
-    //   textToEdit = mainTextContent
-    // }
+  const { startEditing } = useMessageEditing()
 
-    const editedText = await TextEditPopup.show({
-      text: textToEdit,
-      children: (props) => {
-        const onPress = () => {
-          props.onOk?.()
-          resendMessage = true
-        }
-        return message.role === 'user' ? (
-          <ReSendButton
-            icon={<i className="iconfont icon-ic_send" style={{ color: 'var(--color-primary)' }} />}
-            onClick={onPress}>
-            {t('chat.resend')}
-          </ReSendButton>
-        ) : null
-      }
-    })
-
-    if (editedText && editedText !== textToEdit) {
-      // 解析编辑后的文本，提取图片 URL
-      // const imageRegex = /!\[image-\d+\]\((.*?)\)/g
-      // const imageUrls: string[] = []
-      // let match
-      // let content = editedText
-      // TODO 按理说图片应该走上传，不应该在这改
-      // while ((match = imageRegex.exec(editedText)) !== null) {
-      //   imageUrls.push(match[1])
-      //   content = content.replace(match[0], '')
-      // }
-      if (resendMessage) {
-        resendUserMessageWithEdit(message, editedText, assistant)
-      } else {
-        editMessageBlocks(message.id, { id: findMainTextBlocks(message)[0].id, content: editedText })
-      }
-      // // 更新消息内容，保留图片信息
-      // await editMessage(message.id, {
-      //   content: content.trim(),
-      //   metadata: {
-      //     ...message.metadata,
-      //     generateImage:
-      //       imageUrls.length > 0
-      //         ? {
-      //             type: 'url',
-      //             images: imageUrls
-      //           }
-      //         : undefined
-      //   }
-      // })
-
-      // resendMessage &&
-      //   handleResendUserMessage({
-      //     ...message,
-      //     content: content.trim(),
-      //     metadata: {
-      //       ...message.metadata,
-      //       generateImage:
-      //         imageUrls.length > 0
-      //           ? {
-      //               type: 'url',
-      //               images: imageUrls
-      //             }
-      //           : undefined
-      //     }
-      //   })
-    }
-  }, [resendUserMessageWithEdit, editMessageBlocks, assistant, mainTextContent, message, t])
+  const onEdit = useCallback(async () => {
+    startEditing(message.id)
+  }, [message.id, startEditing])
 
   const handleTranslate = useCallback(
     async (language: string) => {
@@ -584,10 +496,10 @@ const ActionButton = styled.div`
   }
 `
 
-const ReSendButton = styled(Button)`
-  position: absolute;
-  top: 10px;
-  left: 0;
-`
+// const ReSendButton = styled(Button)`
+//   position: absolute;
+//   top: 10px;
+//   left: 0;
+// `
 
 export default memo(MessageMenubar)
diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts
index 78f2876212e..099bc005da3 100644
--- a/src/renderer/src/store/thunk/messageThunk.ts
+++ b/src/renderer/src/store/thunk/messageThunk.ts
@@ -971,22 +971,7 @@ export const resendMessageThunk =
  * of its associated assistant responses using resendMessageThunk.
  */
 export const resendUserMessageWithEditThunk =
-  (
-    topicId: Topic['id'],
-    originalMessage: Message,
-    mainTextBlockId: string,
-    editedContent: string,
-    assistant: Assistant
-  ) =>
-  async (dispatch: AppDispatch) => {
-    const blockChanges = {
-      content: editedContent,
-      updatedAt: new Date().toISOString()
-    }
-    // Update block in Redux and DB
-    dispatch(updateOneBlock({ id: mainTextBlockId, changes: blockChanges }))
-    await db.message_blocks.update(mainTextBlockId, blockChanges)
-
+  (topicId: Topic['id'], originalMessage: Message, assistant: Assistant) => async (dispatch: AppDispatch) => {
     // Trigger the regeneration logic for associated assistant messages
     dispatch(resendMessageThunk(topicId, originalMessage, assistant))
   }
@@ -1411,14 +1396,14 @@ export const updateMessageAndBlocksThunk =
     topicId: string,
     // Allow messageUpdates to be optional or just contain the ID if only blocks are updated
     messageUpdates: (Partial<Message> & Pick<Message, 'id'>) | null, // ID is always required for context
-    blockUpdatesList: Partial<MessageBlock>[] // Block updates remain required for this thunk's purpose
+    blockUpdatesList: MessageBlock[] // Block updates remain required for this thunk's purpose
   ) =>
-  async (dispatch: AppDispatch): Promise<boolean> => {
+  async (dispatch: AppDispatch): Promise<void> => {
     const messageId = messageUpdates?.id
 
     if (messageUpdates && !messageId) {
-      console.error('[updateMessageAndBlocksThunk] Message ID is required.')
-      return false
+      console.error('[updateMessageAndUpdateBlocksThunk] Message ID is required.')
+      return
     }
 
     try {
@@ -1434,14 +1419,7 @@ export const updateMessageAndBlocksThunk =
       }
 
       if (blockUpdatesList.length > 0) {
-        blockUpdatesList.forEach((blockUpdate) => {
-          const { id: blockId, ...blockChanges } = blockUpdate
-          if (blockId && Object.keys(blockChanges).length > 0) {
-            dispatch(updateOneBlock({ id: blockId, changes: blockChanges }))
-          } else if (!blockId) {
-            console.warn('[updateMessageAndBlocksThunk] Skipping block update due to missing block ID:', blockUpdate)
-          }
-        })
+        dispatch(upsertManyBlocks(blockUpdatesList))
       }
 
       // 2. 更新数据库 (在事务中)
@@ -1468,27 +1446,57 @@ export const updateMessageAndBlocksThunk =
           }
         }
 
-        // Always process block updates if the list is provided and not empty
         if (blockUpdatesList.length > 0) {
-          const validBlockUpdatesForDb = blockUpdatesList
-            .map((bu) => {
-              const { id, ...changes } = bu
-              if (id && Object.keys(changes).length > 0) {
-                return { key: id, changes: changes }
-              }
-              return null
-            })
-            .filter((bu) => bu !== null) as { key: string; changes: Partial<MessageBlock> }[]
+          await db.message_blocks.bulkPut(blockUpdatesList)
+        }
+      })
+    } catch (error) {
+      console.error(`[updateMessageAndBlocksThunk] Failed to process updates for message ${messageId}:`, error)
+    }
+  }
 
-          if (validBlockUpdatesForDb.length > 0) {
-            await db.message_blocks.bulkUpdate(validBlockUpdatesForDb)
-          }
+export const removeBlocksThunk =
+  (topicId: string, messageId: string, blockIdsToRemove: string[]) =>
+  async (dispatch: AppDispatch, getState: () => RootState): Promise<void> => {
+    if (!blockIdsToRemove.length) {
+      console.warn('[removeBlocksFromMessageThunk] No block IDs provided to remove.')
+      return
+    }
+
+    try {
+      const state = getState()
+      const message = state.messages.entities[messageId]
+
+      if (!message) {
+        console.error(`[removeBlocksFromMessageThunk] Message ${messageId} not found in state.`)
+        return
+      }
+      const blockIdsToRemoveSet = new Set(blockIdsToRemove)
+
+      const updatedBlockIds = (message.blocks || []).filter((id) => !blockIdsToRemoveSet.has(id))
+
+      // 1. Update Redux state
+      dispatch(newMessagesActions.updateMessage({ topicId, messageId, updates: { blocks: updatedBlockIds } }))
+
+      if (blockIdsToRemove.length > 0) {
+        dispatch(removeManyBlocks(blockIdsToRemove))
+      }
+
+      const finalMessagesToSave = selectMessagesForTopic(getState(), topicId)
+
+      // 2. Update database (in a transaction)
+      await db.transaction('rw', db.topics, db.message_blocks, async () => {
+        // Update the message in the topic
+        await db.topics.update(topicId, { messages: finalMessagesToSave })
+        // Delete the blocks from the database
+        if (blockIdsToRemove.length > 0) {
+          await db.message_blocks.bulkDelete(blockIdsToRemove)
         }
       })
 
-      return true
+      return
     } catch (error) {
-      console.error(`[updateMessageAndBlocksThunk] Failed to process updates for message ${messageId}:`, error)
-      return false
+      console.error(`[removeBlocksFromMessageThunk] Failed to remove blocks from message ${messageId}:`, error)
+      throw error
     }
   }
diff --git a/src/renderer/src/utils/messageUtils/find.ts b/src/renderer/src/utils/messageUtils/find.ts
index 64e8beba05e..dd3ae7f92bd 100644
--- a/src/renderer/src/utils/messageUtils/find.ts
+++ b/src/renderer/src/utils/messageUtils/find.ts
@@ -1,16 +1,33 @@
 import store from '@renderer/store'
 import { messageBlocksSelectors } from '@renderer/store/messageBlock'
+import { FileType } from '@renderer/types'
 import type {
   CitationMessageBlock,
   FileMessageBlock,
   ImageMessageBlock,
   MainTextMessageBlock,
   Message,
+  MessageBlock,
   ThinkingMessageBlock,
   TranslationMessageBlock
 } from '@renderer/types/newMessage'
 import { MessageBlockType } from '@renderer/types/newMessage'
 
+export const findAllBlocks = (message: Message): MessageBlock[] => {
+  if (!message || !message.blocks || message.blocks.length === 0) {
+    return []
+  }
+  const state = store.getState()
+  const allBlocks: MessageBlock[] = []
+  for (const blockId of message.blocks) {
+    const block = messageBlocksSelectors.selectById(state, blockId)
+    if (block) {
+      allBlocks.push(block)
+    }
+  }
+  return allBlocks
+}
+
 /**
  * Finds all MainTextMessageBlocks associated with a given message, in order.
  * @param message - The message object.
@@ -122,6 +139,28 @@ export const getKnowledgeBaseIds = (message: Message): string[] | undefined => {
   return firstTextBlock?.flatMap((block) => block.knowledgeBaseIds).filter((id): id is string => Boolean(id))
 }
 
+/**
+ * Gets the file content from all FileMessageBlocks and ImageMessageBlocks of a message.
+ * @param message - The message object.
+ * @returns The file content or an empty string if no file blocks are found.
+ */
+export const getFileContent = (message: Message): FileType[] => {
+  const files: FileType[] = []
+  const fileBlocks = findFileBlocks(message)
+  for (const block of fileBlocks) {
+    if (block.file) {
+      files.push(block.file)
+    }
+  }
+  const imageBlocks = findImageBlocks(message)
+  for (const block of imageBlocks) {
+    if (block.file) {
+      files.push(block.file)
+    }
+  }
+  return files
+}
+
 /**
  * Finds all CitationBlocks associated with a given message.
  * @param message - The message object.


Reviews:
user: login: MyPrototypeWhat
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T03:19:50Z
user: login: MyPrototypeWhat
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T03:35:37Z
user: login: MyPrototypeWhat
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T04:05:24Z
user: login: DeJeune
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T04:21:57Z
user: login: MyPrototypeWhat
body: 有冲突 解决了直接合并吧
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-19T06:14:28Z

Review comments:
diff_hunk: @@ -146,7 +183,13 @@ const MessageItem: FC<Props> = ({
         className="message-content-container"
         style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
         <MessageErrorBoundary>
-          <MessageContent message={message} />
+          <MessageContent
+            message={message}
+            isEditing={isEditing}
+            onSave={handleEditSave}
+            onResend={handleEditResend}
+            onCancel={handleEditCancel}
+          />
path: src/renderer/src/pages/home/Messages/Message.tsx
user: login: MyPrototypeWhat
body: isEditing onSave onResend onCancel 只有MessageContent里的MessageEditor能用到 为什么不放在MessageEditor里面
created_at: 2025-05-13T03:19:42Z
author_association: COLLABORATOR
diff_hunk: @@ -146,7 +183,13 @@ const MessageItem: FC<Props> = ({
         className="message-content-container"
         style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
         <MessageErrorBoundary>
-          <MessageContent message={message} />
+          <MessageContent
+            message={message}
+            isEditing={isEditing}
+            onSave={handleEditSave}
+            onResend={handleEditResend}
+            onCancel={handleEditCancel}
+          />
path: src/renderer/src/pages/home/Messages/Message.tsx
user: login: MyPrototypeWhat
body: 用context来传递isEditing 可以把这些props全去掉了 似乎更优雅一些?
created_at: 2025-05-13T03:35:37Z
author_association: COLLABORATOR
diff_hunk: @@ -309,29 +268,127 @@ export function useMessageOperations(topic: Topic) {
   )
 
   /**
-   * Updates properties of specific message blocks (e.g., content).
-   * Uses the generalized thunk for persistence.
+   * Updates message blocks by comparing original and edited blocks.
+   * Handles adding, updating, and removing blocks in a single operation.
+   * @param messageId The ID of the message to update
+   * @param editedBlocks The complete set of blocks after editing
    */
   const editMessageBlocks = useCallback(
-    async (messageId: string, updates: Partial<MessageBlock>) => {
+    async (messageId: string, editedBlocks: MessageBlock[]) => {
       if (!topic?.id) {
         console.error('[editMessageBlocks] Topic prop is not valid.')
         return
       }
 
-      const blockUpdatesListProcessed = {
-        updatedAt: new Date().toISOString(),
-        ...updates
+      try {
+        // 1. Get the current state of the message and its blocks
+        const state = store.getState()
+        const message = state.messages.entities[messageId]
+        if (!message) {
+          console.error('[editMessageBlocks] Message not found:', messageId)
+          return
+        }
+
+        // 2. Get all original blocks
+        const originalBlocks = message.blocks
+          ? (message.blocks
+              .map((blockId) => state.messageBlocks.entities[blockId])
+              .filter((block) => block !== undefined) as MessageBlock[])
+          : []
+
+        // 3. Create sets for efficient comparison
+        const originalBlockIds = new Set(originalBlocks.map((block) => block.id))
+        const editedBlockIds = new Set(editedBlocks.map((block) => block.id))
+
+        // 4. Identify blocks to remove, update, and add
+        const blockIdsToRemove = originalBlocks
+          .filter((block) => !editedBlockIds.has(block.id))
+          .map((block) => block.id)
+
+        const blocksToUpdate = editedBlocks
+          .filter((block) => originalBlockIds.has(block.id))
+          .map((block) => ({
+            ...block,
+            updatedAt: new Date().toISOString()
+          }))
+
+        const blocksToAdd = editedBlocks
+          .filter((block) => !originalBlockIds.has(block.id))
+          .map((block) => ({
+            ...block,
+            updatedAt: new Date().toISOString()
+          }))
+
+        // 5. Prepare message update with new block IDs
+        const updatedBlockIds = editedBlocks.map((block) => block.id)
+        const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
+          id: messageId,
+          updatedAt: new Date().toISOString(),
+          blocks: updatedBlockIds
+        }
+
+        // 6. Log operations for debugging
+        console.log('[editMessageBlocks] Operations:', {
+          blocksToRemove: blockIdsToRemove.length,
+          blocksToUpdate: blocksToUpdate.length,
+          blocksToAdd: blocksToAdd.length
+        })
+
+        // 7. Update Redux state and database
+        // First update message and add/update blocks
+        if (blocksToAdd.length > 0) {
+          await dispatch(updateMessageAndAddBlocksThunk(topic.id, messageUpdates, blocksToAdd))
+        }
+
+        if (blocksToUpdate.length > 0) {
+          await dispatch(updateMessageAndUpdateBlocksThunk(topic.id, messageUpdates, blocksToUpdate))
+        }
path: src/renderer/src/hooks/useMessageOperations.ts
user: login: MyPrototypeWhat
body: updateMessageAndAddBlocksThunk
updateMessageAndUpdateBlocksThunk
这两个hook应该可以合并为一个吧 updateMessageAndUpdateBlocksThunk中的updateOneBlock改成upsertOneBlock就可以兼容新增
created_at: 2025-05-13T03:46:09Z
author_association: COLLABORATOR
diff_hunk: @@ -309,29 +268,127 @@ export function useMessageOperations(topic: Topic) {
   )
 
   /**
-   * Updates properties of specific message blocks (e.g., content).
-   * Uses the generalized thunk for persistence.
+   * Updates message blocks by comparing original and edited blocks.
+   * Handles adding, updating, and removing blocks in a single operation.
+   * @param messageId The ID of the message to update
+   * @param editedBlocks The complete set of blocks after editing
    */
   const editMessageBlocks = useCallback(
-    async (messageId: string, updates: Partial<MessageBlock>) => {
+    async (messageId: string, editedBlocks: MessageBlock[]) => {
       if (!topic?.id) {
         console.error('[editMessageBlocks] Topic prop is not valid.')
         return
       }
 
-      const blockUpdatesListProcessed = {
-        updatedAt: new Date().toISOString(),
-        ...updates
+      try {
+        // 1. Get the current state of the message and its blocks
+        const state = store.getState()
+        const message = state.messages.entities[messageId]
+        if (!message) {
+          console.error('[editMessageBlocks] Message not found:', messageId)
+          return
+        }
+
+        // 2. Get all original blocks
+        const originalBlocks = message.blocks
+          ? (message.blocks
+              .map((blockId) => state.messageBlocks.entities[blockId])
+              .filter((block) => block !== undefined) as MessageBlock[])
+          : []
+
+        // 3. Create sets for efficient comparison
+        const originalBlockIds = new Set(originalBlocks.map((block) => block.id))
+        const editedBlockIds = new Set(editedBlocks.map((block) => block.id))
+
+        // 4. Identify blocks to remove, update, and add
+        const blockIdsToRemove = originalBlocks
+          .filter((block) => !editedBlockIds.has(block.id))
+          .map((block) => block.id)
+
+        const blocksToUpdate = editedBlocks
+          .filter((block) => originalBlockIds.has(block.id))
+          .map((block) => ({
+            ...block,
+            updatedAt: new Date().toISOString()
+          }))
+
+        const blocksToAdd = editedBlocks
+          .filter((block) => !originalBlockIds.has(block.id))
+          .map((block) => ({
+            ...block,
+            updatedAt: new Date().toISOString()
+          }))
+
+        // 5. Prepare message update with new block IDs
+        const updatedBlockIds = editedBlocks.map((block) => block.id)
+        const messageUpdates: Partial<Message> & Pick<Message, 'id'> = {
+          id: messageId,
+          updatedAt: new Date().toISOString(),
+          blocks: updatedBlockIds
+        }
+
+        // 6. Log operations for debugging
+        console.log('[editMessageBlocks] Operations:', {
+          blocksToRemove: blockIdsToRemove.length,
+          blocksToUpdate: blocksToUpdate.length,
+          blocksToAdd: blocksToAdd.length
+        })
+
+        // 7. Update Redux state and database
+        // First update message and add/update blocks
+        if (blocksToAdd.length > 0) {
+          await dispatch(updateMessageAndAddBlocksThunk(topic.id, messageUpdates, blocksToAdd))
+        }
+
+        if (blocksToUpdate.length > 0) {
+          await dispatch(updateMessageAndUpdateBlocksThunk(topic.id, messageUpdates, blocksToUpdate))
+        }
path: src/renderer/src/hooks/useMessageOperations.ts
user: login: DeJeune
body: OK 我以为不兼容
created_at: 2025-05-13T04:21:57Z
author_association: COLLABORATOR

PR comments:
user: login: Pleasurecruise
created_at: 2025-05-12T14:08:35Z
body: UI风格似乎可以再完善一下（比如圆角啥的
还有 现在是只能删除不能上传？
删除之后本地的文件要考虑删除吗
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-12T14:11:41Z
body: > UI风格似乎可以再完善一下（比如圆角啥的 还有 现在是只能删除不能上传？ 删除之后本地的文件要考虑删除吗

我当时想了下这种是临时的，应该不需要
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-12T14:12:20Z
body: > UI风格似乎可以再完善一下（比如圆角啥的 还有 现在是只能删除不能上传？ 删除之后本地的文件要考虑删除吗

可以上传要在输入框里粘贴（拖动我还没测试），你觉得需要加一个上传按钮吗？
author_association: COLLABORATOR
user: login: Pleasurecruise
created_at: 2025-05-12T14:13:47Z
body: 拖动上传和button上传的方式应该还是需要的
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-12T14:15:39Z
body: OK
author_association: COLLABORATOR
user: login: Pleasurecruise
created_at: 2025-05-12T14:30:18Z
body: 要考虑加一个switch model button吗 还是 保存后切换模型再重发？🧐
author_association: COLLABORATOR
user: login: Pleasurecruise
created_at: 2025-05-19T08:59:30Z
body: 有问题 搜索消息和话题这个功能 用不了了 @DeJeune 
Uncaught Error: useMessageEditing must be used within a MessageEditingProvider
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-19T09:01:32Z
body: > 有问题 搜索消息和话题这个功能 用不了了 @DeJeune Uncaught Error: useMessageEditing must be used within a MessageEditingProvider

看下是不是热更新的原因
author_association: COLLABORATOR
user: login: Pleasurecruise
created_at: 2025-05-19T09:07:44Z
body: 不是 因为搜索话题和消息里面的MessageMenubar还是存在的 还是会涉及到相关功能
<img src="https://github.com/user-attachments/assets/4bc9af3f-2ddd-4200-95dc-ef8e861cf43a" width="400" />
要不要直接把menubar去掉
author_association: COLLABORATOR
user: login: DeJeune
created_at: 2025-05-19T09:09:31Z
body: 可以把搜索话题里面的menubar去掉
author_association: COLLABORATOR
user: login: Pleasurecruise
created_at: 2025-05-19T09:10:08Z
body: 那我直接在多选那个分支里改了
author_association: COLLABORATOR