diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 3a7208a317..3958c9b148 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1919,6 +1919,8 @@ USER_PERMISSIONS_CHAT_MULTIPLE_MODELS = os.getenv('USER_PERMISSIONS_CHAT_MULTIPL USER_PERMISSIONS_CHAT_TEMPORARY = os.getenv('USER_PERMISSIONS_CHAT_TEMPORARY', 'True').lower() == 'true' +USER_PERMISSIONS_CHAT_SIDE_CHAT = os.getenv('USER_PERMISSIONS_CHAT_SIDE_CHAT', 'False').lower() == 'true' + USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = ( os.getenv('USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED', 'False').lower() == 'true' ) @@ -2024,6 +2026,7 @@ DEFAULT_USER_PERMISSIONS = { 'call': USER_PERMISSIONS_CHAT_CALL, 'multiple_models': USER_PERMISSIONS_CHAT_MULTIPLE_MODELS, 'temporary': USER_PERMISSIONS_CHAT_TEMPORARY, + 'side_chat': USER_PERMISSIONS_CHAT_SIDE_CHAT, 'temporary_enforced': USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, }, 'features': { diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 6856daa50a..7b5f41c980 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -249,6 +249,7 @@ class ChatPermissions(BaseModel): call: bool = True multiple_models: bool = True temporary: bool = True + side_chat: bool = False temporary_enforced: bool = False @@ -424,6 +425,12 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_ async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)): user_permissions = form_data.model_dump(by_alias=True) await Config.upsert({'user.permissions': user_permissions}) + from open_webui.socket.main import sio + + try: + await sio.emit('access:updated', {}) + except Exception: + log.exception('Unable to refresh default user permissions') await publish_event( request, EVENTS.USER_PERMISSIONS_UPDATED, diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 5f8527f3ba..1964c3aaa6 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -949,7 +949,21 @@ {/if} - {#if permissions.chat.temporary} + {#if permissions.chat.temporary || defaultPermissions?.chat?.temporary} +
+
+
+ {$i18n.t('Allow Side Chat')} +
+ +
+ {#if defaultPermissions?.chat?.side_chat && !permissions.chat.side_chat} +
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+ {/if} +
+
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 7e8bf7a57a..9343c94840 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -434,6 +434,11 @@ $: isShared = chat != null && chat.user_id !== $user?.id; $: if (isShared && $showCallOverlay) showCallOverlay.set(false); $: canClone = isShared && ($user?.role === 'admin' || ($user?.permissions?.chat?.import ?? true)); + $: sideChatAllowed = + $user?.role === 'admin' || + ($user?.role === 'user' && + ($user.permissions?.chat?.temporary ?? true) && + ($user.permissions?.chat?.side_chat ?? false)); let cloning = false; const cloneSharedChat = async () => { @@ -3931,6 +3936,10 @@ return { status: 'rejected' }; } const sideCommand = String(userPrompt).trim().match(/^\/(?:side|btw)(?:\s+([\s\S]*))?$/i); + if (sideCommand && !sideChatAllowed) { + toast.error($i18n.t('You do not have permission to use side chat.')); + return { status: 'rejected' }; + } if (sideCommand && !embedded && $chatId && history.currentId) { if (selectedModels.length !== 1 || !selectedModelIds[0]) { toast.error($i18n.t('Select only one model to open a side chat.')); @@ -5676,7 +5685,7 @@ chatId={$chatId} chatUser={chatOwner} modelId={selectedModelIds?.at(0) ?? null} - canSideChat={selectedModels.length === 1 && !!selectedModelIds[0]} + canSideChat={sideChatAllowed && selectedModels.length === 1 && !!selectedModelIds[0]} systemPrompt={params?.system || $settings.system || ''} submitPrompt={submitHandler} onAskFile={askFile} diff --git a/src/lib/components/chat/ChatTabs.svelte b/src/lib/components/chat/ChatTabs.svelte index 176c05aca4..4ce5799969 100644 --- a/src/lib/components/chat/ChatTabs.svelte +++ b/src/lib/components/chat/ChatTabs.svelte @@ -14,6 +14,7 @@ temporaryChatEnabled, terminalServers, showTabs, + sideChatRequest, showOverview, showSidebar, mobile, @@ -574,10 +575,20 @@ else if (options.length === 1) options[0](); hasOpenedTabs = true; } + $: if ( + !( + $user?.role === 'admin' || + ($user?.role === 'user' && + ($user.permissions?.chat?.temporary ?? true) && + ($user.permissions?.chat?.side_chat ?? false)) + ) && + $sideChatRequest?.parentId === chatId + ) sideChatRequest.set(null); $: if ($showTabs === 'files' && showFilesTab) openFiles(); $: if ($showTabs === 'side' && chatId && hasMessages) { - openFeature('side'); - showTabs.set(true); + if (canSideChat) openFeature('side'); + else sideChatRequest.set(null); + showTabs.set(tabs.tabs.length > 0); } $: if ( ($showTabs === 'comments' || (typeof $showTabs === 'object' && $showTabs)) && diff --git a/src/lib/components/chat/ChatTabs/SideChat.svelte b/src/lib/components/chat/ChatTabs/SideChat.svelte index ef176e2f0c..ccc53e32e7 100644 --- a/src/lib/components/chat/ChatTabs/SideChat.svelte +++ b/src/lib/components/chat/ChatTabs/SideChat.svelte @@ -3,6 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { socket, + user, openTemporaryChatIds, sideChatRequest, models, @@ -28,6 +29,11 @@ export let modelId: string; export let systemPrompt = ''; const i18n = getContext('i18n'); + $: sideChatAllowed = + $user?.role === 'admin' || + ($user?.role === 'user' && + ($user.permissions?.chat?.temporary ?? true) && + ($user.permissions?.chat?.side_chat ?? false)); // Snapshot the active branch once; later parent edits and streaming stay in the parent. const context = (() => { const branch: any[] = []; @@ -115,7 +121,8 @@ callback && ['request:user_input', 'request:elicitation', 'confirmation', 'input'].includes(type) ) { - if (data?.tool_call && toolApprovalMode === 'full') callback(true); + if (!sideChatAllowed) callback({ status: 'cancelled', answers: {} }); + else if (data?.tool_call && toolApprovalMode === 'full') callback(true); else interactions = [ ...interactions, @@ -159,10 +166,10 @@ }; const reconnect = async () => { - if (!id || !$socket?.id) return; + if (!sideChatAllowed || !id || !$socket?.id) return; try { const result = await resumeTemporaryChat(localStorage.token, id, $socket.id); - if (disposed) return; + if (disposed || !sideChatAllowed) return; for (const stream of result.streams) { const message = messages.find((m) => m.id === stream.message_id); if (message && !message.done) message.output = stream.output; @@ -184,6 +191,7 @@ }; const send = async () => { + if (!sideChatAllowed) return; const question = draft.trim(); if ((!question && !files.length) || busy) return; if (!atSelectedModel && selectedModels.length !== 1) { @@ -204,7 +212,8 @@ openTemporaryChatIds.update((ids) => new Set([...ids, id])); } await tick(); - if (!(await input.uploadPendingFiles(id)) || busy) return; + if (!sideChatAllowed) return; + if (!(await input?.uploadPendingFiles(id)) || busy || !sideChatAllowed) return; const userMessage = { id: uuidv4(), parentId: messages.at(-1)?.id ?? null, @@ -325,10 +334,15 @@ } }; + $: if (!sideChatAllowed) { + cancelInteractions(); + if (busy) void stop(); + } + $: if (ready && $sideChatRequest?.parentId === parentId) { const pending = $sideChatRequest; sideChatRequest.set(null); - if (pending.prompt) { + if (sideChatAllowed && pending.prompt) { draft = pending.prompt; input?.setText(draft); if (!busy) void send(); @@ -408,40 +422,46 @@
{#if error}{/if} - { - params = { ...params, tool_approval_mode: mode }; - }} - {askUser} - dropzoneId={`${inputId}-pane`} - {inputId} - {messagesContainerId} - temporary={true} - embedded - callEnabled={false} - terminalSelectionEnabled={false} - generating={busy} - stopResponse={stop} - createMessagePair={() => {}} - on:submit={() => void send()} - /> + {#if sideChatAllowed} + { + params = { ...params, tool_approval_mode: mode }; + }} + {askUser} + dropzoneId={`${inputId}-pane`} + {inputId} + {messagesContainerId} + temporary={true} + embedded + callEnabled={false} + terminalSelectionEnabled={false} + generating={busy} + stopResponse={stop} + createMessagePair={() => {}} + on:submit={() => void send()} + /> + {:else} +

+ {$i18n.t('You do not have permission to use side chat.')} +

+ {/if}
diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 161af28603..92ee0a58a0 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -203,6 +203,16 @@ (history.currentId && history.messages[history.currentId]?.done != true) || generating); $: canCompact = !!history?.currentId; + $: canSide = + ($_user?.role === 'admin' || + ($_user?.role === 'user' && + ($_user.permissions?.chat?.temporary ?? true) && + ($_user.permissions?.chat?.side_chat ?? false))) && + !embedded && + selectedModels.length === 1 && + !!selectedModelIds[0] && + !!chatId && + !!history?.currentId; $: canComments = !!( !embedded && $config?.features?.enable_comments && @@ -1626,7 +1636,7 @@ compactDisabled: () => isActive, canStatus: () => !!history?.currentId, canComments: () => canComments, - canSide: () => !embedded && selectedModels.length === 1 && !!selectedModelIds[0] && !!chatId && !!history?.currentId, + canSide: () => canSide, canFork: () => !!history?.currentId && !isTemporary && @@ -1641,6 +1651,7 @@ onStatus: statusHandler, onComments: openComments, onSide: () => { + if (!canSide) return; sideChatRequest.set({ parentId: chatId, prompt: '' }); showTabs.set('side'); }, diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index 9b23d642c6..38accf5113 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -58,6 +58,7 @@ export const DEFAULT_PERMISSIONS = { call: true, multiple_models: true, temporary: true, + side_chat: false, temporary_enforced: false }, features: { diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index c71702b7f8..605cce4c89 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -241,6 +241,7 @@ "Allow Sharing With Groups": "", "Allow Sharing With Users": "", "Allow Speech to Text": "", + "Allow Side Chat": "", "Allow Temporary Chat": "", "Allow Text to Speech": "", "Allow the AI to delegate tasks to sub-agents. Each sub-agent creates a real chat with full tool access. Uses additional LLM calls.": "", @@ -4343,6 +4344,7 @@ "You do not have permission to upload files to this knowledge base.": "", "You do not have permission to upload files.": "", "You do not have permission to upload web content.": "", + "You do not have permission to use side chat.": "", "You have more than 50 users, which often means this workspace is supporting organizational use. Open WebUI is free to use as-is, with no restrictions or hidden limits, and we want to keep it that way.": "", "You have no archived conversations.": "", "You have no shared conversations.": "", diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 641b2aeac4..57a5dd0aab 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -198,14 +198,9 @@ onMount(() => { const socketInstance = $socket; - const scheduleAccessRefresh = () => { - socketInstance?.off('connect', refreshAccess); - socketInstance?.once('connect', refreshAccess); - }; - socketInstance?.on('access:updated', scheduleAccessRefresh); + socketInstance?.on('access:updated', refreshAccess); return () => { - socketInstance?.off('access:updated', scheduleAccessRefresh); - socketInstance?.off('connect', refreshAccess); + socketInstance?.off('access:updated', refreshAccess); }; });