Explorar o código

enh: user status indicator

Timothy Jaeryang Baek hai 4 meses
pai
achega
50534a0dcf

+ 11 - 1
backend/open_webui/routers/users.py

@@ -10,6 +10,9 @@ from open_webui.models.users import (
     UserSettings,
     UserSettings,
     UserUpdateForm,
     UserUpdateForm,
 )
 )
+
+
+from open_webui.socket.main import get_active_status_by_user_id
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.env import SRC_LOG_LEVELS
 from open_webui.env import SRC_LOG_LEVELS
 from fastapi import APIRouter, Depends, HTTPException, Request, status
 from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -196,6 +199,7 @@ async def update_user_info_by_session_user(
 class UserResponse(BaseModel):
 class UserResponse(BaseModel):
     name: str
     name: str
     profile_image_url: str
     profile_image_url: str
+    active: Optional[bool] = None
 
 
 
 
 @router.get("/{user_id}", response_model=UserResponse)
 @router.get("/{user_id}", response_model=UserResponse)
@@ -216,7 +220,13 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
     user = Users.get_user_by_id(user_id)
     user = Users.get_user_by_id(user_id)
 
 
     if user:
     if user:
-        return UserResponse(name=user.name, profile_image_url=user.profile_image_url)
+        return UserResponse(
+            **{
+                "name": user.name,
+                "profile_image_url": user.profile_image_url,
+                "active": get_active_status_by_user_id(user_id),
+            }
+        )
     else:
     else:
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,

+ 12 - 6
backend/open_webui/socket/main.py

@@ -159,7 +159,7 @@ async def connect(sid, environ, auth):
                 USER_POOL[user.id] = [sid]
                 USER_POOL[user.id] = [sid]
 
 
             # print(f"user {user.name}({user.id}) connected with session ID {sid}")
             # print(f"user {user.name}({user.id}) connected with session ID {sid}")
-            await sio.emit("user-count", {"count": len(USER_POOL.items())})
+            await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
             await sio.emit("usage", {"models": get_models_in_use()})
             await sio.emit("usage", {"models": get_models_in_use()})
 
 
 
 
@@ -192,7 +192,7 @@ async def user_join(sid, data):
 
 
     # print(f"user {user.name}({user.id}) connected with session ID {sid}")
     # print(f"user {user.name}({user.id}) connected with session ID {sid}")
 
 
-    await sio.emit("user-count", {"count": len(USER_POOL.items())})
+    await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
     return {"id": user.id, "name": user.name}
     return {"id": user.id, "name": user.name}
 
 
 
 
@@ -244,9 +244,9 @@ async def channel_events(sid, data):
         )
         )
 
 
 
 
-@sio.on("user-count")
-async def user_count(sid):
-    await sio.emit("user-count", {"count": len(USER_POOL.items())})
+@sio.on("user-list")
+async def user_list(sid):
+    await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
 
 
 
 
 @sio.event
 @sio.event
@@ -261,7 +261,7 @@ async def disconnect(sid):
         if len(USER_POOL[user_id]) == 0:
         if len(USER_POOL[user_id]) == 0:
             del USER_POOL[user_id]
             del USER_POOL[user_id]
 
 
-        await sio.emit("user-count", {"count": len(USER_POOL)})
+        await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
     else:
     else:
         pass
         pass
         # print(f"Unknown session ID {sid} disconnected")
         # print(f"Unknown session ID {sid} disconnected")
@@ -330,3 +330,9 @@ def get_user_ids_from_room(room):
         )
         )
     )
     )
     return active_user_ids
     return active_user_ids
+
+
+def get_active_status_by_user_id(user_id):
+    if user_id in USER_POOL:
+        return True
+    return False

+ 8 - 5
src/lib/components/channel/Messages/Message.svelte

