Pārlūkot izejas kodu

enh: channel suggestions

Timothy Jaeryang Baek 3 nedēļas atpakaļ
vecāks
revīzija
bbd1d2b58c

+ 10 - 0
backend/open_webui/models/users.py

@@ -107,11 +107,21 @@ class UserInfoResponse(BaseModel):
     role: str
 
 
+class UserIdNameResponse(BaseModel):
+    id: str
+    name: str
+
+
 class UserInfoListResponse(BaseModel):
     users: list[UserInfoResponse]
     total: int
 
 
+class UserIdNameListResponse(BaseModel):
+    users: list[UserIdNameResponse]
+    total: int
+
+
 class UserResponse(BaseModel):
     id: str
     name: str

+ 18 - 0
backend/open_webui/routers/users.py

@@ -18,6 +18,7 @@ from open_webui.models.users import (
     UserModel,
     UserListResponse,
     UserInfoListResponse,
+    UserIdNameListResponse,
     UserRoleUpdateForm,
     Users,
     UserSettings,
@@ -100,6 +101,23 @@ async def get_all_users(
     return Users.get_users()
 
 
+@router.get("/search", response_model=UserIdNameListResponse)
+async def search_users(
+    query: Optional[str] = None,
+    user=Depends(get_verified_user),
+):
+    limit = PAGE_ITEM_COUNT
+
+    page = 1  # Always return the first page for search
+    skip = (page - 1) * limit
+
+    filter = {}
+    if query:
+        filter["query"] = query
+
+    return Users.get_users(filter=filter, skip=skip, limit=limit)
+
+
 ############################
 # User Groups
 ############################

+ 28 - 0
src/lib/apis/users/index.ts

@@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
 	return res;
 };
 
+export const searchUsers = async (token: string, query: string) => {
+	let error = null;
+	let res = null;
+
+	res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
+		method: 'GET',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.error(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
 export const getUserSettings = async (token: string) => {
 	let error = null;
 	const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {

+ 3 - 3
src/lib/components/admin/Users/UserList.svelte

@@ -502,11 +502,11 @@
 > [!NOTE]
 > # **Hey there! 👋**
 >
-> It looks like you have over 50 users that usually falls under organizational usage.
+> It looks like you have over 50 users, that usually falls under organizational usage.
 > 
-> Open WebUI is proudly open source and completely free, with no hidden limits — and we'd love to keep it that way. 🌱  
+> Open WebUI is completely free to use as-is, with no restrictions or hidden limits, and we'd love to keep it that way. 🌱  
 >
-> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more all at a fraction of what it would cost to build and maintain internally.  
+> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more, all at a fraction of what it would cost to build and maintain internally.  
 > 
 > Your support helps us stay independent and continue building great tools for everyone. 💛
 > 

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

@@ -250,6 +250,8 @@
 				<MessageInput
 					id="root"
 					{typingUsers}
+					userSuggestions={true}
+					channelSuggestions={true}
 					{onChange}
 					onSubmit={submitHandler}
 					{scrollToBottom}

+ 19 - 1
src/lib/components/channel/MessageInput.svelte

@@ -56,6 +56,9 @@
 	export let acceptFiles = true;
 	export let showFormattingToolbar = true;
 
+	export let userSuggestions = false;
+	export let channelSuggestions = false;
+
 	export let typingUsersClassName = 'from-white dark:from-gray-900';
 
 	let loaded = false;
@@ -563,9 +566,24 @@
 			{
 				char: '@',
 				render: getSuggestionRenderer(MentionList, {
-					i18n
+					i18n,
+					triggerChar: '@',
+					modelSuggestions: true,
+					userSuggestions
 				})
 			},
+			...(channelSuggestions
+				? [
+						{
+							char: '#',
+							render: getSuggestionRenderer(MentionList, {
+								i18n,
+								triggerChar: '#',
+								channelSuggestions
+							})
+						}
+					]
+				: []),
 			{
 				char: '/',
 				render: getSuggestionRenderer(CommandSuggestionList, {

+ 135 - 15
src/lib/components/channel/MessageInput/MentionList.svelte

@@ -1,41 +1,88 @@
 <script lang="ts">
-	import { getContext } from 'svelte';
+	import { getContext, onDestroy, onMount } from 'svelte';
 	const i18n = getContext('i18n');
 
-	import { models } from '$lib/stores';
+	import { channels, models, user } from '$lib/stores';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Hashtag from '$lib/components/icons/Hashtag.svelte';
+	import Lock from '$lib/components/icons/Lock.svelte';
+	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
+	import { searchUsers } from '$lib/apis/users';
+
 	export let query = '';
 
 	export let command: (payload: { id: string; label: string }) => void;
 	export let selectedIndex = 0;
 
-	let items = [];
+	export let label = '';
+	export let triggerChar = '@';
+
+	export let modelSuggestions = false;
+	export let userSuggestions = false;
+	export let channelSuggestions = false;
+
+	let _models = [];
+	let _users = [];
+	let _channels = [];
+
+	$: filteredItems = [..._users, ..._models, ..._channels].filter(
+		(u) =>
+			u.label.toLowerCase().includes(query.toLowerCase()) ||
+			u.id.toLowerCase().includes(query.toLowerCase())
+	);
+
+	const getUserList = async () => {
+		const res = await searchUsers(localStorage.token, query).catch((error) => {
+			console.error('Error searching users:', error);
+			return null;
+		});
+
+		if (res) {
+			_users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) =>
+				a.label.localeCompare(b.label)
+			);
+		}
+	};
 
-	$: filteredItems = $models.filter((u) => u.name.toLowerCase().includes(query.toLowerCase()));
+	$: if (query && userSuggestions) {
+		getUserList();
+	}
 
 	const select = (index: number) => {
 		const item = filteredItems[index];
-		// Add the "A:" prefix to the id to indicate it's an agent/assistant/ai model
-		if (item) command({ id: `A:${item.id}|${item.name}`, label: item.name });
+		if (!item) return;
+
+		// Add the "U:", "A:" or "C:" prefix to the id
+		// and also append the label after a pipe |
+		// so that the mention renderer can show the label
+		if (item)
+			command({
+				id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'A' : 'C'}:${item.id}|${item.label}`,
+				label: item.label
+			});
 	};
 
 	const onKeyDown = (event: KeyboardEvent) => {
 		if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
 
 		if (event.key === 'ArrowUp') {
-			selectedIndex = (selectedIndex + filteredItems.length - 1) % filteredItems.length;
+			selectedIndex = Math.max(0, selectedIndex - 1);
 			const item = document.querySelector(`[data-selected="true"]`);
 			item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
 			return true;
 		}
 		if (event.key === 'ArrowDown') {
-			selectedIndex = (selectedIndex + 1) % filteredItems.length;
+			selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
 			const item = document.querySelector(`[data-selected="true"]`);
 			item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
 			return true;
 		}
 		if (event.key === 'Enter' || event.key === 'Tab') {
 			select(selectedIndex);
+
+			if (event.key === 'Enter') {
+				event.preventDefault();
+			}
 			return true;
 		}
 		if (event.key === 'Escape') {
@@ -50,18 +97,57 @@
 	export function _onKeyDown(event: KeyboardEvent) {
 		return onKeyDown(event);
 	}
+
+	const keydownListener = (e) => {
+		// required to prevent the default enter behavior
+		if (e.key === 'Enter') {
+			e.preventDefault();
+			select(selectedIndex);
+		}
+	};
+
+	onMount(async () => {
+		window.addEventListener('keydown', keydownListener);
+		if (channelSuggestions) {
+			// Add a dummy channel item
+			_channels = [
+				...$channels.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
+			];
+		} else {
+			if (userSuggestions) {
+				await getUserList();
+			}
+
+			if (modelSuggestions) {
+				_models = [...$models.map((m) => ({ type: 'model', id: m.id, label: m.name, data: m }))];
+			}
+		}
+	});
+
+	onDestroy(() => {
+		window.removeEventListener('keydown', keydownListener);
+	});
 </script>
 
 {#if filteredItems.length}
 	<div
-		class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-60 p-1"
+		class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
 		id="suggestions-container"
 	>
 		<div class="overflow-y-auto scrollbar-thin max-h-60">
-			<div class="px-2 text-xs text-gray-500 py-1">
-				{$i18n.t('Models')}
-			</div>
 			{#each filteredItems as item, i}
+				{#if i === 0 || item?.type !== filteredItems[i - 1]?.type}
+					<div class="px-2 text-xs text-gray-500 py-1">
+						{#if item?.type === 'user'}
+							{$i18n.t('Users')}
+						{:else if item?.type === 'model'}
+							{$i18n.t('Models')}
+						{:else if item?.type === 'channel'}
+							{$i18n.t('Channels')}
+						{/if}
+					</div>
+				{/if}
+
 				<Tooltip content={item?.id} placement="top-start">
 					<button
 						type="button"
@@ -69,13 +155,47 @@
 						on:mousemove={() => {
 							selectedIndex = i;
 						}}
-						class="px-2.5 py-1.5 rounded-xl w-full text-left {i === selectedIndex
+						class="flex items-center justify-between px-2.5 py-1.5 rounded-xl w-full text-left {i ===
+						selectedIndex
 							? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
 							: ''}"
 						data-selected={i === selectedIndex}
 					>
-						<div class="truncate">
-							@{item.name}
+						{#if item.type === 'channel'}
+							<div class=" size-4 justify-center flex items-center mr-0.5">
+								{#if item?.data?.access_control === null}
+									<Hashtag className="size-3" strokeWidth="2.5" />
+								{:else}
+									<Lock className="size-[15px]" strokeWidth="2" />
+								{/if}
+							</div>
+						{:else if item.type === 'model'}
+							<img
+								src={item?.data?.info?.meta?.profile_image_url ??
+									`${WEBUI_BASE_URL}/static/favicon.png`}
+								alt={item?.data?.name ?? item.id}
+								class="rounded-full size-5 items-center mr-2"
+							/>
+						{:else if item.type === 'user'}
+							<img
+								src={`${WEBUI_API_BASE_URL}/users/${item.id}/profile/image`}
+								alt={item?.label ?? item.id}
+								class="rounded-full size-5 items-center mr-2"
+							/>
+						{/if}
+
+						<div class="truncate flex-1 pr-2">
+							{item.label}
+						</div>
+
+						<div class="shrink-0 text-xs text-gray-500">
+							{#if item.type === 'user'}
+								{$i18n.t('User')}
+							{:else if item.type === 'model'}
+								{$i18n.t('Model')}
+							{:else if item.type === 'channel'}
+								{$i18n.t('Channel')}
+							{/if}
 						</div>
 					</button>
 				</Tooltip>

+ 2 - 0
src/lib/components/channel/Thread.svelte

@@ -203,6 +203,8 @@
 					id={threadId}
 					typingUsersClassName="from-gray-50 dark:from-gray-850"
 					{typingUsers}
+					userSuggestions={true}
+					channelSuggestions={true}
 					{onChange}
 					onSubmit={submitHandler}
 				/>

+ 3 - 1
src/lib/components/chat/Messages/Markdown.svelte

@@ -38,7 +38,9 @@
 
 	marked.use(markedKatexExtension(options));
 	marked.use(markedExtension(options));
-	marked.use({ extensions: [mentionExtension({ triggerChar: '@' })] });
+	marked.use({
+		extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })]
+	});
 
 	$: (async () => {
 		if (content) {

+ 67 - 5
src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte

@@ -1,13 +1,16 @@
 <script lang="ts">
 	import type { Token } from 'marked';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import { goto } from '$app/navigation';
+	import { channels, models } from '$lib/stores';
+	import i18n from '$lib/i18n';
 
 	export let token: Token;
 
 	let triggerChar = '';
 	let label = '';
 
-	let idType = '';
+	let idType = null;
 	let id = '';
 
 	$: if (token) {
@@ -16,18 +19,77 @@
 
 	const init = () => {
 		const _id = token?.id;
-		if (_id?.includes(':')) {
-			idType = _id.split(':')[0];
-			id = _id.split(':')[1];
+		// split by : and take first part as idType and second part as id
+
+		const parts = _id?.split(':');
+		if (parts) {
+			idType = parts[0];
+			id = parts.slice(1).join(':'); // in case id contains ':'
 		} else {
+			idType = null;
 			id = _id;
 		}
 
 		label = token?.label ?? id;
 		triggerChar = token?.triggerChar ?? '@';
+
+		if (triggerChar === '#') {
+			if (idType === 'C') {
+				// Channel
+				const channel = $channels.find((c) => c.id === id);
+				if (channel) {
+					label = channel.name;
+				} else {
+					label = $i18n.t('Unknown');
+				}
+			} else if (idType === 'T') {
+				// Thread
+			}
+		} else if (triggerChar === '@') {
+			if (idType === 'U') {
+				// User
+			} else if (idType === 'A') {
+				// Agent/assistant/ai model
+				const model = $models.find((m) => m.id === id);
+				if (model) {
+					label = model.name;
+				} else {
+					label = $i18n.t('Unknown');
+				}
+			}
+		}
 	};
 </script>
 
-<Tooltip as="span" className="mention" content={id} placement="top">
+<Tooltip
+	as="span"
+	className="mention cursor-pointer"
+	onClick={async () => {
+		if (triggerChar === '@') {
+			if (idType === 'U') {
+				// Open user profile
+				console.log('Clicked user mention', id);
+			} else if (idType === 'A') {
+				// Open agent/assistant/ai model profile
+				console.log('Clicked agent mention', id);
+				await goto(`/?model=${id}`);
+			}
+		} else if (triggerChar === '#') {
+			if (idType === 'C') {
+				// Open channel
+				if ($channels.find((c) => c.id === id)) {
+					await goto(`/channels/${id}`);
+				}
+			} else if (idType === 'T') {
+				// Open thread
+			}
+		} else {
+			// Unknown trigger char, just log
+			console.log('Clicked mention', id);
+		}
+	}}
+	content={id}
+	placement="top"
+>
 	{triggerChar}{label}
 </Tooltip>

+ 4 - 1
src/lib/components/common/Tooltip.svelte

@@ -19,6 +19,8 @@
 	export let tippyOptions = {};
 	export let interactive = false;
 
+	export let onClick = () => {};
+
 	let tooltipElement;
 	let tooltipInstance;
 
@@ -61,7 +63,8 @@
 	});
 </script>
 
-<svelte:element this={as} bind:this={tooltipElement} class={className}>
+<!-- svelte-ignore a11y-no-static-element-interactions -->
+<svelte:element this={as} bind:this={tooltipElement} class={className} on:click={onClick}>
 	<slot />
 </svelte:element>
 

+ 19 - 0
src/lib/components/icons/Hashtag.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	class={className}
+	aria-hidden="true"
+	xmlns="http://www.w3.org/2000/svg"
+	stroke-width={strokeWidth}
+	fill="none"
+	stroke="currentColor"
+	viewBox="0 0 24 24"
+	><path d="M10 3L6 21" stroke-linecap="round"></path><path d="M20.5 16H2.5" stroke-linecap="round"
+	></path><path d="M22 7H4" stroke-linecap="round"></path><path
+		d="M18 3L14 21"
+		stroke-linecap="round"
+	></path></svg
+>

+ 19 - 0
src/lib/components/icons/Lock.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	class={className}
+	aria-hidden="true"
+	xmlns="http://www.w3.org/2000/svg"
+	stroke-width={strokeWidth}
+	fill="none"
+	stroke="currentColor"
+	viewBox="0 0 24 24"
+	><path
+		d="M16 12H17.4C17.7314 12 18 12.2686 18 12.6V19.4C18 19.7314 17.7314 20 17.4 20H6.6C6.26863 20 6 19.7314 6 19.4V12.6C6 12.2686 6.26863 12 6.6 12H8M16 12V8C16 6.66667 15.2 4 12 4C8.8 4 8 6.66667 8 8V12M16 12H8"
+		stroke-linecap="round"
+		stroke-linejoin="round"
+	></path></svg
+>

+ 12 - 14
src/lib/components/layout/Sidebar/ChannelItem.svelte

@@ -9,6 +9,8 @@
 
 	import Cog6 from '$lib/components/icons/Cog6.svelte';
 	import ChannelModal from './ChannelModal.svelte';
+	import Lock from '$lib/components/icons/Lock.svelte';
+	import Hashtag from '$lib/components/icons/Hashtag.svelte';
 
 	export let onUpdate: Function = () => {};
 
@@ -52,27 +54,23 @@
 		class=" w-full flex justify-between"
 		href="/channels/{channel.id}"
 		on:click={() => {
+			console.log(channel);
 			if ($mobile) {
 				showSidebar.set(false);
 			}
 		}}
 		draggable="false"
 	>
-		<div class="flex items-center gap-1">
-			<svg
-				xmlns="http://www.w3.org/2000/svg"
-				viewBox="0 0 16 16"
-				fill="currentColor"
-				class="size-5"
-			>
-				<path
-					fill-rule="evenodd"
-					d="M7.487 2.89a.75.75 0 1 0-1.474-.28l-.455 2.388H3.61a.75.75 0 0 0 0 1.5h1.663l-.571 2.998H2.75a.75.75 0 0 0 0 1.5h1.666l-.403 2.114a.75.75 0 0 0 1.474.28l.456-2.394h2.973l-.403 2.114a.75.75 0 0 0 1.474.28l.456-2.394h1.947a.75.75 0 0 0 0-1.5h-1.661l.57-2.998h1.95a.75.75 0 0 0 0-1.5h-1.664l.402-2.108a.75.75 0 0 0-1.474-.28l-.455 2.388H7.085l.402-2.108ZM6.8 6.498l-.571 2.998h2.973l.57-2.998H6.8Z"
-					clip-rule="evenodd"
-				/>
-			</svg>
+		<div class="flex items-center gap-1 shrink-0">
+			<div class=" size-4 justify-center flex items-center">
+				{#if channel?.access_control === null}
+					<Hashtag className="size-3" strokeWidth="2.5" />
+				{:else}
+					<Lock className="size-[15px]" strokeWidth="2" />
+				{/if}
+			</div>
 
-			<div class=" text-left self-center overflow-hidden w-full line-clamp-1">
+			<div class=" text-left self-center overflow-hidden w-full line-clamp-1 flex-1">
 				{channel.name}
 			</div>
 		</div>