enh: reply to message
This commit is contained in:
		
							parent
							
								
									d7c54d92b5
								
							
						
					
					
						commit
						1a18928c94
					
				|  | @ -0,0 +1,34 @@ | |||
| """Add reply_to_id column to message | ||||
| 
 | ||||
| Revision ID: a5c220713937 | ||||
| Revises: 38d63c18f30f | ||||
| Create Date: 2025-09-27 02:24:18.058455 | ||||
| 
 | ||||
| """ | ||||
| 
 | ||||
| from typing import Sequence, Union | ||||
| 
 | ||||
| from alembic import op | ||||
| import sqlalchemy as sa | ||||
| 
 | ||||
| # revision identifiers, used by Alembic. | ||||
| revision: str = "a5c220713937" | ||||
| down_revision: Union[str, None] = "38d63c18f30f" | ||||
| branch_labels: Union[str, Sequence[str], None] = None | ||||
| depends_on: Union[str, Sequence[str], None] = None | ||||
| 
 | ||||
| 
 | ||||
| def upgrade() -> None: | ||||
|     # Add 'reply_to_id' column to the 'message' table for replying to messages | ||||
|     op.add_column( | ||||
|         "message", | ||||
|         sa.Column("reply_to_id", sa.Text(), nullable=True), | ||||
|     ) | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| def downgrade() -> None: | ||||
|     # Remove 'reply_to_id' column from the 'message' table | ||||
|     op.drop_column("message", "reply_to_id") | ||||
| 
 | ||||
|     pass | ||||
|  | @ -5,6 +5,7 @@ from typing import Optional | |||
| 
 | ||||
| from open_webui.internal.db import Base, get_db | ||||
| from open_webui.models.tags import TagModel, Tag, Tags | ||||
| from open_webui.models.users import Users, UserNameResponse | ||||
| 
 | ||||
| 
 | ||||
| from pydantic import BaseModel, ConfigDict | ||||
|  | @ -43,6 +44,7 @@ class Message(Base): | |||
|     user_id = Column(Text) | ||||
|     channel_id = Column(Text, nullable=True) | ||||
| 
 | ||||
|     reply_to_id = Column(Text, nullable=True) | ||||
|     parent_id = Column(Text, nullable=True) | ||||
| 
 | ||||
|     content = Column(Text) | ||||
|  | @ -60,6 +62,7 @@ class MessageModel(BaseModel): | |||
|     user_id: str | ||||
|     channel_id: Optional[str] = None | ||||
| 
 | ||||
|     reply_to_id: Optional[str] = None | ||||
|     parent_id: Optional[str] = None | ||||
| 
 | ||||
|     content: str | ||||
|  | @ -77,6 +80,7 @@ class MessageModel(BaseModel): | |||
| 
 | ||||
| class MessageForm(BaseModel): | ||||
|     content: str | ||||
|     reply_to_id: Optional[str] = None | ||||
|     parent_id: Optional[str] = None | ||||
|     data: Optional[dict] = None | ||||
|     meta: Optional[dict] = None | ||||
|  | @ -88,7 +92,15 @@ class Reactions(BaseModel): | |||
|     count: int | ||||
| 
 | ||||
| 
 | ||||
| class MessageResponse(MessageModel): | ||||
| class MessageUserResponse(MessageModel): | ||||
|     user: Optional[UserNameResponse] = None | ||||
| 
 | ||||
| 
 | ||||
| class MessageReplyToResponse(MessageUserResponse): | ||||
|     reply_to_message: Optional[MessageUserResponse] = None | ||||
| 
 | ||||
| 
 | ||||
| class MessageResponse(MessageReplyToResponse): | ||||
|     latest_reply_at: Optional[int] | ||||
|     reply_count: int | ||||
|     reactions: list[Reactions] | ||||
|  | @ -107,6 +119,7 @@ class MessageTable: | |||
|                     "id": id, | ||||
|                     "user_id": user_id, | ||||
|                     "channel_id": channel_id, | ||||
|                     "reply_to_id": form_data.reply_to_id, | ||||
|                     "parent_id": form_data.parent_id, | ||||
|                     "content": form_data.content, | ||||
|                     "data": form_data.data, | ||||
|  | @ -122,25 +135,36 @@ class MessageTable: | |||
|             db.refresh(result) | ||||
|             return MessageModel.model_validate(result) if result else None | ||||
| 
 | ||||