@@ -25,6 +25,7 @@
 	import Textarea from '$lib/components/common/Textarea.svelte';
 	import Textarea from '$lib/components/common/Textarea.svelte';
 	import Image from '$lib/components/common/Image.svelte';
 	import Image from '$lib/components/common/Image.svelte';
 	import FileItem from '$lib/components/common/FileItem.svelte';
 	import FileItem from '$lib/components/common/FileItem.svelte';
+	import ProfilePreview from './Message/ProfilePreview.svelte';
 
 
 	export let message;
 	export let message;
 	export let showUserProfile = true;
 	export let showUserProfile = true;
@@ -101,11 +102,13 @@
 				class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
 				class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
 			>
 			>
 				{#if showUserProfile}
 				{#if showUserProfile}
-					<ProfileImage
-						src={message.user?.profile_image_url ??
-							($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
-						className={'size-8 translate-y-1 ml-0.5'}
-					/>
+					<ProfilePreview user={message.user}>
+						<ProfileImage
+							src={message.user?.profile_image_url ??
+								($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
+							className={'size-8 translate-y-1 ml-0.5'}
+						/>
+					</ProfilePreview>
 				{:else}
 				{:else}
 					<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
 					<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
 
 

+ 85 - 0
src/lib/components/channel/Messages/Message/ProfilePreview.svelte

@@ -0,0 +1,85 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { createEventDispatcher } from 'svelte';
+
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { WEBUI_BASE_URL } from '$lib/constants';
+	import { activeUserIds } from '$lib/stores';
+
+	export let side = 'right';
+	export let align = 'top';
+
+	export let user = null;
+	let show = false;
+
+	const dispatch = createEventDispatcher();
+</script>
+
+<DropdownMenu.Root
+	bind:open={show}
+	closeFocus={false}
+	onOpenChange={(state) => {
+		dispatch('change', state);
+	}}
+	typeahead={false}
+>
+	<DropdownMenu.Trigger>
+		<slot />
+	</DropdownMenu.Trigger>
+
+	<slot name="content">
+		<DropdownMenu.Content
+			class="w-full max-w-[200px] rounded-lg z-50 bg-white dark:bg-black dark:text-white shadow-lg"
+			sideOffset={8}
+			{side}
+			{align}
+			transition={flyAndScale}
+		>
+			{#if user}
+				<div class=" flex flex-col gap-2 w-full rounded-lg">
+					<div class="py-8 relative bg-gray-900 rounded-t-lg">
+						<img
+							crossorigin="anonymous"
+							src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
+							class=" absolute -bottom-5 left-3 size-12 ml-0.5 object-cover rounded-full -translate-y-[1px]"
+							alt="profile"
+						/>
+					</div>
+
+					<div class=" flex flex-col pt-4 pb-2.5 px-4">
+						<div class=" -mb-1">
+							<span class="font-medium text-sm line-clamp-1"> {user.name} </span>
+						</div>
+
+						<div class=" flex items-center gap-2">
+							{#if $activeUserIds.includes(user.id)}
+								<div>
+									<span class="relative flex size-2">
+										<span
+											class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
+										/>
+										<span class="relative inline-flex rounded-full size-2 bg-green-500" />
+									</span>
+								</div>
+
+								<div class=" -translate-y-[1px]">
+									<span class="text-xs"> Active </span>
+								</div>
+							{:else}
+								<div>
+									<span class="relative flex size-2">
+										<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
+									</span>
+								</div>
+
+								<div class=" -translate-y-[1px]">
+									<span class="text-xs"> Away </span>
+								</div>
+							{/if}
+						</div>
+					</div>
+				</div>
+			{/if}
+		</DropdownMenu.Content>
+	</slot>
+</DropdownMenu.Root>

+ 4 - 2
src/lib/components/common/Dropdown.svelte

@@ -5,6 +5,8 @@
 	import { flyAndScale } from '$lib/utils/transitions';
 	import { flyAndScale } from '$lib/utils/transitions';
 
 
 	export let show = false;
 	export let show = false;
+	export let side = 'bottom';
+	export let align = 'start';
 	const dispatch = createEventDispatcher();
 	const dispatch = createEventDispatcher();
 </script>
 </script>
 
 
@@ -24,8 +26,8 @@
 		<DropdownMenu.Content
 		<DropdownMenu.Content
 			class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-900 z-50 bg-gray-850 text-white"
 			class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-900 z-50 bg-gray-850 text-white"
 			sideOffset={8}
 			sideOffset={8}
-			side="bottom"
-			align="start"
+			{side}
+			{align}
 			transition={flyAndScale}
 			transition={flyAndScale}
 		>
 		>
 			<DropdownMenu.Item class="flex items-center px-3 py-2 text-sm  font-medium">
 			<DropdownMenu.Item class="flex items-center px-3 py-2 text-sm  font-medium">

+ 3 - 3
src/lib/components/layout/Sidebar/UserMenu.svelte

@@ -5,7 +5,7 @@
 	import { flyAndScale } from '$lib/utils/transitions';
 	import { flyAndScale } from '$lib/utils/transitions';
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
 	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
 	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
-	import { showSettings, activeUserCount, USAGE_POOL, mobile, showSidebar } from '$lib/stores';
+	import { showSettings, activeUserIds, USAGE_POOL, mobile, showSidebar } from '$lib/stores';
 	import { fade, slide } from 'svelte/transition';
 	import { fade, slide } from 'svelte/transition';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import { userSignOut } from '$lib/apis/auths';
 	import { userSignOut } from '$lib/apis/auths';
@@ -184,7 +184,7 @@
 				<div class=" self-center truncate">{$i18n.t('Sign Out')}</div>
 				<div class=" self-center truncate">{$i18n.t('Sign Out')}</div>
 			</button>
 			</button>
 
 
-			{#if $activeUserCount}
+			{#if $activeUserIds?.length > 0}
 				<hr class=" border-gray-50 dark:border-gray-850 my-1 p-0" />
 				<hr class=" border-gray-50 dark:border-gray-850 my-1 p-0" />
 
 
 				<Tooltip
 				<Tooltip
@@ -207,7 +207,7 @@
 								{$i18n.t('Active Users')}:
 								{$i18n.t('Active Users')}:
 							</span>
 							</span>
 							<span class=" font-semibold">
 							<span class=" font-semibold">
-								{$activeUserCount}
+								{$activeUserIds?.length}
 							</span>
 							</span>
 						</div>
 						</div>
 					</div>
 					</div>

+ 1 - 1
src/lib/stores/index.ts

@@ -15,7 +15,7 @@ export const MODEL_DOWNLOAD_POOL = writable({});
 export const mobile = writable(false);
 export const mobile = writable(false);
 
 
 export const socket: Writable<null | Socket> = writable(null);
 export const socket: Writable<null | Socket> = writable(null);
-export const activeUserCount: Writable<null | number> = writable(null);
+export const activeUserIds: Writable<null | string[]> = writable(null);
 export const USAGE_POOL: Writable<null | string[]> = writable(null);
 export const USAGE_POOL: Writable<null | string[]> = writable(null);
 
 
 export const theme = writable('system');
 export const theme = writable('system');

+ 4 - 4
src/routes/+layout.svelte

@@ -14,7 +14,7 @@
 		WEBUI_NAME,
 		WEBUI_NAME,
 		mobile,
 		mobile,
 		socket,
 		socket,
-		activeUserCount,
+		activeUserIds,
 		USAGE_POOL,
 		USAGE_POOL,
 		chatId,
 		chatId,
 		chats,
 		chats,
@@ -81,9 +81,9 @@
 			}
 			}
 		});
 		});
 
 
-		_socket.on('user-count', (data) => {
-			console.log('user-count', data);
-			activeUserCount.set(data.count);
+		_socket.on('user-list', (data) => {
+			console.log('user-list', data);
+			activeUserIds.set(data.user_ids);
 		});
 		});
 
 
 		_socket.on('usage', (data) => {
 		_socket.on('usage', (data) => {