瀏覽代碼

enh: typing indicator

Timothy Jaeryang Baek 4 月之前
父節點
當前提交
6ff6d57507
共有 3 個文件被更改,包括 137 次插入46 次删除
  1. 39 11
      backend/open_webui/socket/main.py
  2. 43 2
      src/lib/components/channel/Channel.svelte
  3. 55 33
      src/lib/components/channel/MessageInput.svelte

+ 39 - 11
backend/open_webui/socket/main.py

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

+ 43 - 2
src/lib/components/channel/Channel.svelte

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

+ 55 - 33
src/lib/components/channel/MessageInput.svelte

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