diff --git a/web-app/src/containers/MessageItem.tsx b/web-app/src/containers/MessageItem.tsx index 69a49ba427..60edd56712 100644 --- a/web-app/src/containers/MessageItem.tsx +++ b/web-app/src/containers/MessageItem.tsx @@ -20,6 +20,8 @@ import { CopyButton } from './CopyButton' import { AvatarEmoji } from '@/containers/AvatarEmoji' import { useModelProvider } from '@/hooks/useModelProvider' import { IconRefresh, IconPaperclip } from '@tabler/icons-react' +import { EditMessageDialog } from '@/containers/dialogs/EditMessageDialog' +import { DeleteMessageDialog } from '@/containers/dialogs/DeleteMessageDialog' import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator' import { extractFilesFromPrompt, FileMetadata } from '@/lib/fileMetadata' import { useMemo } from 'react' @@ -42,6 +44,8 @@ export type MessageItemProps = { status: ChatStatus reasoningContainerRef?: React.RefObject onRegenerate?: (messageId: string) => void + onEdit?: (messageId: string, newText: string) => void + onDelete?: (messageId: string) => void assistant?: { avatar?: React.ReactNode; name?: string } showAssistant?: boolean } @@ -53,6 +57,8 @@ export const MessageItem = memo( status, reasoningContainerRef, onRegenerate, + onEdit, + onDelete, assistant, showAssistant, }: MessageItemProps) => { @@ -66,6 +72,28 @@ export const MessageItem = memo( onRegenerate?.(message.id) }, [onRegenerate, message.id]) + const handleEdit = useCallback( + (newText: string) => { + onEdit?.(message.id, newText) + }, + [onEdit, message.id] + ) + + const handleDelete = useCallback(() => { + onDelete?.(message.id) + }, [onDelete, message.id]) + + // Get image URLs from file parts for the edit dialog + const imageUrls = useMemo(() => { + return message.parts + .filter((part) => { + if (part.type !== 'file') return false + const filePart = part as { type: 'file'; url?: string; mediaType?: string } + return filePart.url && filePart.mediaType?.startsWith('image/') + }) + .map((part) => (part as { url: string }).url) + }, [message.parts]) + const isStreaming = isLastMessage && status === CHAT_STATUS.STREAMING // Extract file metadata from message text (for user messages with attachments) @@ -341,17 +369,17 @@ export const MessageItem = memo(
- {selectedModel && - onRegenerate && - status !== CHAT_STATUS.STREAMING && ( - - )} + {onEdit && status !== CHAT_STATUS.STREAMING && ( + 0 ? imageUrls : undefined} + onSave={handleEdit} + /> + )} + + {onDelete && status !== CHAT_STATUS.STREAMING && ( + + )}
)} @@ -367,11 +395,22 @@ export const MessageItem = memo( > - {selectedModel && onRegenerate && !isStreaming && ( + {onEdit && !isStreaming && ( + + )} + + {onDelete && !isStreaming && ( + + )} + + {selectedModel && onRegenerate && !isStreaming && isLastMessage && ( diff --git a/web-app/src/containers/dialogs/DeleteMessageDialog.tsx b/web-app/src/containers/dialogs/DeleteMessageDialog.tsx index c4444b1bdf..3b5a458585 100644 --- a/web-app/src/containers/dialogs/DeleteMessageDialog.tsx +++ b/web-app/src/containers/dialogs/DeleteMessageDialog.tsx @@ -12,11 +12,6 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { IconTrash } from '@tabler/icons-react' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' interface DeleteMessageDialogProps { onDelete: () => void @@ -39,26 +34,19 @@ export function DeleteMessageDialog({ onDelete }: DeleteMessageDialogProps) { } const trigger = ( - - -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setIsOpen(true) - } - }} - > - -
-
- -

{t('delete')}

-
-
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setIsOpen(true) + } + }} + > + +
) return ( diff --git a/web-app/src/containers/dialogs/EditMessageDialog.tsx b/web-app/src/containers/dialogs/EditMessageDialog.tsx index 376a8baa42..ad18eb822b 100644 --- a/web-app/src/containers/dialogs/EditMessageDialog.tsx +++ b/web-app/src/containers/dialogs/EditMessageDialog.tsx @@ -12,16 +12,11 @@ import { import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { IconPencil, IconX } from '@tabler/icons-react' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' interface EditMessageDialogProps { message: string imageUrls?: string[] - onSave: (message: string, imageUrls?: string[]) => void + onSave: (message: string) => void triggerElement?: React.ReactNode } @@ -53,14 +48,9 @@ export function EditMessageDialog({ const handleSave = () => { const hasTextChanged = draft !== message && draft.trim() - const hasImageChanged = - JSON.stringify(imageUrls || []) !== JSON.stringify(keptImages) - if (hasTextChanged || hasImageChanged) { - onSave( - draft.trim() || message, - keptImages.length > 0 ? keptImages : undefined - ) + if (hasTextChanged) { + onSave(draft.trim() || message) setIsOpen(false) } } @@ -73,26 +63,19 @@ export function EditMessageDialog({ } const defaultTrigger = ( - - -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - setIsOpen(true) - } - }} - > - -
-
- -

{t('edit')}

-
-
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + setIsOpen(true) + } + }} + > + +
) return ( diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 0fb6ee8945..73a75d2ddc 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -33,7 +33,7 @@ import { extractContentPartsFromUIMessage, } from '@/lib/messages' import { newUserThreadContent } from '@/lib/completion' -import { ThreadMessage, MessageStatus, ChatCompletionRole } from '@janhq/core' +import { ThreadMessage, MessageStatus, ChatCompletionRole, ContentType } from '@janhq/core' import { createImageAttachment } from '@/types/attachment' import { useChatAttachments, @@ -613,6 +613,82 @@ function ThreadDetail() { regenerate(messageId ? { messageId } : undefined) } + // Handle edit message - updates the message and regenerates from it + const handleEditMessage = useCallback( + (messageId: string, newText: string) => { + if (!languageModelId || !languageModelProvider) { + console.warn('No language model available') + return + } + + const currentLocalMessages = useMessages.getState().getMessages(threadId) + const messageIndex = currentLocalMessages.findIndex( + (m) => m.id === messageId + ) + + if (messageIndex === -1) return + + const originalMessage = currentLocalMessages[messageIndex] + + // Update the message content + const updatedMessage = { + ...originalMessage, + content: [ + { + type: ContentType.Text, + text: { value: newText, annotations: [] }, + }, + ], + } + updateMessage(updatedMessage) + + // Update chat messages for UI + const updatedChatMessages = chatMessages.map((msg) => { + if (msg.id === messageId) { + return { + ...msg, + parts: [{ type: 'text' as const, text: newText }], + } + } + return msg + }) + setChatMessages(updatedChatMessages) + + // Delete all messages after this one and regenerate + const messagesToDelete = currentLocalMessages.slice(messageIndex + 1) + messagesToDelete.forEach((msg) => { + deleteMessage(threadId, msg.id) + }) + + // Regenerate from the edited message + regenerate({ messageId }) + }, + [ + languageModelId, + languageModelProvider, + threadId, + updateMessage, + deleteMessage, + chatMessages, + setChatMessages, + regenerate, + ] + ) + + // Handle delete message + const handleDeleteMessage = useCallback( + (messageId: string) => { + deleteMessage(threadId, messageId) + + // Update chat messages for UI + const updatedChatMessages = chatMessages.filter( + (msg) => msg.id !== messageId + ) + setChatMessages(updatedChatMessages) + }, + [threadId, deleteMessage, chatMessages, setChatMessages] + ) + // Handler for increasing context size const handleContextSizeIncrease = useCallback(async () => { if (!selectedModel) return @@ -659,7 +735,7 @@ function ThreadDetail() { setTimeout(() => { handleRegenerate() }, 1000) - }, [selectedModel, selectedProvider, getProviderByName, serviceHub]) + }, [selectedModel, selectedProvider, getProviderByName, serviceHub, handleRegenerate]) const threadModel = useMemo(() => thread?.model, [thread]) @@ -698,6 +774,8 @@ function ThreadDetail() { status={status} reasoningContainerRef={reasoningContainerRef} onRegenerate={handleRegenerate} + onEdit={handleEditMessage} + onDelete={handleDeleteMessage} /> ) })}