enh: channel suggestions

This commit is contained in:
Timothy Jaeryang Baek 2025-09-16 21:41:47 -05:00
parent 99bba12de2
commit bbd1d2b58c
14 changed files with 341 additions and 40 deletions

View file

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

View file

@ -18,6 +18,7 @@ from open_webui.models.users import (
UserModel, UserModel,
UserListResponse, UserListResponse,
UserInfoListResponse, UserInfoListResponse,
UserIdNameListResponse,
UserRoleUpdateForm, UserRoleUpdateForm,
Users, Users,
UserSettings, UserSettings,
@ -100,6 +101,23 @@ async def get_all_users(
return Users.get_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 # User Groups
############################ ############################

View file

@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
return res; 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) => { export const getUserSettings = async (token: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, { const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {

View file

@ -502,11 +502,11 @@
> [!NOTE] > [!NOTE]
> # **Hey there! 👋** > # **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, youre not only helping us stay independent, youre 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, youre not only helping us stay independent, youre 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. 💛 > Your support helps us stay independent and continue building great tools for everyone. 💛
> >

View file

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

View file

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

View file

@ -1,41 +1,88 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext, onDestroy, onMount } from 'svelte';
const i18n = getContext('i18n'); 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 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 query = '';
export let command: (payload: { id: string; label: string }) => void; export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0; 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 select = (index: number) => {
const item = filteredItems[index]; const item = filteredItems[index];
// Add the "A:" prefix to the id to indicate it's an agent/assistant/ai model if (!item) return;
if (item) command({ id: `A:${item.id}|${item.name}`, label: item.name });
// 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) => { const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false; if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
selectedIndex = (selectedIndex + filteredItems.length - 1) % filteredItems.length; selectedIndex = Math.max(0, selectedIndex - 1);
const item = document.querySelector(`[data-selected="true"]`); const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true; return true;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % filteredItems.length; selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
const item = document.querySelector(`[data-selected="true"]`); const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true; return true;
} }
if (event.key === 'Enter' || event.key === 'Tab') { if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex); select(selectedIndex);
if (event.key === 'Enter') {
event.preventDefault();
}
return true; return true;
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
@ -50,18 +97,57 @@
export function _onKeyDown(event: KeyboardEvent) { export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event); 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> </script>
{#if filteredItems.length} {#if filteredItems.length}
<div <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" id="suggestions-container"
> >
<div class="overflow-y-auto scrollbar-thin max-h-60"> <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} {#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"> <Tooltip content={item?.id} placement="top-start">
<button <button
type="button" type="button"
@ -69,13 +155,47 @@
on:mousemove={() => { on:mousemove={() => {
selectedIndex = i; 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' ? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}" : ''}"
data-selected={i === selectedIndex} data-selected={i === selectedIndex}
> >
<div class="truncate"> {#if item.type === 'channel'}
@{item.name} <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> </div>
</button> </button>
</Tooltip> </Tooltip>

View file

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

View file

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

View file

@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { Token } from 'marked'; import type { Token } from 'marked';
import Tooltip from '$lib/components/common/Tooltip.svelte'; 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; export let token: Token;
let triggerChar = ''; let triggerChar = '';
let label = ''; let label = '';
let idType = ''; let idType = null;
let id = ''; let id = '';
$: if (token) { $: if (token) {
@ -16,18 +19,77 @@
const init = () => { const init = () => {
const _id = token?.id; const _id = token?.id;
if (_id?.includes(':')) { // split by : and take first part as idType and second part as id
idType = _id.split(':')[0];
id = _id.split(':')[1]; const parts = _id?.split(':');
if (parts) {
idType = parts[0];
id = parts.slice(1).join(':'); // in case id contains ':'
} else { } else {
idType = null;
id = _id; id = _id;
} }
label = token?.label ?? id; label = token?.label ?? id;
triggerChar = token?.triggerChar ?? '@'; 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> </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} {triggerChar}{label}
</Tooltip> </Tooltip>

View file

@ -19,6 +19,8 @@
export let tippyOptions = {}; export let tippyOptions = {};
export let interactive = false; export let interactive = false;
export let onClick = () => {};
let tooltipElement; let tooltipElement;
let tooltipInstance; let tooltipInstance;
@ -61,7 +63,8 @@
}); });
</script> </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 /> <slot />
</svelte:element> </svelte:element>

View file

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

View file

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

View file

@ -9,6 +9,8 @@
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import ChannelModal from './ChannelModal.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 = () => {}; export let onUpdate: Function = () => {};
@ -52,27 +54,23 @@
class=" w-full flex justify-between" class=" w-full flex justify-between"
href="/channels/{channel.id}" href="/channels/{channel.id}"
on:click={() => { on:click={() => {
console.log(channel);
if ($mobile) { if ($mobile) {
showSidebar.set(false); showSidebar.set(false);
} }
}} }}
draggable="false" draggable="false"
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1 shrink-0">
<svg <div class=" size-4 justify-center flex items-center">
xmlns="http://www.w3.org/2000/svg" {#if channel?.access_control === null}
viewBox="0 0 16 16" <Hashtag className="size-3" strokeWidth="2.5" />
fill="currentColor" {:else}
class="size-5" <Lock className="size-[15px]" strokeWidth="2" />
> {/if}
<path </div>
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=" 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} {channel.name}
</div> </div>
</div> </div>