|     def get_message_by_id(self, id: str) -> Optional[MessageResponse]: | ||||
|     def get_message_by_id(self, id: str) -> Optional[MessageReplyToResponse]: | ||||
|         with get_db() as db: | ||||
|             message = db.get(Message, id) | ||||
|             if not message: | ||||
|                 return None | ||||
| 
 | ||||
|             reply_to_message = ( | ||||
|                 self.get_message_by_id(message.reply_to_id) | ||||
|                 if message.reply_to_id | ||||
|                 else None | ||||
|             ) | ||||
|             reactions = self.get_reactions_by_message_id(id) | ||||
|             replies = self.get_replies_by_message_id(id) | ||||
|             replies = self.get_thread_replies_by_message_id(id) | ||||
| 
 | ||||
|             return MessageResponse( | ||||
|                 **{ | ||||
|             user = Users.get_user_by_id(message.user_id) | ||||
| 
 | ||||
|             return MessageReplyToResponse.model_validate( | ||||
|                 { | ||||
|                     **MessageModel.model_validate(message).model_dump(), | ||||
|                     "user": user.model_dump() if user else None, | ||||
|                     "reply_to_message": ( | ||||
|                         reply_to_message.model_dump() if reply_to_message else None | ||||
|                     ), | ||||
|                     "latest_reply_at": replies[0].created_at if replies else None, | ||||
|                     "reply_count": len(replies), | ||||
|                     "reactions": reactions, | ||||
|                 } | ||||
|             ) | ||||
| 
 | ||||
|     def get_replies_by_message_id(self, id: str) -> list[MessageModel]: | ||||
|     def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]: | ||||
|         with get_db() as db: | ||||
|             all_messages = ( | ||||
|                 db.query(Message) | ||||
|  | @ -148,7 +172,19 @@ class MessageTable: | |||
|                 .order_by(Message.created_at.desc()) | ||||
|                 .all() | ||||
|             ) | ||||
|             return [MessageModel.model_validate(message) for message in all_messages] | ||||
|             return [ | ||||
|                 MessageReplyToResponse.model_validate( | ||||
|                     { | ||||
|                         **MessageModel.model_validate(message).model_dump(), | ||||
|                         "reply_to_message": ( | ||||
|                             self.get_message_by_id(message.reply_to_id).model_dump() | ||||
|                             if message.reply_to_id | ||||
|                             else None | ||||
|                         ), | ||||
|                     } | ||||
|                 ) | ||||
|                 for message in all_messages | ||||
|             ] | ||||
| 
 | ||||
|     def get_reply_user_ids_by_message_id(self, id: str) -> list[str]: | ||||
|         with get_db() as db: | ||||
|  | @ -159,7 +195,7 @@ class MessageTable: | |||
| 
 | ||||
|     def get_messages_by_channel_id( | ||||
|         self, channel_id: str, skip: int = 0, limit: int = 50 | ||||
|     ) -> list[MessageModel]: | ||||
|     ) -> list[MessageReplyToResponse]: | ||||
|         with get_db() as db: | ||||
|             all_messages = ( | ||||
|                 db.query(Message) | ||||
|  | @ -169,7 +205,20 @@ class MessageTable: | |||
|                 .limit(limit) | ||||
|                 .all() | ||||
|             ) | ||||
|             return [MessageModel.model_validate(message) for message in all_messages] | ||||
| 
 | ||||
|             return [ | ||||
|                 MessageReplyToResponse.model_validate( | ||||
|                     { | ||||
|                         **MessageModel.model_validate(message).model_dump(), | ||||
|                         "reply_to_message": ( | ||||
|                             self.get_message_by_id(message.reply_to_id).model_dump() | ||||
|                             if message.reply_to_id | ||||
|                             else None | ||||
|                         ), | ||||
|                     } | ||||
|                 ) | ||||
|                 for message in all_messages | ||||
|             ] | ||||
| 
 | ||||
|     def get_messages_by_parent_id( | ||||
|         self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50 | ||||
|  |  | |||
|  | @ -167,7 +167,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)): | |||
| 
 | ||||
| 
 | ||||
| class MessageUserResponse(MessageResponse): | ||||
|     user: UserNameResponse | ||||
|     pass | ||||
| 
 | ||||
