This commit is contained in:
Timothy Jaeryang Baek 2026-11-02 10:09:34 +09:00
parent b659b534d4
commit 151f7a84d1
10 changed files with 124 additions and 51 deletions

View file

@ -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': {

View file

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

View file

@ -949,7 +949,21 @@
{/if}
</div>
{#if permissions.chat.temporary}
{#if permissions.chat.temporary || defaultPermissions?.chat?.temporary}
<div class="ml-2 flex flex-col">
<div class="flex w-full justify-between my-1">
<div class="self-center text-xs font-normal">
{$i18n.t('Allow Side Chat')}
</div>
<Switch bind:state={permissions.chat.side_chat} ariaLabel={$i18n.t('Allow Side Chat')} />
</div>
{#if defaultPermissions?.chat?.side_chat && !permissions.chat.side_chat}
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-normal">

View file

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

View file

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

View file

@ -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<any>('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 @@
</div>
<div class="shrink-0 pb-2 z-10">
{#if error}<p role="alert" class="px-4 py-2 text-xs text-red-500">{error}</p>{/if}
<MessageInput
bind:this={input}
bind:prompt={draft}
bind:autoScroll
chatId={id}
history={sideHistory}
bind:selectedModels
multipleModels={false}
bind:atSelectedModel
bind:files
bind:params
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:webSearchEnabled
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
{toolApprovalMode}
onToolApprovalModeChange={(mode: string) => {
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}
<MessageInput
bind:this={input}
bind:prompt={draft}
bind:autoScroll
chatId={id}
history={sideHistory}
bind:selectedModels
multipleModels={false}
bind:atSelectedModel
bind:files
bind:params
bind:selectedToolIds
bind:selectedSkillIds
bind:selectedFilterIds
bind:webSearchEnabled
bind:imageGenerationEnabled
bind:codeInterpreterEnabled
{toolApprovalMode}
onToolApprovalModeChange={(mode: string) => {
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}
<p role="status" class="px-4 py-2 text-xs text-gray-500">
{$i18n.t('You do not have permission to use side chat.')}
</p>
{/if}
</div>
</div>

View file

@ -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');
},

View file

@ -58,6 +58,7 @@ export const DEFAULT_PERMISSIONS = {
call: true,
multiple_models: true,
temporary: true,
side_chat: false,
temporary_enforced: false
},
features: {

View file

@ -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.": "",

View file

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