From f20b8c58ee86692c48749aee32e1ab0ca1c9b6db Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 19 May 2025 15:38:00 +0800 Subject: [PATCH] refactor: implement message editing and resend functionality (#5901) * feat: implement message editing and resend functionality with block management * fix: move start_time_millsec initialization to onChunk for accurate timing * feat: refactor message update thunks to separate block addition and updates * feat: enhance MessageBlockEditor with toolbar buttons and attachment functionality * refactor: implement message editing context and integrate with message operations * style: adjust padding in MessageBlockEditor and related components * refactor: remove MessageEditingContext and integrate editing logic directly in Message component * refactor: streamline message rendering logic by using conditional rendering for MessageEditor and MessageContent * refactor: remove redundant ipcRenderer variable in useMCPServers hook * fix: Add mock for electron's ipcRenderer to support testing * test: Update mocks for ipcRenderer and remove redundant window.electron mock * fix: enhance file handling in MessageEditor with new Electron API - Added support for file dragging with visual feedback. - Improved file type validation during drag-and-drop and clipboard pasting. - Displayed user notifications for unsupported file types. - Refactored file handling logic for better clarity and maintainability. --- src/renderer/__tests__/setup.ts | 29 ++ .../src/context/MessageEditingContext.tsx | 33 ++ src/renderer/src/hooks/useMCPServers.ts | 6 +- .../src/hooks/useMessageOperations.ts | 170 +++++--- .../pages/home/Inputbar/AttachmentPreview.tsx | 98 ++--- .../src/pages/home/Inputbar/Inputbar.tsx | 16 - .../src/pages/home/Messages/Message.tsx | 65 +++- .../pages/home/Messages/MessageContent.tsx | 11 - .../src/pages/home/Messages/MessageEditor.tsx | 367 ++++++++++++++++++ .../src/pages/home/Messages/MessageGroup.tsx | 57 +-- .../pages/home/Messages/MessageMenubar.tsx | 112 +----- src/renderer/src/store/thunk/messageThunk.ts | 96 ++--- src/renderer/src/utils/messageUtils/find.ts | 39 ++ 13 files changed, 786 insertions(+), 313 deletions(-) create mode 100644 src/renderer/src/context/MessageEditingContext.tsx create mode 100644 src/renderer/src/pages/home/Messages/MessageEditor.tsx diff --git a/src/renderer/__tests__/setup.ts b/src/renderer/__tests__/setup.ts index f847a40826..70b9cd70b0 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 0000000000..5c864908b6 --- /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(null) + +export function MessageEditingProvider({ children }: { children: ReactNode }) { + const [editingMessageId, setEditingMessageId] = useState(null) + + const startEditing = (messageId: string) => { + setEditingMessageId(messageId) + } + + const stopEditing = () => { + setEditingMessageId(null) + } + + return ( + {children} + ) +} + +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 49fde29f60..bc95bb2ff6 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 c0c6112df3..5418893463 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 & Pick = { - 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) => { + 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 + } - const messageUpdates: Partial & Pick = { - id: messageId, - updatedAt: new Date().toISOString() - } + // 2. Get all original blocks + const originalBlocks = message.blocks + ? (message.blocks + .map((blockId) => state.messageBlocks.entities[blockId]) + .filter((block) => block !== undefined) as MessageBlock[]) + : [] - await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [blockUpdatesListProcessed])) + // 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 & Pick = { + 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] + [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 & Pick = { + 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, assistant)) + }, + [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 956403e06d..4f69ca7815 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 + + const ext = type.toLowerCase() + + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + return + } + + if (['.doc', '.docx'].includes(ext)) { + return + } + if (['.xls', '.xlsx'].includes(ext)) { + return + } + if (['.ppt', '.pptx'].includes(ext)) { + return + } + if (ext === '.pdf') { + return + } + if (['.md', '.markdown'].includes(ext)) { + return + } + + if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { + return + } + + if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { + return + } + + if (['.url'].includes(ext)) { + return + } + + if (['.sitemap'].includes(ext)) { + return + } + + if (['.folder'].includes(ext)) { + return + } + + return +} + +export const FileNameRender: FC<{ file: FileType }> = ({ file }) => { const [visible, setVisible] = useState(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 = ({ files, setFiles }) => { - const getFileIcon = (type?: string) => { - if (!type) return - - const ext = type.toLowerCase() - - if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { - return - } - - if (['.doc', '.docx'].includes(ext)) { - return - } - if (['.xls', '.xlsx'].includes(ext)) { - return - } - if (['.ppt', '.pptx'].includes(ext)) { - return - } - if (ext === '.pdf') { - return - } - if (['.md', '.markdown'].includes(ext)) { - return - } - - if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { - return - } - - if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { - return - } - - if (['.url'].includes(ext)) { - return - } - - if (['.sitemap'].includes(ext)) { - return - } - - if (['.folder'].includes(ext)) { - return - } - - return - } - 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 92bcaf5c6a..f68f5fbbbf 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 = ({ 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) => { 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 2e79850a91..b1995a4c05 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 = ({ 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(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 = ({ background: messageBackground, overflowY: 'visible' }}> - - - + {isEditing ? ( + + ) : ( + + + + )} {showMenubar && ( = ({ 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 0000000000..8b4cd32685 --- /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 = ({ message, onSave, onResend, onCancel }) => { + const allBlocks = findAllBlocks(message) + const [editedBlocks, setEditedBlocks] = useState(allBlocks) + const [files, setFiles] = useState([]) + 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(null) + const attachmentButtonRef = useRef(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) => { + 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) => { + const textarea = e.target + textarea.style.height = 'auto' + textarea.style.height = `${textarea.scrollHeight}px` + } + + return ( + <> + e.preventDefault()} onDrop={handleDrop}> + {editedBlocks + .filter((block) => block.type === MessageBlockType.MAIN_TEXT) + .map((block) => ( + + ))} + {(editedBlocks.some((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) || + files.length > 0) && ( + + {editedBlocks + .filter((block) => block.type === MessageBlockType.FILE || block.type === MessageBlockType.IMAGE) + .map( + (block) => + block.file && ( + handleFileRemove(block.id)}> + + + ) + )} + + {files.map((file) => ( + setFiles((prevFiles) => prevFiles.filter((f) => f.id !== file.id))}> + + + ))} + + )} + + + + + + + + + + + + + + handleClick()}> + + + + + handleClick(true)}> + + + + + + + + ) +} + +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 b1c1a5d577..3d8c1de1fd 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 ( - - + - {messages.map(renderMessage)} - - {isGrouped && ( - { - setMultiModelMessageStyle(style) - messages.forEach((message) => { - editMessage(message.id, { multiModelMessageStyle: style }) - }) - }} - messages={messages} - selectMessageId={selectedMessageId} - setSelectedMessage={setSelectedMessage} - topic={topic} - /> - )} - + className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}> + + {messages.map(renderMessage)} + + {isGrouped && ( + { + setMultiModelMessageStyle(style) + messages.forEach((message) => { + editMessage(message.id, { multiModelMessageStyle: style }) + }) + }} + messages={messages} + selectMessageId={selectedMessageId} + setSelectedMessage={setSelectedMessage} + topic={topic} + /> + )} + + ) } diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 7a3ca793ff..eac6e4c9d2 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) => { deleteMessage, resendMessage, regenerateAssistantMessage, - resendUserMessageWithEdit, getTranslationUpdater, appendAssistantResponse, - editMessageBlocks, removeMessageBlock } = useMessageOperations(topic) const loading = useTopicLoading(topic) @@ -119,92 +112,11 @@ const MessageMenubar: FC = (props) => { [assistant, loading, message, resendMessage] ) + const { startEditing } = useMessageEditing() + 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 editedText = await TextEditPopup.show({ - text: textToEdit, - children: (props) => { - const onPress = () => { - props.onOk?.() - resendMessage = true - } - return message.role === 'user' ? ( - } - onClick={onPress}> - {t('chat.resend')} - - ) : 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]) + 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 78f2876212..099bc005da 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 & Pick) | null, // ID is always required for context - blockUpdatesList: Partial[] // Block updates remain required for this thunk's purpose + blockUpdatesList: MessageBlock[] // Block updates remain required for this thunk's purpose ) => - async (dispatch: AppDispatch): Promise => { + async (dispatch: AppDispatch): Promise => { 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 }[] + 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 => { + 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 64e8beba05..dd3ae7f92b 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.