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.internal.db import Base, get_db
|
||||||
from open_webui.models.tags import TagModel, Tag, Tags
|
from open_webui.models.tags import TagModel, Tag, Tags
|
||||||
|
from open_webui.models.users import Users, UserNameResponse
|
||||||
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
@ -43,6 +44,7 @@ class Message(Base):
|
||||||
user_id = Column(Text)
|
user_id = Column(Text)
|
||||||
channel_id = Column(Text, nullable=True)
|
channel_id = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
reply_to_id = Column(Text, nullable=True)
|
||||||
parent_id = Column(Text, nullable=True)
|
parent_id = Column(Text, nullable=True)
|
||||||
|
|
||||||
content = Column(Text)
|
content = Column(Text)
|
||||||
|
@ -60,6 +62,7 @@ class MessageModel(BaseModel):
|
||||||
user_id: str
|
user_id: str
|
||||||
channel_id: Optional[str] = None
|
channel_id: Optional[str] = None
|
||||||
|
|
||||||
|
reply_to_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
|
|
||||||
content: str
|
content: str
|
||||||
|
@ -77,6 +80,7 @@ class MessageModel(BaseModel):
|
||||||
|
|
||||||
class MessageForm(BaseModel):
|
class MessageForm(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
|
reply_to_id: Optional[str] = None
|
||||||
parent_id: Optional[str] = None
|
parent_id: Optional[str] = None
|
||||||
data: Optional[dict] = None
|
data: Optional[dict] = None
|
||||||
meta: Optional[dict] = None
|
meta: Optional[dict] = None
|
||||||
|
@ -88,7 +92,15 @@ class Reactions(BaseModel):
|
||||||
count: int
|
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]
|
latest_reply_at: Optional[int]
|
||||||
reply_count: int
|
reply_count: int
|
||||||
reactions: list[Reactions]
|
reactions: list[Reactions]
|
||||||
|
@ -107,6 +119,7 @@ class MessageTable:
|
||||||
"id": id,
|
"id": id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
|
"reply_to_id": form_data.reply_to_id,
|
||||||
"parent_id": form_data.parent_id,
|
"parent_id": form_data.parent_id,
|
||||||
"content": form_data.content,
|
"content": form_data.content,
|
||||||
"data": form_data.data,
|
"data": form_data.data,
|
||||||
|
@ -122,25 +135,36 @@ class MessageTable:
|
||||||
db.refresh(result)
|
db.refresh(result)
|
||||||
return MessageModel.model_validate(result) if result else None
|
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:
|
with get_db() as db:
|
||||||
message = db.get(Message, id)
|
message = db.get(Message, id)
|
||||||
if not message:
|
if not message:
|
||||||
return None
|
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)
|
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(),
|
**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,
|
"latest_reply_at": replies[0].created_at if replies else None,
|
||||||
"reply_count": len(replies),
|
"reply_count": len(replies),
|
||||||
"reactions": reactions,
|
"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:
|
with get_db() as db:
|
||||||
all_messages = (
|
all_messages = (
|
||||||
db.query(Message)
|
db.query(Message)
|
||||||
|
@ -148,7 +172,19 @@ class MessageTable:
|
||||||
.order_by(Message.created_at.desc())
|
.order_by(Message.created_at.desc())
|
||||||
.all()
|
.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]:
|
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
|
@ -159,7 +195,7 @@ class MessageTable:
|
||||||
|
|
||||||
def get_messages_by_channel_id(
|
def get_messages_by_channel_id(
|
||||||
self, channel_id: str, skip: int = 0, limit: int = 50
|
self, channel_id: str, skip: int = 0, limit: int = 50
|
||||||
) -> list[MessageModel]:
|
) -> list[MessageReplyToResponse]:
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
all_messages = (
|
all_messages = (
|
||||||
db.query(Message)
|
db.query(Message)
|
||||||
|
@ -169,7 +205,20 @@ class MessageTable:
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.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(
|
def get_messages_by_parent_id(
|
||||||
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
|
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):
|
class MessageUserResponse(MessageResponse):
|
||||||
user: UserNameResponse
|
pass
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
|
@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)
|
user = Users.get_user_by_id(message.user_id)
|
||||||
users[message.user_id] = user
|
users[message.user_id] = user
|
||||||
|
|
||||||
replies = Messages.get_replies_by_message_id(message.id)
|
thread_replies = Messages.get_thread_replies_by_message_id(message.id)
|
||||||
latest_reply_at = replies[0].created_at if replies else None
|
latest_thread_reply_at = (
|
||||||
|
thread_replies[0].created_at if thread_replies else None
|
||||||
|
)
|
||||||
|
|
||||||
messages.append(
|
messages.append(
|
||||||
MessageUserResponse(
|
MessageUserResponse(
|
||||||
**{
|
**{
|
||||||
**message.model_dump(),
|
**message.model_dump(),
|
||||||
"reply_count": len(replies),
|
"reply_count": len(thread_replies),
|
||||||
"latest_reply_at": latest_reply_at,
|
"latest_reply_at": latest_thread_reply_at,
|
||||||
"reactions": Messages.get_reactions_by_message_id(message.id),
|
"reactions": Messages.get_reactions_by_message_id(message.id),
|
||||||
"user": UserNameResponse(**users[message.user_id].model_dump()),
|
"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)
|
mentions = extract_mentions(message.content)
|
||||||
message_content = replace_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
|
# 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:
|
if not model_mentions:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for mention in model_mentions:
|
for mention in model_mentions.values():
|
||||||
model_id = mention["id"]
|
model_id = mention["id"]
|
||||||
model = MODELS.get(model_id, None)
|
model = MODELS.get(model_id, None)
|
||||||
|
|
||||||
|
@ -406,24 +422,14 @@ async def new_message_handler(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = Messages.insert_new_message(form_data, channel.id, user.id)
|
message = Messages.insert_new_message(form_data, channel.id, user.id)
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
|
message = Messages.get_message_by_id(message.id)
|
||||||
event_data = {
|
event_data = {
|
||||||
"channel_id": channel.id,
|
"channel_id": channel.id,
|
||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
"data": {
|
"data": {
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"data": MessageUserResponse(
|
"data": message.model_dump(),
|
||||||
**{
|
|
||||||
**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(),
|
|
||||||
},
|
},
|
||||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||||
"channel": channel.model_dump(),
|
"channel": channel.model_dump(),
|
||||||
|
@ -447,23 +453,16 @@ async def new_message_handler(
|
||||||
"message_id": parent_message.id,
|
"message_id": parent_message.id,
|
||||||
"data": {
|
"data": {
|
||||||
"type": "message:reply",
|
"type": "message:reply",
|
||||||
"data": MessageUserResponse(
|
"data": parent_message.model_dump(),
|
||||||
**{
|
|
||||||
**parent_message.model_dump(),
|
|
||||||
"user": UserNameResponse(
|
|
||||||
**Users.get_user_by_id(
|
|
||||||
parent_message.user_id
|
|
||||||
).model_dump()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
).model_dump(),
|
|
||||||
},
|
},
|
||||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||||
"channel": channel.model_dump(),
|
"channel": channel.model_dump(),
|
||||||
},
|
},
|
||||||
to=f"channel:{channel.id}",
|
to=f"channel:{channel.id}",
|
||||||
)
|
)
|
||||||
return MessageModel(**message.model_dump()), channel
|
return message, channel
|
||||||
|
else:
|
||||||
|
raise Exception("Error creating message")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
@ -651,14 +650,7 @@ async def update_message_by_id(
|
||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
"data": {
|
"data": {
|
||||||
"type": "message:update",
|
"type": "message:update",
|
||||||
"data": MessageUserResponse(
|
"data": message.model_dump(),
|
||||||
**{
|
|
||||||
**message.model_dump(),
|
|
||||||
"user": UserNameResponse(
|
|
||||||
**user.model_dump()
|
|
||||||
).model_dump(),
|
|
||||||
}
|
|
||||||
).model_dump(),
|
|
||||||
},
|
},
|
||||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||||
"channel": channel.model_dump(),
|
"channel": channel.model_dump(),
|
||||||
|
@ -724,9 +716,6 @@ async def add_reaction_to_message(
|
||||||
"type": "message:reaction:add",
|
"type": "message:reaction:add",
|
||||||
"data": {
|
"data": {
|
||||||
**message.model_dump(),
|
**message.model_dump(),
|
||||||
"user": UserNameResponse(
|
|
||||||
**Users.get_user_by_id(message.user_id).model_dump()
|
|
||||||
).model_dump(),
|
|
||||||
"name": form_data.name,
|
"name": form_data.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -793,9 +782,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
|
||||||
"type": "message:reaction:remove",
|
"type": "message:reaction:remove",
|
||||||
"data": {
|
"data": {
|
||||||
**message.model_dump(),
|
**message.model_dump(),
|
||||||
"user": UserNameResponse(
|
|
||||||
**Users.get_user_by_id(message.user_id).model_dump()
|
|
||||||
).model_dump(),
|
|
||||||
"name": form_data.name,
|
"name": form_data.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -882,16 +868,7 @@ async def delete_message_by_id(
|
||||||
"message_id": parent_message.id,
|
"message_id": parent_message.id,
|
||||||
"data": {
|
"data": {
|
||||||
"type": "message:reply",
|
"type": "message:reply",
|
||||||
"data": MessageUserResponse(
|
"data": parent_message.model_dump(),
|
||||||
**{
|
|
||||||
**parent_message.model_dump(),
|
|
||||||
"user": UserNameResponse(
|
|
||||||
**Users.get_user_by_id(
|
|
||||||
parent_message.user_id
|
|
||||||
).model_dump()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
).model_dump(),
|
|
||||||
},
|
},
|
||||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||||
"channel": channel.model_dump(),
|
"channel": channel.model_dump(),
|
||||||
|
|
|
@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageForm = {
|
type MessageForm = {
|
||||||
|
reply_to_id?: string;
|
||||||
parent_id?: string;
|
parent_id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
data?: object;
|
data?: object;
|
||||||
|
|
|
@ -20,12 +20,14 @@
|
||||||
|
|
||||||
let scrollEnd = true;
|
let scrollEnd = true;
|
||||||
let messagesContainerElement = null;
|
let messagesContainerElement = null;
|
||||||
|
let chatInputElement = null;
|
||||||
|
|
||||||
let top = false;
|
let top = false;
|
||||||
|
|
||||||
let channel = null;
|
let channel = null;
|
||||||
let messages = null;
|
let messages = null;
|
||||||
|
|
||||||
|
let replyToMessage = null;
|
||||||
let threadId = null;
|
let threadId = null;
|
||||||
|
|
||||||
let typingUsers = [];
|
let typingUsers = [];
|
||||||
|
@ -141,16 +143,20 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
|
const res = await sendMessage(localStorage.token, id, {
|
||||||
(error) => {
|
content: content,
|
||||||
|
data: data,
|
||||||
|
reply_to_id: replyToMessage?.id ?? null
|
||||||
|
}).catch((error) => {
|
||||||
toast.error(`${error}`);
|
toast.error(`${error}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replyToMessage = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange = async () => {
|
const onChange = async () => {
|
||||||
|
@ -222,8 +228,14 @@
|
||||||
{#key id}
|
{#key id}
|
||||||
<Messages
|
<Messages
|
||||||
{channel}
|
{channel}
|
||||||
{messages}
|
|
||||||
{top}
|
{top}
|
||||||
|
{messages}
|
||||||
|
{replyToMessage}
|
||||||
|
onReply={async (message) => {
|
||||||
|
replyToMessage = message;
|
||||||
|
await tick();
|
||||||
|
chatInputElement?.focus();
|
||||||
|
}}
|
||||||
onThread={(id) => {
|
onThread={(id) => {
|
||||||
threadId = id;
|
threadId = id;
|
||||||
}}
|
}}
|
||||||
|
@ -250,6 +262,8 @@
|
||||||
<div class=" pb-[1rem] px-2.5">
|
<div class=" pb-[1rem] px-2.5">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
id="root"
|
id="root"
|
||||||
|
bind:chatInputElement
|
||||||
|
bind:replyToMessage
|
||||||
{typingUsers}
|
{typingUsers}
|
||||||
userSuggestions={true}
|
userSuggestions={true}
|
||||||
channelSuggestions={true}
|
channelSuggestions={true}
|
||||||
|
|
|
@ -23,20 +23,23 @@
|
||||||
|
|
||||||
import { getSessionUser } from '$lib/apis/auths';
|
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 Tooltip from '../common/Tooltip.svelte';
|
||||||
import RichTextInput from '../common/RichTextInput.svelte';
|
import RichTextInput from '../common/RichTextInput.svelte';
|
||||||
import VoiceRecording from '../chat/MessageInput/VoiceRecording.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 FileItem from '../common/FileItem.svelte';
|
||||||
import Image from '../common/Image.svelte';
|
import Image from '../common/Image.svelte';
|
||||||
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
|
||||||
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.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 MentionList from './MessageInput/MentionList.svelte';
|
||||||
import Skeleton from '../chat/Messages/Skeleton.svelte';
|
import Skeleton from '../chat/Messages/Skeleton.svelte';
|
||||||
|
import XMark from '../icons/XMark.svelte';
|
||||||
|
|
||||||
export let placeholder = $i18n.t('Type here...');
|
export let placeholder = $i18n.t('Type here...');
|
||||||
|
|
||||||
|
@ -60,6 +63,8 @@
|
||||||
export let userSuggestions = false;
|
export let userSuggestions = false;
|
||||||
export let channelSuggestions = false;
|
export let channelSuggestions = false;
|
||||||
|
|
||||||
|
export let replyToMessage = null;
|
||||||
|
|
||||||
export let typingUsersClassName = 'from-white dark:from-gray-900';
|
export let typingUsersClassName = 'from-white dark:from-gray-900';
|
||||||
|
|
||||||
let loaded = false;
|
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"
|
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'}
|
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}
|
{#if files.length > 0}
|
||||||
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
|
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
|
||||||
{#each files as file, fileIdx}
|
{#each files as file, fileIdx}
|
||||||
|
@ -890,6 +921,7 @@
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
console.info('Escape');
|
console.info('Escape');
|
||||||
|
replyToMessage = null;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:paste={async (e) => {
|
on:paste={async (e) => {
|
||||||
|
|
|
@ -23,10 +23,12 @@
|
||||||
export let id = null;
|
export let id = null;
|
||||||
export let channel = null;
|
export let channel = null;
|
||||||
export let messages = [];
|
export let messages = [];
|
||||||
|
export let replyToMessage = null;
|
||||||
export let top = false;
|
export let top = false;
|
||||||
export let thread = false;
|
export let thread = false;
|
||||||
|
|
||||||
export let onLoad: Function = () => {};
|
export let onLoad: Function = () => {};
|
||||||
|
export let onReply: Function = () => {};
|
||||||
export let onThread: Function = () => {};
|
export let onThread: Function = () => {};
|
||||||
|
|
||||||
let messagesLoading = false;
|
let messagesLoading = false;
|
||||||
|
@ -94,10 +96,12 @@
|
||||||
<Message
|
<Message
|
||||||
{message}
|
{message}
|
||||||
{thread}
|
{thread}
|
||||||
|
replyToMessage={replyToMessage?.id === message.id}
|
||||||
disabled={!channel?.write_access}
|
disabled={!channel?.write_access}
|
||||||
showUserProfile={messageIdx === 0 ||
|
showUserProfile={messageIdx === 0 ||
|
||||||
messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
|
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={() => {
|
onDelete={() => {
|
||||||
messages = messages.filter((m) => m.id !== message.id);
|
messages = messages.filter((m) => m.id !== message.id);
|
||||||
|
|
||||||
|
@ -123,6 +127,9 @@
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onReply={(message) => {
|
||||||
|
onReply(message);
|
||||||
|
}}
|
||||||
onThread={(id) => {
|
onThread={(id) => {
|
||||||
onThread(id);
|
onThread(id);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -13,8 +13,9 @@
|
||||||
import { getContext, onMount } from 'svelte';
|
import { getContext, onMount } from 'svelte';
|
||||||
const i18n = getContext<Writable<i18nType>>('i18n');
|
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 { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
||||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||||
|
@ -32,18 +33,20 @@
|
||||||
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
|
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
|
||||||
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
|
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
|
||||||
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
|
||||||
import { formatDate } from '$lib/utils';
|
|
||||||
import Emoji from '$lib/components/common/Emoji.svelte';
|
import Emoji from '$lib/components/common/Emoji.svelte';
|
||||||
import { t } from 'i18next';
|
|
||||||
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
|
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
|
||||||
|
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
|
||||||
|
|
||||||
export let message;
|
export let message;
|
||||||
export let showUserProfile = true;
|
export let showUserProfile = true;
|
||||||
export let thread = false;
|
export let thread = false;
|
||||||
|
|
||||||
|
export let replyToMessage = false;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
|
||||||
export let onDelete: Function = () => {};
|
export let onDelete: Function = () => {};
|
||||||
export let onEdit: Function = () => {};
|
export let onEdit: Function = () => {};
|
||||||
|
export let onReply: Function = () => {};
|
||||||
export let onThread: Function = () => {};
|
export let onThread: Function = () => {};
|
||||||
export let onReaction: Function = () => {};
|
export let onReaction: Function = () => {};
|
||||||
|
|
||||||
|
@ -65,9 +68,15 @@
|
||||||
|
|
||||||
{#if message}
|
{#if message}
|
||||||
<div
|
<div
|
||||||
|
id="message-{message.id}"
|
||||||
class="flex flex-col justify-between px-5 {showUserProfile
|
class="flex flex-col justify-between px-5 {showUserProfile
|
||||||
? 'pt-1.5 pb-0.5'
|
? '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}
|
{#if !edit && !disabled}
|
||||||
<div
|
<div
|
||||||
|
@ -95,6 +104,17 @@
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</EmojiPicker>
|
</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}
|
{#if !thread}
|
||||||
<Tooltip content={$i18n.t('Reply in Thread')}>
|
<Tooltip content={$i18n.t('Reply in Thread')}>
|
||||||
<button
|
<button
|
||||||
|
@ -134,6 +154,56 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<div
|
||||||
class=" flex w-full message-{message.id}"
|
class=" flex w-full message-{message.id}"
|
||||||
id="message-{message.id}"
|
id="message-{message.id}"
|
||||||
|
@ -151,7 +221,7 @@
|
||||||
<ProfilePreview user={message.user}>
|
<ProfilePreview user={message.user}>
|
||||||
<ProfileImage
|
<ProfileImage
|
||||||
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
|
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>
|
</ProfilePreview>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -348,3 +418,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 messages = null;
|
||||||
let top = false;
|
let top = false;
|
||||||
|
|
||||||
|
let messagesContainerElement = null;
|
||||||
|
let chatInputElement = null;
|
||||||
|
|
||||||
|
let replyToMessage = null;
|
||||||
|
|
||||||
let typingUsers = [];
|
let typingUsers = [];
|
||||||
let typingUsersTimeout = {};
|
let typingUsersTimeout = {};
|
||||||
|
|
||||||
let messagesContainerElement = null;
|
|
||||||
|
|
||||||
$: if (threadId) {
|
$: if (threadId) {
|
||||||
initHandler();
|
initHandler();
|
||||||
}
|
}
|
||||||
|
@ -128,12 +131,15 @@
|
||||||
|
|
||||||
const res = await sendMessage(localStorage.token, channel.id, {
|
const res = await sendMessage(localStorage.token, channel.id, {
|
||||||
parent_id: threadId,
|
parent_id: threadId,
|
||||||
|
reply_to_id: replyToMessage?.id ?? null,
|
||||||
content: content,
|
content: content,
|
||||||
data: data
|
data: data
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
toast.error(`${error}`);
|
toast.error(`${error}`);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
replyToMessage = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange = async () => {
|
const onChange = async () => {
|
||||||
|
@ -180,9 +186,16 @@
|
||||||
<Messages
|
<Messages
|
||||||
id={threadId}
|
id={threadId}
|
||||||
{channel}
|
{channel}
|
||||||
{messages}
|
|
||||||
{top}
|
{top}
|
||||||
|
{messages}
|
||||||
|
{replyToMessage}
|
||||||
thread={true}
|
thread={true}
|
||||||
|
onReply={async (message) => {
|
||||||
|
replyToMessage = message;
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
chatInputElement?.focus();
|
||||||
|
}}
|
||||||
onLoad={async () => {
|
onLoad={async () => {
|
||||||
const newMessages = await getChannelThreadMessages(
|
const newMessages = await getChannelThreadMessages(
|
||||||
localStorage.token,
|
localStorage.token,
|
||||||
|
@ -207,6 +220,8 @@
|
||||||
|
|
||||||
<div class=" pb-[1rem] px-2.5 w-full">
|
<div class=" pb-[1rem] px-2.5 w-full">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
|
bind:replyToMessage
|
||||||
|
bind:chatInputElement
|
||||||
id={threadId}
|
id={threadId}
|
||||||
disabled={!channel?.write_access}
|
disabled={!channel?.write_access}
|
||||||
placeholder={!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