enh: reply to message

This commit is contained in:
Timothy Jaeryang Baek
2025-09-27 04:05:12 -05:00
parent d7c54d92b5
commit 1a18928c94
10 changed files with 318 additions and 84 deletions

View File

@@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
};
type MessageForm = {
reply_to_id?: string;
parent_id?: string;
content: string;
data?: object;

View File

@@ -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}

View File

@@ -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) => {

View File

@@ -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);
}}

View File

@@ -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>

View File

@@ -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

View File

@@ -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
>