mirror of
				https://github.com/open-webui/open-webui
				synced 2025-06-26 18:26:48 +00:00 
			
		
		
		
	enh: typing indicator
This commit is contained in:
		
							parent
							
								
									4f93ecf519
								
							
						
					
					
						commit
						6ff6d57507
					
				@ -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
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user