From 1e9dd6e330548c20a1d4054206c3e1b77fb08a5a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 8 Oct 2026 15:29:52 +0400 Subject: [PATCH] refac --- backend/open_webui/socket/main.py | 26 ++++++++ src/lib/components/chat/Chat.svelte | 60 +++++++++++++++++++ src/lib/components/chat/MessageInput.svelte | 24 ++++++++ .../chat/Messages/UserMessage.svelte | 10 +++- src/lib/components/common/InputModal.svelte | 3 +- src/lib/i18n/locales/en-US/translation.json | 1 + 6 files changed, 120 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 280d655ecb..99bc15a9ac 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -700,6 +700,32 @@ async def chat_events(sid, data): event_data = data.get('data', {}) event_type = event_data.get('type') + if event_type == 'typing': + chat_id = data.get('chat_id') + typing_data = event_data.get('data') + typing = typing_data.get('typing') if isinstance(typing_data, dict) else None + if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id) or not isinstance(typing, bool): + return False + room = f'chat:{chat_id}' + if sid not in (get_room_sid_map(sio.manager, '/', room) or {}): + return False + sender = await Users.get_user_by_id(user['id']) + if not sender or not await Chats.get_accessible_chat_by_id(chat_id, sender, permission='write'): + return False + await sio.emit( + 'events', + { + 'chat_id': chat_id, + 'user_id': sender.id, + 'user': {'id': sender.id, 'name': sender.name}, + 'shared': True, + 'data': {'type': 'typing', 'data': {'typing': typing}}, + }, + room=room, + skip_sid=sid, + ) + return True + if event_type in {'join', 'leave'}: chat_id = data.get('chat_id') if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id): diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 7a0178e961..9787be7d27 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1264,6 +1264,36 @@ let joinedChatId = ''; let chatRefresh: { id: string; promise: Promise } | null = null; + let typingUsers: { id: string; name: string }[] = []; + let typingUsersTimeout: Record> = {}; + let lastTypingEmit = 0; + + const emitTyping = (typing: boolean) => { + if (!$socket?.connected || !joinedChatId) return; + if (typing && (joinedChatId !== $chatId || (readOnly && chat?.chat?.share_mode !== 'continue'))) + return; + if (typing ? Date.now() - lastTypingEmit < 2000 : !lastTypingEmit) return; + lastTypingEmit = typing ? Date.now() : 0; + $socket.emit('events:chat', { + chat_id: joinedChatId, + data: { type: 'typing', data: { typing } } + }); + }; + + const removeTypingUser = (id: string) => { + clearTimeout(typingUsersTimeout[id]); + delete typingUsersTimeout[id]; + typingUsers = typingUsers.filter((user) => user.id !== id); + }; + + const clearTyping = () => { + emitTyping(false); + Object.values(typingUsersTimeout).forEach(clearTimeout); + typingUsersTimeout = {}; + typingUsers = []; + lastTypingEmit = 0; + }; + $: if (!prompt.trim()) emitTyping(false); const mergeChatMessages = ( incoming: typeof history, @@ -1333,6 +1363,7 @@ const syncChatRoom = (socket: typeof $socket, id: string, temporary: boolean) => { const next = socket && id && !temporary && !isTemporaryChatId(id) ? id : ''; if (next === joinedChatId) return; + clearTyping(); if (joinedChatId) socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } }); joinedChatId = next; @@ -1354,12 +1385,32 @@ ) { await tick(); const type = event?.data?.type ?? null; + if (type === 'typing') { + const sender = event.user; + if ( + !sender?.id || + sender.id === $user?.id || + (readOnly && chat?.chat?.share_mode !== 'continue') + ) + return; + if (event.data.data?.typing) { + if (!typingUsers.some((entry) => entry.id === sender.id)) + typingUsers = [...typingUsers, { id: sender.id, name: sender.name }]; + clearTimeout(typingUsersTimeout[sender.id]); + typingUsersTimeout[sender.id] = setTimeout(() => removeTypingUser(sender.id), 5000); + } else { + removeTypingUser(sender.id); + } + return; + } if (event.shared) { if (type === 'chat:messages') { + removeTypingUser(event.user_id); mergeChatMessages(event.data.data); return; } if (type === 'chat:access' || type === 'chat:active') { + if (type === 'chat:access') clearTyping(); if (type === 'chat:access' || event.data.data?.active === false) await refreshChat(); return; } @@ -1684,6 +1735,7 @@ ); const handleSocketConnect = async () => { + clearTyping(); if (!$chatId || $temporaryChatEnabled) return; joinedChatId = ''; syncChatRoom($socket, $chatId, $temporaryChatEnabled); @@ -1698,6 +1750,7 @@ window.addEventListener('message', onMessageHandler); $socket?.on('events', chatEventHandler); $socket?.on('connect', handleSocketConnect); + $socket?.on('disconnect', clearTyping); $audioQueue?.destroy(); @@ -1792,6 +1845,7 @@ // Clear the selected chat when leaving the chat surface (e.g. navigating // to the admin panel), otherwise the previously-viewed chat stays selected // in the sidebar and deleting/archiving it wrongly navigates away. + clearTyping(); if (joinedChatId) $socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } }); joinedChatId = ''; @@ -1801,6 +1855,7 @@ window.removeEventListener('message', onMessageHandler); $socket?.off('events', chatEventHandler); $socket?.off('connect', handleSocketConnect); + $socket?.off('disconnect', clearTyping); dismissContextCompactionToast(); audioQueueInstance?.destroy(); audioQueue.set(null); @@ -3458,6 +3513,7 @@ bridge: bridgeRequest = null }: { _raw?: boolean; bridge?: { userMessageId: string; modelId: string } | null } = {} ): Promise => { + emitTyping(false); if (bridgeRequest && (selectedModelIds.length !== 1 || !bridgeRequest.modelId)) return { status: 'rejected' }; @@ -4831,6 +4887,8 @@ class=" pb-2 {dragged ? 'z-0' : 'z-10'}" > emitTyping(event.detail)} callActive={!!(bridge?.connected || bridge?.connecting)} bind:this={messageInput} {history} @@ -4924,6 +4982,8 @@ {/if}
emitTyping(event.detail)} callActive={!!(bridge?.connected || bridge?.connecting)} bind:this={messageInput} {history} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 56e29dcb27..4198394319 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -117,6 +117,7 @@ import AskUserCard from './AskUserCard.svelte'; import { goto } from '$app/navigation'; import InputModal from '../common/InputModal.svelte'; + import Skeleton from './Messages/Skeleton.svelte'; import Expand from '../icons/Expand.svelte'; import QueuedMessageItem from './MessageInput/QueuedMessageItem.svelte'; import TaskList from './Messages/ResponseMessage/TaskList.svelte'; @@ -134,6 +135,12 @@ export let onUpload: Function = (e) => {}; export let onChange: Function = () => {}; + export let typingUsers: { id: string; name: string }[] = []; + + const handleTypingInput = (event: Event) => { + const input = event.target as HTMLElement; + if (input.isContentEditable) dispatch('typing', Boolean(input.textContent?.trim())); + }; export let onWebSearchToggle: Function = () => {}; export let createMessagePair: Function; @@ -1672,6 +1679,7 @@ { @@ -1693,6 +1701,21 @@ : 'max-w-[58rem]'} w-full" >
+ {#if typingUsers.length > 0} +
+
+ +
+ + {typingUsers.map((user) => user.name).join(', ')} + + {$i18n.t(typingUsers.length === 1 ? 'is typing...' : 'are typing...')} +
+
+
+ {/if} {#if autoScroll === false && history?.currentId}
{#if suggestions} {#key $settings?.richTextInput ?? true} diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index fc98539b03..90c2f2eecd 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -142,12 +142,16 @@ />
- {#if (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')} + {#if !compactPreview && (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
@@ -169,7 +173,7 @@ ? '' : 'pl-1'}" > - {#if (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')} + {#if !compactPreview && (!($settings?.chatBubble ?? true) || !isOwn) && !(message?.meta?.internal === true && message?.meta?.type === 'subagent') && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
{}; + export let oninput: ((event: Event) => void) | undefined = undefined; export let onClose = () => {}; let inputElement; -
+
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 126e81fa95..00b16bedbe 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -1256,6 +1256,7 @@ "Invalid JSON format in {{NAME}}": "", "Invalid JSON format in Additional Config": "", "Invalid JSON format in MinerU Parameters": "", + "are typing...": "", "is typing...": "", "Italic": "", "January": "",