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}
{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);
};
});