From bbd1d2b58c89b35daea234f1fc9208f2af840899 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 16 Sep 2025 21:41:47 -0500 Subject: [PATCH] enh: channel suggestions --- backend/open_webui/models/users.py | 10 ++ backend/open_webui/routers/users.py | 18 +++ src/lib/apis/users/index.ts | 28 ++++ .../components/admin/Users/UserList.svelte | 6 +- src/lib/components/channel/Channel.svelte | 2 + .../components/channel/MessageInput.svelte | 20 ++- .../channel/MessageInput/MentionList.svelte | 150 ++++++++++++++++-- src/lib/components/channel/Thread.svelte | 2 + .../components/chat/Messages/Markdown.svelte | 4 +- .../MarkdownInlineTokens/MentionToken.svelte | 72 ++++++++- src/lib/components/common/Tooltip.svelte | 5 +- src/lib/components/icons/Hashtag.svelte | 19 +++ src/lib/components/icons/Lock.svelte | 19 +++ .../layout/Sidebar/ChannelItem.svelte | 26 ++- 14 files changed, 341 insertions(+), 40 deletions(-) create mode 100644 src/lib/components/icons/Hashtag.svelte create mode 100644 src/lib/components/icons/Lock.svelte diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 620a746eed..81edc2d924 100644 --- a/backend/open_webui/models/users.py +++ b/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 diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 5b331dce73..9a0f8c6aaf 100644 --- a/backend/open_webui/routers/users.py +++ b/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 ############################ diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index bdb44f2627..ac057359a5 100644 --- a/src/lib/apis/users/index.ts +++ b/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`, { diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte index 86e04020cf..5a844cefb5 100644 --- a/src/lib/components/admin/Users/UserList.svelte +++ b/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. 💛 > diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index b9044c6942..53aaefc05b 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -250,6 +250,8 @@ - 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 = '@'; - $: filteredItems = $models.filter((u) => u.name.toLowerCase().includes(query.toLowerCase())); + 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) + ); + } + }; + + $: 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); + }); {#if filteredItems.length}
-
- {$i18n.t('Models')} -
{#each filteredItems as item, i} + {#if i === 0 || item?.type !== filteredItems[i - 1]?.type} +
+ {#if item?.type === 'user'} + {$i18n.t('Users')} + {:else if item?.type === 'model'} + {$i18n.t('Models')} + {:else if item?.type === 'channel'} + {$i18n.t('Channels')} + {/if} +
+ {/if} + diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte index 247c6ab287..7d0f926668 100644 --- a/src/lib/components/channel/Thread.svelte +++ b/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} /> diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index c2ef2b923e..c33e452a6c 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/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) { diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte index 8b6b76a394..98642fe8c5 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte @@ -1,13 +1,16 @@ - + { + 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} diff --git a/src/lib/components/common/Tooltip.svelte b/src/lib/components/common/Tooltip.svelte index 59575520e6..a8e79e4841 100644 --- a/src/lib/components/common/Tooltip.svelte +++ b/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 @@ }); - + + diff --git a/src/lib/components/icons/Hashtag.svelte b/src/lib/components/icons/Hashtag.svelte new file mode 100644 index 0000000000..08d229954e --- /dev/null +++ b/src/lib/components/icons/Hashtag.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/icons/Lock.svelte b/src/lib/components/icons/Lock.svelte new file mode 100644 index 0000000000..bd0be308d1 --- /dev/null +++ b/src/lib/components/icons/Lock.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index 26356a5534..755e3b77ba 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/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" > -
- - - +
+
+ {#if channel?.access_control === null} + + {:else} + + {/if} +
-
+
{channel.name}