diff --git a/webapp/features/Threads/Conversation/ConversationContext.tsx b/webapp/features/Threads/Conversation/ConversationContext.tsx index a517fca7..8d994b5c 100644 --- a/webapp/features/Threads/Conversation/ConversationContext.tsx +++ b/webapp/features/Threads/Conversation/ConversationContext.tsx @@ -148,7 +148,7 @@ function ConversationProvider({ conversationMessages: Message[], conversation: Conversation, previousMessage: Message | undefined, - modelName: string, + authorName: string, ) => { let updatedConversation = conversation; let updatedMessages: Message[] | undefined; @@ -162,9 +162,18 @@ function ConversationProvider({ updatedConversation, conversation.id, )); + } else if (authorName === 'Note') { + message = createMessage({ role: 'note', name: authorName }, content); + message.status = status; + ({ updatedConversation, updatedMessages } = await updateMessagesAndConversation( + [message], + getConversationMessages(conversation.id), + conversation, + conversation.id, + )); } else { const userMessage = createMessage({ role: 'user', name: 'You' }, raw, raw); - message = createMessage({ role: 'assistant', name: modelName || 'Assistant' }, content); + message = createMessage({ role: 'assistant', name: authorName || 'Assistant' }, content); message.status = status; userMessage.sibling = message.id; message.sibling = userMessage.id; @@ -252,6 +261,50 @@ function ConversationProvider({ ], ); + const handleNote = useCallback( + async ( + prompt: ParsedPrompt, + conversation: Conversation, + previousMessage?: Message | undefined, + ) => { + let updatedConversation = conversation; + + if (!previousMessage) { + updatedConversation = + (await clearPrompt?.(conversation, conversations))?.find( + (c) => c.id === conversation.id, + ) || conversation; + } + if (updatedConversation.temp) { + updatedConversation.name = getConversationTitle(updatedConversation, t); + } + + const updatedMessages = getConversationMessages(conversation.id); + const message = previousMessage; + await updateMessages( + prompt.raw, + prompt.text, + MessageStatus.Delivered, + updatedMessages, + updatedConversation, + message, + 'Note', + ); + if (tempConversationId) { + router.replace(`${Page.Threads}/${tempConversationId}`, undefined, { shallow: true }); + } + }, + [ + clearPrompt, + conversations, + getConversationMessages, + router, + t, + tempConversationId, + updateMessages, + ], + ); + const preProcessingSendMessage = useCallback( async ( prompt: ParsedPrompt, @@ -292,6 +345,17 @@ function ConversationProvider({ return {}; } + if (result.type === 'note') { + setIsProcessing({ ...isProcessing, [conversation.id]: true }); + try { + await handleNote(prompt, conversation, previousMessage); + } catch (error) { + setErrorMessage({ ...errorMessages, [conversation.id]: `${error}` }); + } + setIsProcessing({ ...isProcessing, [conversation.id]: false }); + + return {}; + } let selectedAssistant: Assistant | undefined; if (result.assistantId) { @@ -328,6 +392,7 @@ function ConversationProvider({ getAssistant, getConversationMessages, handleImageGeneration, + handleNote, isProcessing, providers, selectedConversation, diff --git a/webapp/features/Threads/Conversation/ConversationList.tsx b/webapp/features/Threads/Conversation/ConversationList.tsx index 6637ee78..1509d1db 100644 --- a/webapp/features/Threads/Conversation/ConversationList.tsx +++ b/webapp/features/Threads/Conversation/ConversationList.tsx @@ -20,7 +20,7 @@ import { AvatarRef, Conversation, Message, MessageImpl } from '@/types'; import { getConversationAssets } from '@/utils/data/conversations'; import { getMessageFirstAsset } from '@/utils/data/messages'; import { Button } from '../../../components/ui/button'; -import MessageView from './MessageView'; +import MessageView from './Message'; type ConversationListProps = { conversation: Conversation; diff --git a/webapp/features/Threads/Conversation/Message/Actions.tsx b/webapp/features/Threads/Conversation/Message/Actions.tsx new file mode 100644 index 00000000..97a9daf2 --- /dev/null +++ b/webapp/features/Threads/Conversation/Message/Actions.tsx @@ -0,0 +1,179 @@ +// Copyright 2024 Mik Bry +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ChevronLeft, ChevronRight, Pencil, RotateCcw, Trash2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { MessageImpl, MessageStatus } from '@/types'; +import { shortcutAsText } from '@/utils/shortcuts'; +import useTranslation from '@/hooks/useTranslation'; +import CopyToClipBoard from '@/components/common/CopyToClipBoard'; +import { RefObject } from 'react'; +import { ShortcutIds } from '@/hooks/useShortcuts'; +import { DisplayMessageState } from './types'; + +function DeleteButton({ onDeleteMessage }: { onDeleteMessage: () => void }) { + return ( + + ); +} + +type ActionsProps = { + state: DisplayMessageState; + message: MessageImpl; + content: string; + contentRef: RefObject; + disabled: boolean; + isUser: boolean; + isHover: boolean; + current: number; + setCurrent: (value: number) => void; + onEdit: () => void; + onCancelEdit: () => void; + onResendMessage: () => void; + onDeleteMessage: () => void; + onDeleteAssets: () => void; + onSave: () => void; + onCopyMessage: (messageId: string, state: boolean) => void; + onCancelSending: () => void; +}; + +function Actions({ + state, + message, + content, + contentRef, + disabled, + isUser, + isHover, + current, + setCurrent, + onEdit, + onCancelEdit, + onResendMessage, + onDeleteMessage, + onDeleteAssets, + onSave, + onCopyMessage, + onCancelSending, +}: ActionsProps) { + const { t } = useTranslation(); + return ( + <> + {state === DisplayMessageState.FileAsset && isHover && ( +
+ +
+ )} + {(state === DisplayMessageState.Pending || state === DisplayMessageState.Streaming) && + isHover && ( +
+ +
+ )} + {(state === DisplayMessageState.Markdown || + state === DisplayMessageState.Note || + state === DisplayMessageState.Text) && + isHover && ( +
+ {message.contentHistory && message.contentHistory.length > 0 && ( +
+ + + {' '} + {message.contentHistory.length - current + 1} /{' '} + {message.contentHistory.length + 1}{' '} + + +
+ )} + {!(isUser || state === DisplayMessageState.Note) && ( + + )} + onCopyMessage(message.id, copied)} + /> + {message.status !== MessageStatus.Error && ( + + )} + +
+ )} + {state === DisplayMessageState.Edit && ( +
+ + +
+ )} + + ); +} + +export default Actions; diff --git a/webapp/features/Threads/Conversation/Message/AvatarIcon.tsx b/webapp/features/Threads/Conversation/Message/AvatarIcon.tsx new file mode 100644 index 00000000..b0630dc3 --- /dev/null +++ b/webapp/features/Threads/Conversation/Message/AvatarIcon.tsx @@ -0,0 +1,36 @@ +// Copyright 2024 Mik Bry +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Bot, Notebook, User } from 'lucide-react'; +import AvatarView from '@/components/common/AvatarView'; +import OpenAI from '@/components/icons/OpenAI'; +import { Avatar } from '@/types'; + +function AvatarIcon({ isUser, avatar }: { isUser: boolean; avatar: Avatar }) { + let icon: React.ReactNode; + const className = 'h-4 w-4 text-muted-foreground'; + if (isUser) { + icon = ; + } else if (avatar.name?.toLowerCase().startsWith('gpt-')) { + icon = ; + } else if (avatar.name === 'Note') { + icon = ; + } else { + icon = ; + } + + return ; +} + +export default AvatarIcon; diff --git a/webapp/features/Threads/Conversation/Message/index.tsx b/webapp/features/Threads/Conversation/Message/index.tsx new file mode 100644 index 00000000..2d07175c --- /dev/null +++ b/webapp/features/Threads/Conversation/Message/index.tsx @@ -0,0 +1,232 @@ +// Copyright 2023 Mik Bry +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; + +import { MoreHorizontal, File } from 'lucide-react'; +import { + getMessageContentAuthorAsString, + getMessageContentHistoryAsString, +} from '@/utils/data/messages'; +import useHover from '@/hooks/useHover'; +import useMarkdownProcessor from '@/hooks/useMarkdownProcessor/index'; +import { Asset, Avatar, AvatarRef, MessageImpl, MessageStatus } from '@/types'; +import useTranslation from '@/hooks/useTranslation'; +import { getFilename } from '@/utils/misc'; +import { cn } from '@/lib/utils'; +import { Textarea } from '../../../../components/ui/textarea'; +import AvatarIcon from './AvatarIcon'; +import { DisplayMessageState } from './types'; +import Actions from './Actions'; + +type MessageComponentProps = { + message: MessageImpl; + asset?: Asset; + index: number; + avatars: AvatarRef[]; + disabled?: boolean; + edit: boolean; + onStartEdit: (messageId: string, index: number) => void; + onCancelMessageEdit: () => void; + onResendMessage: () => void; + onDeleteMessage: () => void; + onDeleteAssets: () => void; + onChangeContent: (content: string, submit: boolean) => void; + onCopyMessage: (messageId: string, state: boolean) => void; + onCancelSending: () => void; +}; + +function MessageComponent({ + message, + asset, + index, + avatars, + disabled = false, + edit, + onStartEdit, + onCancelMessageEdit, + onResendMessage, + onDeleteMessage, + onChangeContent, + onDeleteAssets, + onCopyMessage, + onCancelSending, +}: MessageComponentProps) { + const { t } = useTranslation(); + const [ref, isHover] = useHover(); + const inputRef = useRef(null); + const contentRef = useRef(null); + const [editValue, setEditValue] = useState(undefined); + const [current, setCurrent] = useState(0); + + const author = getMessageContentAuthorAsString(message, current); + const avatar = + avatars.find( + (a) => + author.metadata?.assistantId === a.ref || + author.metadata?.modelId === a.ref || + a.name === author.name, + ) || ({ name: author.name } as Avatar); + const isUser = author.role === 'user'; + + const content = getMessageContentHistoryAsString( + message, + current, + !isUser || editValue !== undefined, + ); + const { Content, MarkDownContext } = useMarkdownProcessor(content || ''); + + const handleEdit = useCallback(() => { + const raw = getMessageContentHistoryAsString(message, current, true); + setEditValue(raw); + onStartEdit(message.id, index); + }, [message, current, onStartEdit, index]); + + useEffect(() => { + if (edit && editValue === undefined) { + handleEdit(); + } + }, [edit, handleEdit, editValue]); + + const handleSave = () => { + const newContent = inputRef.current?.value; + if (newContent && content !== newContent) { + onChangeContent(newContent, isUser); + } + setEditValue(undefined); + onCancelMessageEdit(); + }; + + const handleCancelEdit = () => { + setEditValue(undefined); + onCancelMessageEdit(); + }; + + let state = DisplayMessageState.Markdown; + if (message.assets) { + state = DisplayMessageState.FileAsset; + } else if (editValue !== undefined) { + state = DisplayMessageState.Edit; + } else if (isUser) { + state = DisplayMessageState.Text; + } else if (author.role === 'note') { + state = DisplayMessageState.Note; + } else if (message.status === MessageStatus.Pending || content === '...') { + state = DisplayMessageState.Pending; + } else if (message.status === MessageStatus.Stream) { + state = DisplayMessageState.Streaming; + } + + const memoizedContent = useMemo(() => ({ content }), [content]); + return ( + +
+
+
+
+
+ +
+
+
+
+
+
+ {state !== DisplayMessageState.Note && ( +

{avatar.name}

+ )} + {state === DisplayMessageState.FileAsset && ( +
+ + + {t('Document added')}:{' '} + {asset?.type === 'file' ? getFilename(asset?.file) : t('Not found')} + +
+ )} + {state === DisplayMessageState.Pending && ( +
+ +
+ )} + {(state === DisplayMessageState.Markdown || + state === DisplayMessageState.Note || + state === DisplayMessageState.Streaming) && ( +
+ {Content} +
+ )} + {state === DisplayMessageState.Text && ( +
+ {content} +
+ )} + {state === DisplayMessageState.Edit && ( +
+