enh: typing indicator

This commit is contained in:
Timothy Jaeryang Baek 2024-12-26 21:51:09 -08:00
parent 4f93ecf519
commit 6ff6d57507
3 changed files with 137 additions and 46 deletions

View File

@ -4,7 +4,7 @@ import logging
import sys
import time
from open_webui.models.users import Users
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
@ -152,7 +152,7 @@ async def connect(sid, environ, auth):
user = Users.get_user_by_id(data["id"])
if user:
SESSION_POOL[sid] = user.id
SESSION_POOL[sid] = user.model_dump()
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@ -178,7 +178,7 @@ async def user_join(sid, data):
if not user:
return
SESSION_POOL[sid] = user.id
SESSION_POOL[sid] = user.model_dump()
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@ -217,22 +217,45 @@ async def join_channel(sid, data):
await sio.enter_room(sid, f"channel:{channel.id}")
@sio.on("channel-events")
async def channel_events(sid, data):
room = f"channel:{data['channel_id']}"
participants = sio.manager.get_participants(
namespace="/",
room=room,
)
sids = [sid for sid, _ in participants]
if sid not in sids:
return
event_data = data["data"]
event_type = event_data["type"]
if event_type == "typing":
await sio.emit(
"channel-events",
{
"channel_id": data["channel_id"],
"data": event_data,
"user": UserNameResponse(**SESSION_POOL[sid]).model_dump(),
},
room=room,
)
@sio.on("user-count")
async def user_count(sid):
await sio.emit("user-count", {"count": len(USER_POOL.items())})
@sio.on("chat")
async def chat(sid, data):
print("chat", sid, SESSION_POOL[sid], data)
@sio.event
async def disconnect(sid):
if sid in SESSION_POOL:
user_id = SESSION_POOL[sid]
user = SESSION_POOL[sid]
del SESSION_POOL[sid]
user_id = user["id"]
USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid]
if len(USER_POOL[user_id]) == 0:
@ -289,7 +312,10 @@ def get_event_call(request_info):
def get_user_id_from_session_pool(sid):
return SESSION_POOL.get(sid)
user = SESSION_POOL.get(sid)
if user:
return user["id"]
return None
def get_user_ids_from_room(room):
@ -299,6 +325,8 @@ def get_user_ids_from_room(room):
)
active_user_ids = list(
set([SESSION_POOL.get(session_id[0]) for session_id in active_session_ids])
set(
[SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
)
)
return active_user_ids

View File

@ -2,7 +2,7 @@
import { toast } from 'svelte-sonner';
import { onDestroy, onMount, tick } from 'svelte';
import { chatId, showSidebar, socket } from '$lib/stores';
import { chatId, showSidebar, socket, user } from '$lib/stores';
import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
import Messages from './Messages.svelte';
@ -20,6 +20,9 @@
let channel = null;
let messages = null;
let typingUsers = [];
let typingUsersTimeout = {};
$: if (id) {
initHandler();
}
@ -76,6 +79,32 @@
} else if (type === 'message:delete') {
console.log('message:delete', data);
messages = messages.filter((message) => message.id !== data.id);
} else if (type === 'typing') {
if (event.user.id === $user.id) {
return;
}
typingUsers = data.typing
? [
...typingUsers,
...(typingUsers.find((user) => user.id === event.user.id)
? []
: [
{
id: event.user.id,
name: event.user.name
}
])
]
: typingUsers.filter((user) => user.id !== event.user.id);
if (typingUsersTimeout[event.user.id]) {
clearTimeout(typingUsersTimeout[event.user.id]);
}
typingUsersTimeout[event.user.id] = setTimeout(() => {
typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
}, 5000);
}
}
};
@ -97,6 +126,18 @@
}
};
const onChange = async () => {
$socket?.emit('channel-events', {
channel_id: id,
data: {
type: 'typing',
data: {
typing: true
}
}
});
};
onMount(() => {
if ($chatId) {
chatId.set('');
@ -150,6 +191,6 @@
</div>
<div class=" pb-[1rem]">
<MessageInput onSubmit={submitHandler} {scrollToBottom} {scrollEnd} />
<MessageInput {typingUsers} {onChange} onSubmit={submitHandler} {scrollToBottom} {scrollEnd} />
</div>
</div>

View File

@ -6,7 +6,7 @@
const i18n = getContext('i18n');
import { config, mobile, settings } from '$lib/stores';
import { config, mobile, settings, socket } from '$lib/stores';
import { blobToFile, compressImage } from '$lib/utils';
import Tooltip from '../common/Tooltip.svelte';
@ -32,7 +32,10 @@
let filesInputElement;
let inputFiles;
export let typingUsers = [];
export let onSubmit: Function;
export let onChange: Function;
export let scrollEnd = true;
export let scrollToBottom: Function;
@ -258,6 +261,10 @@
chatInputElement?.focus();
};
$: if (content) {
onChange();
}
onMount(async () => {
window.setTimeout(() => {
const chatInput = document.getElementById('chat-input');
@ -290,37 +297,6 @@
<FilesOverlay show={draggedOver} />
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 max-w-6xl w-full">
<div class="relative">
{#if scrollEnd === false}
<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none">
<button
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
on:click={() => {
scrollEnd = true;
scrollToBottom();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
</div>
</div>
<input
bind:this={filesInputElement}
bind:files={inputFiles}
@ -341,8 +317,54 @@
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0 relative"
>
<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
<div class="flex flex-col px-3 w-full">
<div class="relative">
{#if scrollEnd === false}
<div
class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
>
<button
class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
on:click={() => {
scrollEnd = true;
scrollToBottom();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
{/if}
</div>
<div class="relative">
<div class=" -mt-5 bg-gradient-to-t from-white dark:from-gray-900">
{#if typingUsers.length > 0}
<div class=" text-xs px-4 mb-1">
<span class=" font-medium text-black dark:text-white">
{typingUsers.map((user) => user.name).join(', ')}
</span>
{$i18n.t('is typing...')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="">
{#if recording}
<VoiceRecording