| 
 | ||||
| @router.get("/{id}/messages", response_model=list[MessageUserResponse]) | ||||
|  | @ -196,15 +196,17 @@ async def get_channel_messages( | |||
|             user = Users.get_user_by_id(message.user_id) | ||||
|             users[message.user_id] = user | ||||
| 
 | ||||
|         replies = Messages.get_replies_by_message_id(message.id) | ||||
|         latest_reply_at = replies[0].created_at if replies else None | ||||
|         thread_replies = Messages.get_thread_replies_by_message_id(message.id) | ||||
|         latest_thread_reply_at = ( | ||||
|             thread_replies[0].created_at if thread_replies else None | ||||
|         ) | ||||
| 
 | ||||
|         messages.append( | ||||
|             MessageUserResponse( | ||||
|                 **{ | ||||
|                     **message.model_dump(), | ||||
|                     "reply_count": len(replies), | ||||
|                     "latest_reply_at": latest_reply_at, | ||||
|                     "reply_count": len(thread_replies), | ||||
|                     "latest_reply_at": latest_thread_reply_at, | ||||
|                     "reactions": Messages.get_reactions_by_message_id(message.id), | ||||
|                     "user": UserNameResponse(**users[message.user_id].model_dump()), | ||||
|                 } | ||||
|  | @ -253,12 +255,26 @@ async def model_response_handler(request, channel, message, user): | |||
|     mentions = extract_mentions(message.content) | ||||
|     message_content = replace_mentions(message.content) | ||||
| 
 | ||||
|     model_mentions = {} | ||||
| 
 | ||||
|     # check if the message is a reply to a message sent by a model | ||||
|     if ( | ||||
|         message.reply_to_message | ||||
|         and message.reply_to_message.meta | ||||
|         and message.reply_to_message.meta.get("model_id", None) | ||||
|     ): | ||||
|         model_id = message.reply_to_message.meta.get("model_id", None) | ||||
|         model_mentions[model_id] = {"id": model_id, "id_type": "M"} | ||||
| 
 | ||||
|     # check if any of the mentions are models | ||||
|     model_mentions = [mention for mention in mentions if mention["id_type"] == "M"] | ||||
|     for mention in mentions: | ||||
|         if mention["id_type"] == "M" and mention["id"] not in model_mentions: | ||||
|             model_mentions[mention["id"]] = mention | ||||
| 
 | ||||
|     if not model_mentions: | ||||
|         return False | ||||
| 
 | ||||
|     for mention in model_mentions: | ||||
|     for mention in model_mentions.values(): | ||||
|         model_id = mention["id"] | ||||
|         model = MODELS.get(model_id, None) | ||||
| 
 | ||||
|  | @ -406,24 +422,14 @@ async def new_message_handler( | |||
| 
 | ||||
|     try: | ||||
|         message = Messages.insert_new_message(form_data, channel.id, user.id) | ||||
| 
 | ||||
|         if message: | ||||
|             message = Messages.get_message_by_id(message.id) | ||||
|             event_data = { | ||||
|                 "channel_id": channel.id, | ||||
|                 "message_id": message.id, | ||||
|                 "data": { | ||||
|                     "type": "message", | ||||
|                     "data": MessageUserResponse( | ||||
|                         **{ | ||||
|                             **message.model_dump(), | ||||
|                             "reply_count": 0, | ||||
|                             "latest_reply_at": None, | ||||
|                             "reactions": Messages.get_reactions_by_message_id( | ||||
|                                 message.id | ||||
|                             ), | ||||
|                             "user": UserNameResponse(**user.model_dump()), | ||||
|                         } | ||||
|                     ).model_dump(), | ||||
|                     "data": message.model_dump(), | ||||
|                 }, | ||||
|                 "user": UserNameResponse(**user.model_dump()).model_dump(), | ||||
|                 "channel": channel.model_dump(), | ||||
|  | @ -447,23 +453,16 @@ async def new_message_handler( | |||
|                             "message_id": parent_message.id, | ||||
|                             "data": { | ||||
|                                 "type": "message:reply", | ||||
|                                 "data": MessageUserResponse( | ||||
|                                     **{ | ||||
|                                         **parent_message.model_dump(), | ||||
|                                         "user": UserNameResponse( | ||||
|                                             **Users.get_user_by_id( | ||||
|                                                 parent_message.user_id | ||||
|                                             ).model_dump() | ||||
|                                         ), | ||||
|                                     } | ||||
|                                 ).model_dump(), | ||||
|                                 "data": parent_message.model_dump(), | ||||
|                             }, | ||||
|                             "user": UserNameResponse(**user.model_dump()).model_dump(), | ||||
|                             "channel": channel.model_dump(), | ||||
|                         }, | ||||
|                         to=f"channel:{channel.id}", | ||||
|                     ) | ||||
|         return MessageModel(**message.model_dump()), channel | ||||
|             return message, channel | ||||
|         else: | ||||
|             raise Exception("Error creating message") | ||||
|     except Exception as e: | ||||
|         log.exception(e) | ||||
|         raise HTTPException( | ||||
|  | @ -651,14 +650,7 @@ async def update_message_by_id( | |||
|                     "message_id": message.id, | ||||
|                     "data": { | ||||
|                         "type": "message:update", | ||||
|                         "data": MessageUserResponse( | ||||
|                             **{ | ||||
|                                 **message.model_dump(), | ||||
|                                 "user": UserNameResponse( | ||||
|                                     **user.model_dump() | ||||
|                                 ).model_dump(), | ||||
|                             } | ||||
|                         ).model_dump(), | ||||
|                         "data": message.model_dump(), | ||||
|                     }, | ||||
|                     "user": UserNameResponse(**user.model_dump()).model_dump(), | ||||
|                     "channel": channel.model_dump(), | ||||
|  | @ -724,9 +716,6 @@ async def add_reaction_to_message( | |||
|                     "type": "message:reaction:add", | ||||
|                     "data": { | ||||
|                         **message.model_dump(), | ||||
|                         "user": UserNameResponse( | ||||
|                             **Users.get_user_by_id(message.user_id).model_dump() | ||||
|                         ).model_dump(), | ||||
|                         "name": form_data.name, | ||||
|                     }, | ||||
|                 }, | ||||
|  | @ -793,9 +782,6 @@ async def remove_reaction_by_id_and_user_id_and_name( | |||
|                     "type": "message:reaction:remove", | ||||
|                     "data": { | ||||
|                         **message.model_dump(), | ||||
|                         "user": UserNameResponse( | ||||
|                             **Users.get_user_by_id(message.user_id).model_dump() | ||||
|                         ).model_dump(), | ||||
|                         "name": form_data.name, | ||||
|                     }, | ||||
|                 }, | ||||
|  | @ -882,16 +868,7 @@ async def delete_message_by_id( | |||
|                         "message_id": parent_message.id, | ||||
|                         "data": { | ||||
|                             "type": "message:reply", | ||||
|                             "data": MessageUserResponse( | ||||
|                                 **{ | ||||
|                                     **parent_message.model_dump(), | ||||
|                                     "user": UserNameResponse( | ||||
|                                         **Users.get_user_by_id( | ||||
|                                             parent_message.user_id | ||||
|                                         ).model_dump() | ||||
|                                     ), | ||||
|                                 } | ||||
|                             ).model_dump(), | ||||
|                             "data": parent_message.model_dump(), | ||||
|                         }, | ||||
|                         "user": UserNameResponse(**user.model_dump()).model_dump(), | ||||
|                         "channel": channel.model_dump(), | ||||
|  |  | |||
|  | @ -248,6 +248,7 @@ export const getChannelThreadMessages = async ( | |||
| }; | ||||
| 
 | ||||
| type MessageForm = { | ||||
| 	reply_to_id?: string; | ||||
| 	parent_id?: string; | ||||
| 	content: string; | ||||
| 	data?: object; | ||||
|  |  | |||
|  | @ -20,12 +20,14 @@ | |||
| 
 | ||||
| 	let scrollEnd = true; | ||||
| 	let messagesContainerElement = null; | ||||
| 	let chatInputElement = null; | ||||
| 
 | ||||
| 	let top = false; | ||||
| 
 | ||||
| 	let channel = null; | ||||
| 	let messages = null; | ||||
| 
 | ||||
| 	let replyToMessage = null; | ||||
| 	let threadId = null; | ||||
| 
 | ||||
| 	let typingUsers = []; | ||||
|  | @ -141,16 +143,20 @@ | |||
| 			return; | ||||
| 		} | ||||
| 
 | ||||
| 		const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch( | ||||
| 			(error) => { | ||||
| 				toast.error(`${error}`); | ||||
| 				return null; | ||||
| 			} | ||||
| 		); | ||||
| 		const res = await sendMessage(localStorage.token, id, { | ||||
| 			content: content, | ||||
| 			data: data, | ||||
| 			reply_to_id: replyToMessage?.id ?? null | ||||
| 		}).catch((error) => { | ||||
| 			toast.error(`${error}`); | ||||
| 			return null; | ||||
| 		}); | ||||
| 
 | ||||
| 		if (res) { | ||||
| 			messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; | ||||
| 		} | ||||
| 
 | ||||
| 		replyToMessage = null; | ||||
| 	}; | ||||
| 
 | ||||
| 	const onChange = async () => { | ||||
|  | @ -222,8 +228,14 @@ | |||
| 						{#key id} | ||||
| 							<Messages | ||||
| 								{channel} | ||||
| 								{messages} | ||||
| 								{top} | ||||
| 								{messages} | ||||
| 								{replyToMessage} | ||||
| 								onReply={async (message) => { | ||||
| 									replyToMessage = message; | ||||
| 									await tick(); | ||||
| 									chatInputElement?.focus(); | ||||
| 								}} | ||||
| 								onThread={(id) => { | ||||
| 									threadId = id; | ||||
| 								}} | ||||
|  | @ -250,6 +262,8 @@ | |||
| 			<div class=" pb-[1rem] px-2.5"> | ||||
| 				<MessageInput | ||||
| 					id="root" | ||||
| 					bind:chatInputElement | ||||
| 					bind:replyToMessage | ||||
| 					{typingUsers} | ||||
| 					userSuggestions={true} | ||||
| 					channelSuggestions={true} | ||||
|  |  | |||
|  | @ -23,20 +23,23 @@ | |||
| 
 | ||||
| 	import { getSessionUser } from '$lib/apis/auths'; | ||||
| 
 | ||||
| 	import { uploadFile } from '$lib/apis/files'; | ||||
| 	import { WEBUI_API_BASE_URL } from '$lib/constants'; | ||||
| 
 | ||||
| 	import { getSuggestionRenderer } from '../common/RichTextInput/suggestions'; | ||||
| 	import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte'; | ||||
| 
 | ||||
| 	import InputMenu from './MessageInput/InputMenu.svelte'; | ||||
| 	import Tooltip from '../common/Tooltip.svelte'; | ||||
| 	import RichTextInput from '../common/RichTextInput.svelte'; | ||||
| 	import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; | ||||
| 	import InputMenu from './MessageInput/InputMenu.svelte'; | ||||
| 	import { uploadFile } from '$lib/apis/files'; | ||||
| 	import { WEBUI_API_BASE_URL } from '$lib/constants'; | ||||
| 	import FileItem from '../common/FileItem.svelte'; | ||||
| 	import Image from '../common/Image.svelte'; | ||||
| 	import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; | ||||
| 	import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte'; | ||||
| 	import { getSuggestionRenderer } from '../common/RichTextInput/suggestions'; | ||||
| 	import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte'; | ||||
| 	import MentionList from './MessageInput/MentionList.svelte'; | ||||
| 	import Skeleton from '../chat/Messages/Skeleton.svelte'; | ||||
| 	import XMark from '../icons/XMark.svelte'; | ||||
| 
 | ||||
| 	export let placeholder = $i18n.t('Type here...'); | ||||
| 
 | ||||
|  | @ -60,6 +63,8 @@ | |||
| 	export let userSuggestions = false; | ||||
| 	export let channelSuggestions = false; | ||||
| 
 | ||||
| 	export let replyToMessage = null; | ||||
| 
 | ||||
| 	export let typingUsersClassName = 'from-white dark:from-gray-900'; | ||||
| 
 | ||||
| 	let loaded = false; | ||||
|  | @ -773,6 +778,32 @@ | |||
| 							class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100" | ||||
| 							dir={$settings?.chatDirection ?? 'auto'} | ||||
| 						> | ||||
| 							{#if replyToMessage !== null} | ||||
| 								<div class="px-3 pt-3 text-left w-full flex flex-col z-10"> | ||||
| 									<div class="flex items-center justify-between w-full"> | ||||
| 										<div class="pl-[1px] flex items-center gap-2 text-sm"> | ||||
| 											<div class="translate-y-[0.5px]"> | ||||
| 												<span class="" | ||||
| 													>{$i18n.t('Replying to {{NAME}}', { | ||||
| 														NAME: replyToMessage?.meta?.model_name ?? replyToMessage.user.name | ||||
| 													})}</span | ||||
| 												> | ||||
| 											</div> | ||||
| 										</div> | ||||
| 										<div> | ||||
| 											<button | ||||
| 												class="flex items-center dark:text-gray-500" | ||||
| 												on:click={() => { | ||||
| 													replyToMessage = null; | ||||
| 												}} | ||||
| 											> | ||||
| 												<XMark /> | ||||
| 											</button> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							{/if} | ||||
| 
 | ||||
| 							{#if files.length > 0} | ||||
| 								<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2"> | ||||
| 									{#each files as file, fileIdx} | ||||
|  | @ -890,6 +921,7 @@ | |||
| 
 | ||||
| 												if (e.key === 'Escape') { | ||||
| 													console.info('Escape'); | ||||
| 													replyToMessage = null; | ||||
| 												} | ||||
| 											}} | ||||
| 											on:paste={async (e) => { | ||||
|  |  | |||
|  | @ -23,10 +23,12 @@ | |||
| 	export let id = null; | ||||
| 	export let channel = null; | ||||
| 	export let messages = []; | ||||
| 	export let replyToMessage = null; | ||||
| 	export let top = false; | ||||
| 	export let thread = false; | ||||
| 
 | ||||
| 	export let onLoad: Function = () => {}; | ||||
| 	export let onReply: Function = () => {}; | ||||
| 	export let onThread: Function = () => {}; | ||||
| 
 | ||||
| 	let messagesLoading = false; | ||||
|  | @ -94,10 +96,12 @@ | |||
| 			<Message | ||||
| 				{message} | ||||
| 				{thread} | ||||
| 				replyToMessage={replyToMessage?.id === message.id} | ||||
| 				disabled={!channel?.write_access} | ||||
| 				showUserProfile={messageIdx === 0 || | ||||
| 					messageList.at(messageIdx - 1)?.user_id !== message.user_id || | ||||
| 					messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id} | ||||
| 					messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id || | ||||
| 					message?.reply_to_message} | ||||
| 				onDelete={() => { | ||||
| 					messages = messages.filter((m) => m.id !== message.id); | ||||
| 
 | ||||
|  | @ -123,6 +127,9 @@ | |||
| 						return null; | ||||
| 					}); | ||||
| 				}} | ||||
| 				onReply={(message) => { | ||||
| 					onReply(message); | ||||
| 				}} | ||||
| 				onThread={(id) => { | ||||
| 					onThread(id); | ||||
| 				}} | ||||
|  |  | |||
|  | @ -13,8 +13,9 @@ | |||
| 	import { getContext, onMount } from 'svelte'; | ||||
| 	const i18n = getContext<Writable<i18nType>>('i18n'); | ||||
| 
 | ||||
| 	import { settings, user, shortCodesToEmojis } from '$lib/stores'; | ||||
| 	import { formatDate } from '$lib/utils'; | ||||
| 
 | ||||
| 	import { settings, user, shortCodesToEmojis } from '$lib/stores'; | ||||
| 	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; | ||||
| 
 | ||||
| 	import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; | ||||
|  | @ -32,18 +33,20 @@ | |||
| 	import FaceSmile from '$lib/components/icons/FaceSmile.svelte'; | ||||
| 	import EmojiPicker from '$lib/components/common/EmojiPicker.svelte'; | ||||
| 	import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; | ||||
| 	import { formatDate } from '$lib/utils'; | ||||
| 	import Emoji from '$lib/components/common/Emoji.svelte'; | ||||
| 	import { t } from 'i18next'; | ||||
| 	import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; | ||||
| 	import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte'; | ||||
| 
 | ||||
| 	export let message; | ||||
| 	export let showUserProfile = true; | ||||
| 	export let thread = false; | ||||
| 
 | ||||
| 	export let replyToMessage = false; | ||||
| 	export let disabled = false; | ||||
| 
 | ||||
| 	export let onDelete: Function = () => {}; | ||||
| 	export let onEdit: Function = () => {}; | ||||
| 	export let onReply: Function = () => {}; | ||||
| 	export let onThread: Function = () => {}; | ||||
| 	export let onReaction: Function = () => {}; | ||||
| 
 | ||||
|  | @ -65,9 +68,15 @@ | |||
| 
 | ||||
| {#if message} | ||||
| 	<div | ||||
| 		id="message-{message.id}" | ||||
| 		class="flex flex-col justify-between px-5 {showUserProfile | ||||
| 			? 'pt-1.5 pb-0.5' | ||||
| 			: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative" | ||||
| 			: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage | ||||
| 			? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' | ||||
| 			: ''} {(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) === | ||||
| 		$user?.id | ||||
| 			? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4' | ||||
| 			: ''}" | ||||
| 	> | ||||
| 		{#if !edit && !disabled} | ||||
| 			<div | ||||
|  | @ -95,6 +104,17 @@ | |||
| 						</Tooltip> | ||||
| 					</EmojiPicker> | ||||
| 
 | ||||
| 					<Tooltip content={$i18n.t('Reply')}> | ||||
| 						<button | ||||
| 							class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5" | ||||
| 							on:click={() => { | ||||
| 								onReply(message); | ||||
| 							}} | ||||
| 						> | ||||
| 							<ArrowUpLeftAlt className="size-5" /> | ||||
| 						</button> | ||||
| 					</Tooltip> | ||||
| 
 | ||||
| 					{#if !thread} | ||||
| 						<Tooltip content={$i18n.t('Reply in Thread')}> | ||||
| 							<button | ||||
|  | @ -134,6 +154,56 @@ | |||
| 			</div> | ||||
| 		{/if} | ||||
| 
 | ||||
| 		{#if message?.reply_to_message?.user} | ||||
| 			<div class="relative text-xs mb-1"> | ||||
| 				<div | ||||
| 					class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-2 border-l-2 border-gray-300 dark:border-gray-500 z-0" | ||||
| 				></div> | ||||
| 
 | ||||
| 				<button | ||||
| 					class="ml-12 flex items-center space-x-2 relative z-0" | ||||
| 					on:click={() => { | ||||
| 						const messageElement = document.getElementById( | ||||
| 							`message-${message.reply_to_message.id}` | ||||
| 						); | ||||
| 						if (messageElement) { | ||||
| 							messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); | ||||
| 							messageElement.classList.add('highlight'); | ||||
| 							setTimeout(() => { | ||||
| 								messageElement.classList.remove('highlight'); | ||||
| 							}, 2000); | ||||
| 							return; | ||||
| 						} | ||||
| 					}} | ||||
| 				> | ||||
| 					{#if message?.reply_to_message?.meta?.model_id} | ||||
| 						<img | ||||
| 							src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`} | ||||
| 							alt={message.reply_to_message.meta.model_name ?? | ||||
| 								message.reply_to_message.meta.model_id} | ||||
| 							class="size-4 ml-0.5 rounded-full object-cover" | ||||
| 						/> | ||||
| 					{:else} | ||||
| 						<img | ||||
| 							src={message.reply_to_message.user?.profile_image_url ?? | ||||
| 								`${WEBUI_BASE_URL}/static/favicon.png`} | ||||
| 							alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')} | ||||
| 							class="size-4 ml-0.5 rounded-full object-cover" | ||||
| 						/> | ||||
| 					{/if} | ||||
| 
 | ||||
| 					<div class="shrink-0"> | ||||
| 						{message?.reply_to_message.meta?.model_name ?? | ||||
| 							message?.reply_to_message.user?.name ?? | ||||
| 							$i18n.t('Unknown User')} | ||||
| 					</div> | ||||
| 
 | ||||
| 					<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1"> | ||||
| 						<Markdown id={`${message.id}-reply-to`} content={message?.reply_to_message?.content} /> | ||||
| 					</div> | ||||
| 				</button> | ||||
| 			</div> | ||||
| 		{/if} | ||||
| 		<div | ||||
| 			class=" flex w-full message-{message.id}" | ||||
| 			id="message-{message.id}" | ||||
|  | @ -151,7 +221,7 @@ | |||
| 						<ProfilePreview user={message.user}> | ||||
| 							<ProfileImage | ||||
| 								src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`} | ||||
| 								className={'size-8 translate-y-1 ml-0.5'} | ||||
| 								className={'size-8 ml-0.5'} | ||||
| 							/> | ||||
| 						</ProfilePreview> | ||||
| 					{/if} | ||||
|  | @ -348,3 +418,18 @@ | |||
| 		</div> | ||||
| 	</div> | ||||
| {/if} | ||||
| 
 | ||||
| <style> | ||||
| 	.highlight { | ||||
| 		animation: highlightAnimation 2s ease-in-out; | ||||
| 	} | ||||
| 
 | ||||
| 	@keyframes highlightAnimation { | ||||
| 		0% { | ||||
| 			background-color: rgba(0, 60, 255, 0.1); | ||||
| 		} | ||||
| 		100% { | ||||
| 			background-color: transparent; | ||||
| 		} | ||||
| 	} | ||||
| </style> | ||||
|  |  | |||
|  | @ -22,11 +22,14 @@ | |||
| 	let messages = null; | ||||
| 	let top = false; | ||||
| 
 | ||||
| 	let messagesContainerElement = null; | ||||
| 	let chatInputElement = null; | ||||
| 
 | ||||
| 	let replyToMessage = null; | ||||
| 
 | ||||
| 	let typingUsers = []; | ||||
| 	let typingUsersTimeout = {}; | ||||
| 
 | ||||
| 	let messagesContainerElement = null; | ||||
| 
 | ||||
| 	$: if (threadId) { | ||||
| 		initHandler(); | ||||
| 	} | ||||
|  | @ -128,12 +131,15 @@ | |||
| 
 | ||||
| 		const res = await sendMessage(localStorage.token, channel.id, { | ||||
| 			parent_id: threadId, | ||||
| 			reply_to_id: replyToMessage?.id ?? null, | ||||
| 			content: content, | ||||
| 			data: data | ||||
| 		}).catch((error) => { | ||||
| 			toast.error(`${error}`); | ||||
| 			return null; | ||||
| 		}); | ||||
| 
 | ||||
| 		replyToMessage = null; | ||||
| 	}; | ||||
| 
 | ||||
| 	const onChange = async () => { | ||||
|  | @ -180,9 +186,16 @@ | |||
| 				<Messages | ||||
| 					id={threadId} | ||||
| 					{channel} | ||||
| 					{messages} | ||||
| 					{top} | ||||
| 					{messages} | ||||
| 					{replyToMessage} | ||||
| 					thread={true} | ||||
| 					onReply={async (message) => { | ||||
| 						replyToMessage = message; | ||||
| 
 | ||||
| 						await tick(); | ||||
| 						chatInputElement?.focus(); | ||||
| 					}} | ||||
| 					onLoad={async () => { | ||||
| 						const newMessages = await getChannelThreadMessages( | ||||
| 							localStorage.token, | ||||
|  | @ -207,6 +220,8 @@ | |||
| 
 | ||||
| 			<div class=" pb-[1rem] px-2.5 w-full"> | ||||
| 				<MessageInput | ||||
| 					bind:replyToMessage | ||||
| 					bind:chatInputElement | ||||
| 					id={threadId} | ||||
| 					disabled={!channel?.write_access} | ||||
| 					placeholder={!channel?.write_access | ||||
|  |  | |||
|  | @ -0,0 +1,20 @@ | |||
| <script lang="ts"> | ||||
| 	export let className = 'w-4 h-4'; | ||||
| 	export let strokeWidth = '1.5'; | ||||
| </script> | ||||
| 
 | ||||
| <svg | ||||
| 	class={className} | ||||
| 	aria-hidden="true" | ||||
| 	xmlns="http://www.w3.org/2000/svg" | ||||
| 	stroke-width={strokeWidth} | ||||
| 	fill="none" | ||||
| 	stroke="currentColor" | ||||
| 	viewBox="0 0 24 24" | ||||
| 	><path d="M10.25 4.75L6.75 8.25L10.25 11.75" stroke-linecap="round" stroke-linejoin="round" | ||||
| 	></path><path | ||||
| 		d="M6.75 8.25L12.75 8.25C14.9591 8.25 16.75 10.0409 16.75 12.25V19.25" | ||||
| 		stroke-linecap="round" | ||||
| 		stroke-linejoin="round" | ||||
| 	></path></svg | ||||
| > | ||||
		Loading…
	
		Reference in New Issue