From fb5431a09c924347f625aa3dad4c10c57c4382b4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 15 Oct 2026 02:19:49 +0400 Subject: [PATCH 01/90] refac --- .../Settings/Advanced/AdvancedParams.svelte | 128 +++++++++++++----- 1 file changed, 96 insertions(+), 32 deletions(-) diff --git a/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte b/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte index c563a5254a..197fadf24e 100644 --- a/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte +++ b/src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte @@ -118,7 +118,9 @@ className="inline-tooltip" >
-
+
{$i18n.t('settings.personal.general.parameters.streamChatResponse.label')}
copy('to-proposal')}>{$i18n.t('Original')} → +
{/if} + {#if comparing} + + +
{ + if (event.key === 'Escape') { + event.stopPropagation(); + optionsDropdown.close(); + } + }} + > + + + + + +
+
+ {/if}
@@ -754,6 +916,7 @@ order: 3; width: 100%; } + .comparison-options, .comparison-close { order: 2; } @@ -822,39 +985,49 @@ .merge-container :global(.cm-mergeViewEditor) { min-width: 0; } - .merge-container :global(.cm-merge-a .cm-changedLine) { + .merge-container :global(.cm-merge-a .cm-changedLine), + .merge-container :global(.cm-merge-a .cm-visibleChangedLine) { background: #fce9e6; } - .merge-container :global(.cm-merge-b .cm-changedLine) { + .merge-container :global(.cm-merge-b .cm-changedLine), + .merge-container :global(.cm-merge-b .cm-visibleChangedLine) { background: #e0f5f1; } - .merge-container :global(.cm-merge-a .cm-changedText) { + .merge-container :global(.cm-merge-a .cm-changedText), + .merge-container :global(.cm-merge-a .cm-visibleChangedText) { background: #f9b9ad; } - .merge-container :global(.cm-merge-b .cm-changedText) { + .merge-container :global(.cm-merge-b .cm-changedText), + .merge-container :global(.cm-merge-b .cm-visibleChangedText) { background: #a3e3d8; } - .merge-container :global(.cm-changedText) { + .merge-container :global(.cm-changedText), + .merge-container :global(.cm-visibleChangedText) { border-radius: 3px; } .merge-container :global(.cm-mergeSpacer) { background: repeating-linear-gradient(135deg, transparent 0 3px, #94a3b814 3px 4px); } - :global(.dark) .merge-container :global(.cm-merge-a .cm-changedLine) { + :global(.dark) .merge-container :global(.cm-merge-a .cm-changedLine), + :global(.dark) .merge-container :global(.cm-merge-a .cm-visibleChangedLine) { background: #3d1915; color: #f4d0c7; } - :global(.dark) .merge-container :global(.cm-merge-b .cm-changedLine) { + :global(.dark) .merge-container :global(.cm-merge-b .cm-changedLine), + :global(.dark) .merge-container :global(.cm-merge-b .cm-visibleChangedLine) { background: #0d302a; color: #a9e9dd; } - :global(.dark) .merge-container :global(.cm-changedLine span) { + :global(.dark) .merge-container :global(.cm-changedLine span), + :global(.dark) .merge-container :global(.cm-visibleChangedLine span) { color: inherit; } - :global(.dark) .merge-container :global(.cm-merge-a .cm-changedText) { + :global(.dark) .merge-container :global(.cm-merge-a .cm-changedText), + :global(.dark) .merge-container :global(.cm-merge-a .cm-visibleChangedText) { background: #77362c; } - :global(.dark) .merge-container :global(.cm-merge-b .cm-changedText) { + :global(.dark) .merge-container :global(.cm-merge-b .cm-changedText), + :global(.dark) .merge-container :global(.cm-merge-b .cm-visibleChangedText) { background: #176456; } :global(.dark) .merge-container :global(.cm-merge-a .cm-changedLineGutter) { @@ -880,4 +1053,12 @@ /* Reserve the same gutter width, including padding and fractional glyph rounding. */ min-width: calc(var(--diff-line-digits) * 1ch + 9px); } + + .filtering-differences :global(.cm-changedLine:not(.cm-visibleChangedLine)) { + background: transparent !important; + color: inherit !important; + } + .filtering-differences :global(.cm-changedText:not(.cm-visibleChangedText)) { + background: transparent !important; + } diff --git a/src/lib/components/common/diffEditor.ts b/src/lib/components/common/diffEditor.ts index 877e3b4584..4138cbf950 100644 --- a/src/lib/components/common/diffEditor.ts +++ b/src/lib/components/common/diffEditor.ts @@ -1,5 +1,6 @@ -import type { ChangeSpec, Text } from '@codemirror/state'; -import type { Chunk, DiffConfig } from '@codemirror/merge'; +import { StateEffect, StateField, type ChangeSpec, type Text } from '@codemirror/state'; +import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view'; +import { Chunk, type DiffConfig } from '@codemirror/merge'; // A scanLimit also gives up based on document length, even for sparse, tiny edits. export const diffEditorConfig: DiffConfig = { timeout: 100 }; @@ -132,3 +133,146 @@ export function selectedChunk(chunks: readonly Chunk[], side: 'a' | 'b', positio return position >= from && (position < to || position === from); }); } + +export function visibleDiffChunks( + a: Text, + b: Text, + chunks: readonly Chunk[], + ignoreWhitespace: boolean, + ignoreEmphasis = false +) { + if (!ignoreWhitespace && !ignoreEmphasis) return chunks; + const ignored = ignoreWhitespace ? (ignoreEmphasis ? /[\s_*]/g : /\s/g) : /[_*]/g; + return chunks.flatMap((chunk) => { + const changes = chunk.changes.filter( + (change) => + a.sliceString(chunk.fromA + change.fromA, chunk.fromA + change.toA).replace(ignored, '') !== + b.sliceString(chunk.fromB + change.fromB, chunk.fromB + change.toB).replace(ignored, '') + ); + return changes.length + ? [new Chunk(changes, chunk.fromA, chunk.toA, chunk.fromB, chunk.toB, chunk.precise)] + : []; + }); +} + +export type CollapsedPair = { + a: { from: number; to: number; lines: number }; + b: { from: number; to: number; lines: number }; +}; + +export function unchangedRanges(a: Text, b: Text, chunks: readonly Chunk[]): CollapsedPair[] { + const result: CollapsedPair[] = []; + let previousA = 0, + previousB = 0; + for (let index = 0; index <= chunks.length; index++) { + const chunk = chunks[index]; + const range = (doc: Text, previous: number, next: number | undefined) => { + const first = index ? doc.lineAt(Math.min(previous, doc.length)).number + 3 : 1; + const last = next === undefined ? doc.lines : doc.lineAt(next).number - 4; + return last - first >= 3 + ? { from: doc.line(first).from, to: doc.line(last).to, lines: last - first + 1 } + : null; + }; + const left = range(a, previousA, chunk?.fromA), + right = range(b, previousB, chunk?.fromB); + if (left && right) result.push({ a: left, b: right }); + if (chunk) { + previousA = chunk.toA; + previousB = chunk.toB; + } + } + return result; +} + +class CollapsedLines extends WidgetType { + constructor( + readonly label: string, + readonly expand: () => void + ) { + super(); + } + toDOM() { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'cm-collapsedLines w-full text-left'; + button.textContent = this.label; + button.addEventListener('click', this.expand); + return button; + } + get estimatedHeight() { + return 27; + } + ignoreEvent() { + return true; + } +} + +export const setDiffPresentation = StateEffect.define(); +const presentation = StateField.define({ + create: () => Decoration.none, + update(value, transaction) { + for (const effect of transaction.effects) + if (effect.is(setDiffPresentation)) return effect.value; + return value.map(transaction.changes); + }, + provide: (field) => EditorView.decorations.from(field) +}); +export const diffPresentation = [ + presentation, + EditorView.atomicRanges.of((view) => + view.state + .field(presentation) + .update({ filter: (_from, _to, decoration) => !!decoration.spec.block }) + ) +]; + +export function presentationDecorations( + doc: Text, + side: 'a' | 'b', + chunks: readonly Chunk[], + ignoreWhitespace: boolean, + ignoreEmphasis: boolean, + folds: readonly CollapsedPair[], + label: (lines: number) => string, + expand: (pair: CollapsedPair) => void +) { + const decorations = []; + if (ignoreWhitespace || ignoreEmphasis) { + const visible = ignoreWhitespace ? (ignoreEmphasis ? /[^\s_*]+/g : /\S+/g) : /[^_*]+/g; + const rows = new Set(); + for (const chunk of chunks) { + const offset = side === 'a' ? chunk.fromA : chunk.fromB; + for (const change of chunk.changes) { + const from = offset + (side === 'a' ? change.fromA : change.fromB); + const to = offset + (side === 'a' ? change.toA : change.toB); + for (const match of doc.sliceString(from, to).matchAll(visible)) { + const start = from + match.index; + decorations.push( + Decoration.mark({ class: 'cm-visibleChangedText' }).range( + start, + start + match[0].length + ) + ); + for ( + let line = doc.lineAt(start).number; + line <= doc.lineAt(start + match[0].length - 1).number; + line++ + ) + rows.add(doc.line(line).from); + } + } + } + for (const from of rows) + decorations.push(Decoration.line({ class: 'cm-visibleChangedLine' }).range(from)); + } + for (const pair of folds) { + const range = pair[side]; + decorations.push( + Decoration.replace({ + block: true, + widget: new CollapsedLines(label(range.lines), () => expand(pair)) + }).range(range.from, range.to) + ); + } + return Decoration.set(decorations, true); +} From 613eac51c5c9cc67065998282af16e8016d5a2cb Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 15 Oct 2026 15:53:18 +0400 Subject: [PATCH 14/90] refac --- src/lib/components/chat/FileNav.svelte | 20 ++++++++----- .../chat/FileNav/FileEntryRow.svelte | 12 ++++---- .../chat/FileNav/FileNavToolbar.svelte | 30 ++++++++++--------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 1e821dc173..511451d991 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -13,6 +13,8 @@ - - +{#if !readOnly} + + +{/if} diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index 53d35c9323..56831ebaf7 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -30,6 +30,7 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; const i18n = getContext>('i18n'); + const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown'); export let id = ''; export let edit = true; @@ -368,6 +369,7 @@ const render = async () => { onUpdate(token, id); + if (markdown?.allowRemoteContent === false) return; if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) { try { renderHTML = await renderMermaid(code); @@ -440,7 +442,7 @@ class="relative {className} flex flex-col rounded-2xl border border-gray-100/30 dark:border-gray-850/30 my-0.5 overflow-clip" dir="ltr" > - {#if ['mermaid', 'vega', 'vega-lite'].includes(lang)} + {#if markdown?.allowRemoteContent !== false && ['mermaid', 'vega', 'vega-lite'].includes(lang)} {#if renderHTML} {$i18n.t('Running')} - {:else if run} + {:else if run && markdown?.allowRemoteContent !== false} + + {/if} + + + + {#if state.status === 'ended'} +
+

+ {$i18n.t('Encrypted chat ended')} +

+ +
+ {:else} +
+
+ {#if state.messages.length === 0} +
+

+ {state.status === 'joining' + ? $i18n.t('Joining encrypted chat…') + : $i18n.t('Messages are end-to-end encrypted and aren’t saved.')} +

+

{$i18n.t('Leaving or reloading clears this device’s chat.')}

+
+ {/if} + {#each state.messages as message (message.id)} + {#if message.type === 'joined'} +

+ {$i18n.t('{{NAME}} joined', { NAME: message.user.name })} +

+ {:else} + + + + + + {#if message.sender === state.self} + {#if message.expected.length > 0 && message.received.length === message.expected.length} + + {$i18n.t('Delivered')} + + {:else if !message.relayed} + + {$i18n.t('Sending…')} + + {/if} + {/if} + + + {/if} + {/each} +
+
+
+
+
+
+ { + draft = state.status === 'active' ? md : ''; + }} + on:keydown={({ detail: { event } }) => { + if (event.isComposing || event.defaultPrevented || event.key !== 'Enter') return; + const control = event.ctrlKey || event.metaKey; + if ( + ($settings?.ctrlEnterToSend ?? false) + ? control + : !event.shiftKey && (!$mobile || control) + ) { + event.preventDefault(); + send(); + } + }} + /> +
+
+
+ {#if oversized}{$i18n.t('Message exceeds the 16 KiB limit.')}{/if} + + + +
+
+
+ {/if} + + + +
+

{$i18n.t('Participants')}

+ +
+
+ {#each state.members as member (member.identity.participation)} +
+ + + {member.name} + {verified.has(member.fingerprint) ? $i18n.t('Verified') : $i18n.t('Verify')} + + +
+

+ {$i18n.t( + 'Compare this code with this participant through another trusted channel. It changes when they rejoin.' + )} +

+ {member.fingerprint.match(/.{1,4}/g)?.join(' ')} + {#if member.identity.participation !== state.self} + + {/if} +
+
+ {/each} +
+ +
+
+
diff --git a/vite.config.ts b/vite.config.ts index 3780dbe6a9..5216d38835 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ sveltekit(), viteStaticCopy({ targets: [ + { + src: 'src/lib/encryption/vendor/THIRD_PARTY_LICENSES.txt', + dest: 'licenses/encryption' + }, { src: 'node_modules/onnxruntime-web/dist/*.jsep.*', From b1e876896caee5bf30a010fb414a9249ec7f59a2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 15 Oct 2026 17:37:55 +0400 Subject: [PATCH 16/90] refac --- src/routes/(app)/channels/[id]/encrypted/+page.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/(app)/channels/[id]/encrypted/+page.svelte b/src/routes/(app)/channels/[id]/encrypted/+page.svelte index 91c7fc9001..2100794923 100644 --- a/src/routes/(app)/channels/[id]/encrypted/+page.svelte +++ b/src/routes/(app)/channels/[id]/encrypted/+page.svelte @@ -163,7 +163,7 @@

{$i18n.t('Leaving or reloading clears this device’s chat.')}

{/if} - {#each state.messages as message (message.id)} + {#each state.messages as message, messageIdx (message.id)} {#if message.type === 'joined'}

{$i18n.t('{{NAME}} joined', { NAME: message.user.name })} @@ -172,6 +172,9 @@ Date: Thu, 15 Oct 2026 17:40:50 +0400 Subject: [PATCH 17/90] refac --- src/lib/components/channel/MessageInput.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index e257695043..22e2891c6f 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -1077,6 +1077,9 @@ }} on:keydown={async (e) => { e = e.detail.event; + // IME confirmation must finish before a shortcut can send and clear the editor. + if (e.isComposing || e.keyCode === 229 || inputEditor?.view.composing) return; + const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac const suggestionsContainerElement = From 8132145936eb6f640fd7a78710bc965a8e338b03 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 15 Oct 2026 17:42:03 +0400 Subject: [PATCH 18/90] refac --- src/lib/components/channel/MessageInput.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 22e2891c6f..0f0366fcf3 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -1078,7 +1078,8 @@ on:keydown={async (e) => { e = e.detail.event; // IME confirmation must finish before a shortcut can send and clear the editor. - if (e.isComposing || e.keyCode === 229 || inputEditor?.view.composing) return; + if (e.isComposing || e.keyCode === 229 || inputEditor?.view.composing) + return; const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac From 0b3812f2857b4c65fd1ab931f40de4f1264edc46 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 15 Oct 2026 17:56:56 +0400 Subject: [PATCH 19/90] refac --- backend/open_webui/config.py | 5 ++ backend/open_webui/routers/groups.py | 8 +- backend/open_webui/routers/users.py | 9 +- backend/open_webui/socket/encryption.py | 89 +++++++++++++------ .../utils/access_control/__init__.py | 11 +-- .../admin/Users/Groups/Permissions.svelte | 23 ++++- src/lib/components/channel/Channel.svelte | 6 +- src/lib/components/channel/ChannelMenu.svelte | 7 +- src/lib/constants/permissions.ts | 1 + src/lib/encryption/client.ts | 14 +++ .../channels/[id]/encrypted/+page.svelte | 13 ++- 11 files changed, 133 insertions(+), 53 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index c1fc5c8815..c91a5f7e6d 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1949,6 +1949,10 @@ USER_PERMISSIONS_FEATURES_NOTES = os.getenv('USER_PERMISSIONS_FEATURES_NOTES', ' USER_PERMISSIONS_FEATURES_CHANNELS = os.getenv('USER_PERMISSIONS_FEATURES_CHANNELS', 'True').lower() == 'true' +USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS = ( + os.getenv('USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS', 'False').lower() == 'true' +) + USER_PERMISSIONS_FEATURES_API_KEYS = os.getenv('USER_PERMISSIONS_FEATURES_API_KEYS', 'False').lower() == 'true' USER_PERMISSIONS_FEATURES_MEMORIES = os.getenv('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true' @@ -2032,6 +2036,7 @@ DEFAULT_USER_PERMISSIONS = { 'notes': USER_PERMISSIONS_FEATURES_NOTES, 'folders': USER_PERMISSIONS_FEATURES_FOLDERS, 'channels': USER_PERMISSIONS_FEATURES_CHANNELS, + 'encrypted_channels': USER_PERMISSIONS_FEATURES_ENCRYPTED_CHANNELS, 'direct_tool_servers': USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, # Chat features 'web_search': USER_PERMISSIONS_FEATURES_WEB_SEARCH, diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 8b4be1f3cb..5cbb8840bd 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Optional, Literal from fastapi import APIRouter, Depends, HTTPException, Request, status, Query -from open_webui.config import CACHE_DIR +from open_webui.config import CACHE_DIR, DEFAULT_USER_PERMISSIONS from open_webui.models.config import Config from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event @@ -33,7 +33,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, or_ from pydantic import BaseModel -from open_webui.utils.access_control import combine_permissions +from open_webui.utils.access_control import combine_permissions, fill_missing_permissions from open_webui.utils.json_codec import JSONCodec log = logging.getLogger(__name__) @@ -418,7 +418,9 @@ async def preview_group_access( active_models = [m for m in all_models if m.is_active] ancestors = (await db.execute(select(Group).where(Group.id.in_(group_ids - {id})))).scalars().all() - inherited_permissions = JSONCodec.loads(JSONCodec.dumps(await Config.get('user.permissions') or {})) + inherited_permissions = fill_missing_permissions( + JSONCodec.loads(JSONCodec.dumps(await Config.get('user.permissions') or {})), DEFAULT_USER_PERMISSIONS + ) for ancestor in ancestors: inherited_permissions = combine_permissions(inherited_permissions, ancestor.permissions or {}) effective_permissions = combine_permissions( diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 69e634a8c5..90a327e3fd 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -5,11 +5,13 @@ import io import logging import time from collections import Counter +from copy import deepcopy from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import FileResponse, Response, StreamingResponse +from open_webui.config import DEFAULT_USER_PERMISSIONS from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR @@ -37,7 +39,7 @@ from open_webui.models.access_grants import AccessGrants from open_webui.models.knowledge import Knowledges from open_webui.models.models import Models from open_webui.models.tools import Tools -from open_webui.utils.access_control import get_permissions, has_permission +from open_webui.utils.access_control import fill_missing_permissions, get_permissions, has_permission from open_webui.utils.auth import ( get_admin_user, get_password_hash, @@ -262,6 +264,7 @@ class FeaturesPermissions(BaseModel): api_keys: bool = False notes: bool = True channels: bool = True + encrypted_channels: bool = False folders: bool = True direct_tool_servers: bool = False @@ -412,7 +415,9 @@ def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]: @router.get('/default/permissions', response_model=UserPermissions) async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): - user_permissions = await Config.get('user.permissions') + user_permissions = fill_missing_permissions( + deepcopy(await Config.get('user.permissions') or {}), DEFAULT_USER_PERMISSIONS + ) return { 'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})), 'sharing': SharingPermissions(**user_permissions.get('sharing', {})), diff --git a/backend/open_webui/socket/encryption.py b/backend/open_webui/socket/encryption.py index 3a5bf7cb23..227fb9556d 100644 --- a/backend/open_webui/socket/encryption.py +++ b/backend/open_webui/socket/encryption.py @@ -7,8 +7,10 @@ import json import time from contextlib import asynccontextmanager from contextvars import ContextVar +from copy import deepcopy from uuid import uuid4 +from open_webui.config import DEFAULT_USER_PERMISSIONS from open_webui.env import ( ENABLE_WEBSOCKET_SUPPORT, ENV, @@ -18,8 +20,9 @@ from open_webui.env import ( ) from open_webui.models.channels import Channels from open_webui.models.config import Config +from open_webui.models.groups import Groups from open_webui.models.users import Users -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import combine_permissions, fill_missing_permissions MAX_PACKET = 1024 * 1024 TTL = 90 @@ -112,7 +115,6 @@ class Relay: sio.on(f'encryption:{action}', handler) async def authorize(self, sid, channel): - require(await Config.get('channels.encryption.enable')) require(ENABLE_WEBSOCKET_SUPPORT and self.sio.transport(sid) == 'websocket') require(not WEBSOCKET_SERVER_LOGGING and not WEBSOCKET_SERVER_ENGINEIO_LOGGING) scope = (self.sio.get_environ(sid) or {}).get('asgi.scope', {}) @@ -121,11 +123,6 @@ class Relay: require(auth and auth.get('id')) user = await Users.get_user_by_id(auth['id']) require(user and user.role in ('user', 'admin')) - require(await Config.get('channels.enable')) - require( - user.role == 'admin' - or await has_permission(user.id, 'features.channels', await Config.get('user.permissions')) - ) record = await Channels.get_channel_by_id(channel) require(record and (record.type == 'dm' or (record.type == 'group' and record.is_private is True))) require(await Channels.is_user_channel_member(channel, user.id)) @@ -185,38 +182,65 @@ class Relay: ) await self.invite(None, channel) - async def prune(self, state): + async def eligible_users(self, user_ids): + config = await Config.get_many('channels.enable', 'channels.encryption.enable', 'user.permissions') + if not config['channels.enable'] or not config['channels.encryption.enable']: + return {} + defaults = fill_missing_permissions(deepcopy(config['user.permissions'] or {}), DEFAULT_USER_PERMISSIONS) + users = await Users.get_users_by_user_ids(list(set(user_ids))) + groups = await Groups.get_groups_by_member_ids( + [u.id for u in users if u.role == 'user'], include_inherited=True + ) + eligible = {} + for user in users: + if user.role == 'admin': + eligible[user.id] = user + elif user.role == 'user': + permissions = deepcopy(defaults) + for group in groups[user.id]: + combine_permissions(permissions, group.permissions or {}) + features = permissions.get('features', {}) + if features.get('channels') and features.get('encrypted_channels'): + eligible[user.id] = user + return eligible + + async def prune(self, state, eligible=None): if not state: return None + if eligible is None: + eligible = await self.eligible_users([p['user'] for p in state['members'].values()]) members = await Channels.get_members_by_channel_id(state['channel']) active = {m.user_id for m in members if m.is_active} - users = await Users.get_users_by_user_ids([p['user'] for p in state['members'].values()]) - humans = {u.id for u in users if u.role in ('user', 'admin')} - stale = [] - for pid, p in state['members'].items(): - if p['seen'] < time.time() - 45 or p['user'] not in active or p['user'] not in humans: - stale.append(pid) + stale = [ + pid + for pid, p in state['members'].items() + if p['seen'] < time.time() - 45 or p['user'] not in active or p['user'] not in eligible + ] if state['host'] in stale: await self.close(state['channel'], state) return None - for pid in stale: - await self.remove(state, pid) + if stale: + await self.remove(state, *stale) return state - async def remove(self, state, pid): - if pid == state['host']: + async def remove(self, state, *pids): + if state['host'] in pids: await self.close(state['channel'], state) return - p = state['members'].pop(pid) - self.local.pop(p['sid'], None) - await self.sio.emit( - 'encryption:event', {'kind': 'ended', 'channel': state['channel'], 'session': state['session']}, to=p['sid'] - ) - await self.deliver( - state, - [i for i, m in state['members'].items() if m['admitted']], - {'kind': 'departed', 'participation': pid, 'admitted': p['admitted']}, - ) + # Exclude every revoked recipient before any departure event is forwarded. + removed = [(pid, state['members'].pop(pid)) for pid in pids] + for pid, p in removed: + self.local.pop(p['sid'], None) + await self.sio.emit( + 'encryption:event', + {'kind': 'ended', 'channel': state['channel'], 'session': state['session']}, + to=p['sid'], + ) + await self.deliver( + state, + [i for i, m in state['members'].items() if m['admitted']], + {'kind': 'departed', 'participation': pid, 'admitted': p['admitted']}, + ) async def limit(self, user, action, size): now = time.monotonic() @@ -321,7 +345,14 @@ class Relay: authorized = True await self.limit(user.id, action, size) async with self.registry.transaction(channel) as state: - state = await self.prune(state) + # Read current policy after waiting for the channel's coordination lock. + eligible = await self.eligible_users( + [user.id, *([p['user'] for p in state['members'].values()] if state else [])] + ) + state = await self.prune(state, eligible) + require(user.id in eligible) + user = eligible[user.id] + require(await Channels.is_user_channel_member(channel, user.id)) if action == 'status': return { 'ok': True, diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index da690e55a5..03dad016f1 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -1,4 +1,5 @@ import logging +from copy import deepcopy from typing import Any from open_webui.config import DEFAULT_USER_PERMISSIONS @@ -12,7 +13,6 @@ from open_webui.models.access_grants import ( ) from open_webui.models.groups import Groups from open_webui.models.users import UserModel -from open_webui.utils.json_codec import JSONCodec from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def fill_missing_permissions(permissions: dict[str, Any], default_permissions: d """ for key, value in default_permissions.items(): if key not in permissions: - permissions[key] = value + permissions[key] = deepcopy(value) elif isinstance(value, dict) and isinstance(permissions[key], dict): # Both are nested dictionaries permissions[key] = fill_missing_permissions(permissions[key], value) @@ -61,15 +61,12 @@ async def get_permissions( user_groups = await Groups.get_groups_by_member_id(user_id, db=db, include_inherited=True) # Deep copy default permissions to avoid modifying the original dict - permissions = JSONCodec.loads(JSONCodec.dumps(default_permissions)) + permissions = fill_missing_permissions(deepcopy(default_permissions or {}), DEFAULT_USER_PERMISSIONS) # Combine permissions from all user groups for group in user_groups: permissions = combine_permissions(permissions, group.permissions or {}) - # Ensure all fields from default_permissions are present and filled in - permissions = fill_missing_permissions(permissions, default_permissions) - return permissions @@ -105,7 +102,7 @@ async def has_permission( return True # Check default permissions afterward if the group permissions don't allow it - default_permissions = fill_missing_permissions(default_permissions, DEFAULT_USER_PERMISSIONS) + default_permissions = fill_missing_permissions(deepcopy(default_permissions or {}), DEFAULT_USER_PERMISSIONS) return get_permission(default_permissions, permission_hierarchy) diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 55b1101d99..4016d0d2eb 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -8,8 +8,8 @@ import { DEFAULT_PERMISSIONS } from '$lib/constants/permissions'; import { config } from '$lib/stores'; - export let permissions = {}; - export let defaultPermissions = {}; + export let permissions: Record = {}; + export let defaultPermissions: Record = {}; // Reactive statement to ensure all fields are present in `permissions` $: { @@ -1007,6 +1007,25 @@ {/if} +

+
+
+ {$i18n.t('Encrypted Channels')} +
+ +
+ {#if defaultPermissions?.features?.encrypted_channels && !permissions.features.encrypted_channels} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+
diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index 0a4a91e741..0e04844d48 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -21,7 +21,7 @@ import Messages from './Messages.svelte'; import MessageInput from './MessageInput.svelte'; import Navbar from './Navbar.svelte'; - import { connected, enter, request } from '$lib/encryption/client'; + import { canUseEncryption, connected, enter, request } from '$lib/encryption/client'; import Drawer from '../common/Drawer.svelte'; import ResizableSidePanel from '../common/ResizableSidePanel.svelte'; import EllipsisVertical from '../icons/EllipsisVertical.svelte'; @@ -37,7 +37,7 @@ $: watchInvitation( $socket, channel?.id === id ? id : null, - mounted && $config?.features?.enable_encrypted_channels + mounted && canUseEncryption($user, $config?.features) ); function watchInvitation( @@ -428,7 +428,7 @@ aria-label={$i18n.t('Join encrypted chat')} class="text-[0.6875rem] font-normal text-gray-700 underline-offset-2 hover:underline dark:text-gray-200" on:click={() => { - if (!invitationSession) return; + if (!invitationSession || !canUseEncryption($user, $config?.features)) return; enter(id, { action: 'join', session: invitationSession }); void goto(`/channels/${encodeURIComponent(id)}/encrypted`); }} diff --git a/src/lib/components/channel/ChannelMenu.svelte b/src/lib/components/channel/ChannelMenu.svelte index 5317781f3d..ad87b47233 100644 --- a/src/lib/components/channel/ChannelMenu.svelte +++ b/src/lib/components/channel/ChannelMenu.svelte @@ -1,9 +1,9 @@ + + + +
+ +
+ + diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index 56831ebaf7..41eb74f85c 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -30,7 +30,9 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; const i18n = getContext>('i18n'); - const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown'); + const markdown = getContext<{ allowRemoteContent: boolean; document?: boolean } | undefined>( + 'markdown' + ); export let id = ''; export let edit = true; @@ -370,7 +372,10 @@ const render = async () => { onUpdate(token, id); if (markdown?.allowRemoteContent === false) return; - if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) { + if ( + lang === 'mermaid' && + (markdown?.document || (token?.raw ?? '').slice(-4).includes('```')) + ) { try { renderHTML = await renderMermaid(code); } catch (error) { @@ -381,7 +386,7 @@ } } else if ( (lang === 'vega' || lang === 'vega-lite') && - (token?.raw ?? '').slice(-4).includes('```') + (markdown?.document || (token?.raw ?? '').slice(-4).includes('```')) ) { try { renderHTML = await renderVegaVisualization(code, lang); diff --git a/src/lib/components/chat/Messages/Markdown/AlertRenderer.svelte b/src/lib/components/chat/Messages/Markdown/AlertRenderer.svelte index 1470e63634..6402b666b6 100644 --- a/src/lib/components/chat/Messages/Markdown/AlertRenderer.svelte +++ b/src/lib/components/chat/Messages/Markdown/AlertRenderer.svelte @@ -43,7 +43,10 @@ } }; - export function alertComponent(token: Token): AlertData | false { + export function alertComponent( + token: Token, + lexer: (source: string) => Token[] = marked.lexer + ): AlertData | false { const regExpStr = `^(?:\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\])\\s*?\n*`; const regExp = new RegExp(regExpStr); const matches = token.text?.match(regExp); @@ -51,7 +54,7 @@ if (matches && matches.length) { const alertType = matches[1] as AlertType; const newText = token.text.replace(regExp, ''); - const newTokens = marked.lexer(newText); + const newTokens = lexer(newText); return { type: alertType, text: newText, diff --git a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte index a3a949e7a4..784244ad31 100644 --- a/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/HTMLToken.svelte @@ -7,6 +7,7 @@ import { settings } from '$lib/stores'; const i18n: any = getContext('i18n'); + const markdown = getContext<{ document?: boolean } | undefined>('markdown'); export let id: string; export let token: Token; @@ -18,7 +19,9 @@ {#if token.type === 'html'} - {#if html && html.includes(']*>([\s\S]*?)<\/video>/)} {@const videoSrc = video && video[1]} {#if videoSrc} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte index 38f3857894..e515b544b3 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte @@ -7,7 +7,10 @@ import { goto } from '$app/navigation'; const i18n = getContext('i18n'); - const markdown = getContext<{ allowRemoteContent: boolean } | undefined>('markdown'); + const markdown = getContext< + | { allowRemoteContent: boolean; document?: boolean; parseInline?: (tokens: Token[]) => string } + | undefined + >('markdown'); import { WEBUI_BASE_URL } from '$lib/constants'; import { skills, terminalSkills } from '$lib/stores'; @@ -82,16 +85,24 @@ // Invalid URL, let browser handle it } }; + $: inlineHtml = + markdown?.document && tokens.some((token) => token.type === 'html') + ? DOMPurify.sanitize(markdown.parseInline?.(tokens) ?? '') + : null; -{#each tokens as token, tokenIdx (tokenIdx)} + +{#if inlineHtml !== null} + {@html inlineHtml} +{/if} +{#each inlineHtml === null ? tokens : [] as token, tokenIdx (tokenIdx)} {#if token.type === 'escape'} {unescapeHtml(token.text)} {:else if token.type === 'html'} {:else if token.type === 'link'} {@const linkToken = getLinkToken(token)} - {#if linkToken.noteId && markdown?.allowRemoteContent !== false} + {#if linkToken.noteId && !markdown?.document && markdown?.allowRemoteContent !== false} {:else if token.tokens && !linkToken.isAutolink} {/if} {:else if token.type === 'text'} - + {#if markdown?.document} + {unescapeHtml(token.text)} + {:else} + + {/if} {/if} {/each} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index 02c8537863..de9ae88dce 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -2,6 +2,9 @@ import { decode } from 'html-entities'; import { onMount, getContext } from 'svelte'; const i18n = getContext('i18n'); + const markdown = getContext< + { document?: boolean; lexer?: (source: string) => Token[] } | undefined + >('markdown'); import fileSaver from 'file-saver'; const { saveAs } = fileSaver; @@ -184,17 +187,19 @@ /> {:else if token.type === 'code'} - {#if token.codeBlockStyle !== 'indented'} + {#if markdown?.document || token.codeBlockStyle !== 'indented'} { onSave({ @@ -295,7 +300,7 @@
{:else if token.type === 'blockquote'} - {@const alert = alertComponent(token)} + {@const alert = alertComponent(token, markdown?.lexer)} {#if alert} {:else} @@ -327,6 +332,7 @@ { onTaskClick({ @@ -368,6 +374,7 @@ { onTaskClick({ From f4557ca06b8331c8914bac9e8f54ce63b0445c21 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 16 Oct 2026 01:22:00 +0400 Subject: [PATCH 27/90] refac --- .../chat/Placeholder/FolderTitle.svelte | 10 +---- .../layout/Sidebar/Folders/FolderMenu.svelte | 15 ------- .../layout/Sidebar/Folders/FolderModal.svelte | 40 ++++++++++++++----- .../Sidebar/Folders/FolderShareModal.svelte | 3 +- .../layout/Sidebar/RecursiveFolder.svelte | 18 +-------- 5 files changed, 34 insertions(+), 52 deletions(-) diff --git a/src/lib/components/chat/Placeholder/FolderTitle.svelte b/src/lib/components/chat/Placeholder/FolderTitle.svelte index cb09042d73..0db4f341d5 100644 --- a/src/lib/components/chat/Placeholder/FolderTitle.svelte +++ b/src/lib/components/chat/Placeholder/FolderTitle.svelte @@ -9,7 +9,7 @@ import { toast } from 'svelte-sonner'; - import { selectedFolder, user } from '$lib/stores'; + import { selectedFolder } from '$lib/stores'; import { deleteFolderById, @@ -20,7 +20,6 @@ import { getChatsByFolderId } from '$lib/apis/chats'; import FolderModal from '$lib/components/layout/Sidebar/Folders/FolderModal.svelte'; - import FolderShareModal from '$lib/components/layout/Sidebar/Folders/FolderShareModal.svelte'; import Folder from '$lib/components/icons/Folder.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; @@ -38,7 +37,6 @@ let showFolderModal = false; let showCreateSubFolderModal = false; - let showShareModal = false; let showDeleteConfirm = false; let deleteFolderContents = true; @@ -177,8 +175,6 @@ onSubmit={createSubFolderHandler} /> - - { showFolderModal = true; }} - onShare={() => { - showShareModal = true; - }} onDelete={() => { showDeleteConfirm = true; }} diff --git a/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte b/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte index 8d916b0af6..6762232773 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte @@ -8,7 +8,6 @@ import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; import EditPencil from '../icons/EditPencil.svelte'; import FolderIcon from '../icons/Folder.svelte'; - import ShareIcon from '../icons/Share.svelte'; import TrashIcon from '../icons/Trash.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Download from '../icons/Download.svelte'; @@ -17,8 +16,6 @@ export let align: 'start' | 'end' = 'start'; export let onEdit = () => {}; export let onExport = () => {}; - export let onShare = () => {}; - export let canShare = false; export let onDelete = () => {}; export let onCreateSubFolder = () => {}; export let onMarkAllRead = () => {}; @@ -93,18 +90,6 @@
{$i18n.t('Export')}
- {#if canShare} - - {/if} -
- +
+ {#if edit && folder && ($user?.role === 'admin' || folder.user_id === $user?.id)} + (showAccessModal = true)} /> + {/if} + +
@@ -306,3 +322,7 @@
+ +{#if show && edit && folder && ($user?.role === 'admin' || folder.user_id === $user?.id)} + +{/if} diff --git a/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte b/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte index 2af0b876e8..fe34bfbbb4 100644 --- a/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte +++ b/src/lib/components/layout/Sidebar/Folders/FolderShareModal.svelte @@ -68,9 +68,10 @@
- {$i18n.t('Share')}: {folder?.name ?? ''} + {$i18n.t('Access')}: {folder?.name ?? ''}
-
+
- {#if accessGrants.length > 0} - + {#if folder?.parent_id} +

+ {$i18n.t('Access granted to parent folders also applies here.')} +

{/if} +
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index fdbda68df3..754b71823c 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -88,6 +88,7 @@ "about the Roman Empire": "", "Accept Autocomplete Generation\nJump to Prompt Variable": "", "Access": "", + "Access granted to parent folders also applies here.": "Access granted to parent folders also applies here.", "Access Control": "", "Access Grants": "", "Access level": "", @@ -1719,6 +1720,7 @@ "Next version": "", "No": "", "No access grants. Private to you.": "", + "No direct access grants.": "No direct access grants.", "No actions found": "", "No activity data": "", "No additional headers are sent unless configured.": "", From 4dedaaf6e2292c2c4fb48668eee1292cfd7de2d0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 16 Oct 2026 01:36:10 +0400 Subject: [PATCH 29/90] refac --- backend/open_webui/socket/main.py | 97 ++++++++++++++++--- src/lib/components/chat/Chat.svelte | 35 +++++-- src/lib/components/chat/Navbar.svelte | 16 ++- .../components/common/ChatParticipants.svelte | 34 +++++-- src/lib/encryption/client.ts | 81 +++++++++++++++- src/lib/i18n/locales/en-US/translation.json | 1 + .../channels/[id]/encrypted/+page.svelte | 37 +++++-- 7 files changed, 259 insertions(+), 42 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 7e0a51be05..dfbc1d18c0 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -232,6 +232,45 @@ def get_session_pool_batches(): return [list(SESSION_POOL.items())] +async def publish_chat_presence(chat_id: str): + # Room maps only contain this worker's sockets; the session pool spans workers. + lock = ( + REDIS.lock(f'{REDIS_KEY_PREFIX}:chat:{chat_id}:presence:lock', + timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT, + blocking_timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT) + if REDIS is not None + else SOCKET_EVENT_LOCKS.setdefault(('chat', chat_id), asyncio.Lock()) + ) + try: + async with lock, asyncio.timeout(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2): + now = time.time() + people = {} + for batch in get_session_pool_batches(): + for _, session in batch: + if ( + session + and chat_id in (session.get('chat_ids') or []) + and now - session.get('last_seen_at', 0) <= SESSION_POOL_TIMEOUT + ): + people[session['id']] = {'id': session['id'], 'name': session.get('name', '')} + if REDIS is not None and not await lock.owned(): + return + await sio.emit( + 'events', + { + 'chat_id': chat_id, + 'shared': True, + 'data': { + 'type': 'chat:presence', + 'data': {'users': sorted(people.values(), key=lambda person: person['id'])}, + }, + }, + room=f'chat:{chat_id}', + ) + except Exception: + log.exception('Failed to publish presence for chat %s', chat_id) + + async def periodic_session_pool_cleanup(): """Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance).""" retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT) @@ -258,11 +297,20 @@ async def periodic_session_pool_cleanup(): ] if expired: log.warning('Reaping %d orphaned session(s) from the session pool', len(expired)) + chat_ids = { + chat_id for sid, entry in batch if sid in expired for chat_id in entry.get('chat_ids', []) + } if WEBSOCKET_MANAGER == 'redis': SESSION_POOL.delete_many(*expired) else: for sid in expired: SESSION_POOL.pop(sid, None) + for sid, entry in batch: + if sid in expired: + for chat_id in entry.get('chat_ids', []): + await sio.leave_room(sid, f'chat:{chat_id}') + for chat_id in chat_ids: + await publish_chat_presence(chat_id) await asyncio.sleep(0) # don't hold the loop for the whole sweep next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT @@ -567,12 +615,12 @@ async def user_join(sid, data): async def refresh_chat_access(chat_id=None): # The pool includes sessions on other workers; Socket.IO routes room changes via Redis. access = {} + chat_ids = set() for batch in get_session_pool_batches(): for sid, session in batch: if not session: continue - chat_ids = set(session.get('chat_ids') or []) - for cid in list(chat_ids): + for cid in session.get('chat_ids') or []: if chat_id and cid != chat_id: continue key = (cid, session['id']) @@ -581,12 +629,18 @@ async def refresh_chat_access(chat_id=None): access[key] = bool(user and await Chats.get_accessible_chat_by_id(cid, user)) if not access[key]: await sio.leave_room(sid, f'chat:{cid}') - chat_ids.discard(cid) + chat_ids.add(cid) + # Preserve joins/leaves and heartbeats that happened during access checks. + current = SESSION_POOL.get(sid) + if current: + SESSION_POOL[sid] = { + **current, 'chat_ids': [id for id in current.get('chat_ids', []) if id != cid] + } await sio.emit( 'events', {'chat_id': cid, 'shared': True, 'data': {'type': 'chat:access', 'data': {}}}, to=sid ) - if chat_ids != set(session.get('chat_ids') or []): - SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)} + for cid in chat_ids: + await publish_chat_presence(cid) @sio.on('heartbeat') @@ -750,18 +804,25 @@ async def chat_events(sid, data): chat_id = data.get('chat_id') if not isinstance(chat_id, str) or not is_saved_chat_id(chat_id): return False - session = SESSION_POOL.get(sid) or user - chat_ids = set(session.get('chat_ids') or []) - if event_type == 'leave': - await sio.leave_room(sid, f'chat:{chat_id}') - chat_ids.discard(chat_id) - else: - reader = await Users.get_user_by_id(user['id']) - if not reader or not await Chats.get_accessible_chat_by_id(chat_id, reader): + async with SOCKET_EVENT_LOCKS.setdefault(('session', sid), asyncio.Lock()): + if event_type == 'leave': + await sio.leave_room(sid, f'chat:{chat_id}') + else: + reader = await Users.get_user_by_id(user['id']) + if not reader or not await Chats.get_accessible_chat_by_id(chat_id, reader): + return False + await sio.enter_room(sid, f'chat:{chat_id}') + session = SESSION_POOL.get(sid) + if not session: + await sio.leave_room(sid, f'chat:{chat_id}') return False - await sio.enter_room(sid, f'chat:{chat_id}') - chat_ids.add(chat_id) - SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)} + chat_ids = set(session.get('chat_ids') or []) + if event_type == 'leave': + chat_ids.discard(chat_id) + else: + chat_ids.add(chat_id) + SESSION_POOL[sid] = {**session, 'chat_ids': list(chat_ids)} + await publish_chat_presence(chat_id) return True if event_type == 'last_read_at': @@ -1136,7 +1197,11 @@ async def disconnect(sid, reason=None): await encryption.disconnect(sid) LOCAL_AUTHENTICATED_SIDS.discard(sid) if sid in SESSION_POOL: + chat_ids = (SESSION_POOL.get(sid) or {}).get('chat_ids', []) del SESSION_POOL[sid] + for chat_id in chat_ids: + await sio.leave_room(sid, f'chat:{chat_id}') + await publish_chat_presence(chat_id) # Clean up USAGE_POOL entries for this session for model_id, connections in list(USAGE_POOL.items()): diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 246b59c61e..93a546315b 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1303,6 +1303,8 @@ }; let joinedChatId = ''; + let liveParticipants: { id: string; name: string }[] = []; + const clearPresence = () => (liveParticipants = []); let chatRefresh: { id: string; promise: Promise } | null = null; let typingUsers: { id: string; name: string }[] = []; let typingUsersTimeout: Record> = {}; @@ -1388,6 +1390,7 @@ mergeChatMessages(fresh.chat.history, baseline); } catch (error) { if ($chatId === id && joinedChatId === id) { + clearPresence(); chat = null; history = { messages: {}, currentId: null }; if (!embedded) await goto('/'); @@ -1401,20 +1404,32 @@ }; const syncChatRoom = (socket: typeof $socket, id: string, temporary: boolean) => { - const next = socket && id && !temporary && !isTemporaryChatId(id) ? id : ''; + const next = socket?.connected && id && !temporary && !isTemporaryChatId(id) ? id : ''; if (next === joinedChatId) return; clearTyping(); - if (joinedChatId) - socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } }); + clearPresence(); + if (joinedChatId && socket?.connected) + socket.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } }); joinedChatId = next; - if (next) - socket?.emit('events:chat', { chat_id: next, data: { type: 'join' } }, () => { - if ($chatId === next) void refreshChat(); + const connectionId = socket?.id; + if (next && socket) + socket.emit('events:chat', { chat_id: next, data: { type: 'join' } }, (accepted: boolean) => { + if (!socket.connected || socket.id !== connectionId || $chatId !== next) return; + if (accepted) void refreshChat(); + else clearPresence(); }); }; $: syncChatRoom($socket, $chatId, $temporaryChatEnabled); const chatEventHandler = async (event, cb) => { + if (event?.data?.type === 'chat:presence') { + if (!$socket?.connected || event.chat_id !== $chatId || event.chat_id !== joinedChatId) + return; + liveParticipants = (event.data.data?.users ?? []).filter( + (person: { id: string; name: string }) => person.id !== $user?.id + ); + return; + } console.log(event); if (event?.data?.type === 'voice:play_animation') { cb?.( @@ -1850,6 +1865,7 @@ $socket?.on('events', chatEventHandler); $socket?.on('connect', handleSocketConnect); $socket?.on('disconnect', clearTyping); + $socket?.on('disconnect', clearPresence); $socket?.on('disconnect', cancelBrowserInteractions); $audioQueue?.destroy(); @@ -1947,8 +1963,9 @@ // to the admin panel), otherwise the previously-viewed chat stays selected // in the sidebar and deleting/archiving it wrongly navigates away. clearTyping(); - if (joinedChatId) - $socket?.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } }); + clearPresence(); + if (joinedChatId && $socket?.connected) + $socket.emit('events:chat', { chat_id: joinedChatId, data: { type: 'leave' } }); joinedChatId = ''; chatId.set(''); chatTitle.set(''); @@ -1957,6 +1974,7 @@ $socket?.off('events', chatEventHandler); $socket?.off('connect', handleSocketConnect); $socket?.off('disconnect', clearTyping); + $socket?.off('disconnect', clearPresence); $socket?.off('disconnect', cancelBrowserInteractions); dismissContextCompactionToast(); audioQueueInstance?.destroy(); @@ -4990,6 +5008,7 @@
{:else} void) | null = null; @@ -113,7 +115,9 @@ {/if}
{#if chat?.id}
@@ -174,7 +178,7 @@ {/if}
-
+
{#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true} @@ -243,6 +247,14 @@ {/if} + {#if liveParticipants.length > 0} + + {/if} + {#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)}
-
+ conversation?.sendTyping(false)} + > + {#if typingMembers.length > 0} +
+ +
+ {typingMembers.map((member) => member.name).join(', ')} + {typingMembers.length === 1 ? $i18n.t('is typing...') : $i18n.t('are typing...')} +
+
+ {/if}
{ draft = state.status === 'active' ? md : ''; + void conversation?.sendTyping(Boolean(draft.trim())); }} on:keydown={({ detail: { event } }) => { if (event.isComposing || event.defaultPrevented || event.key !== 'Enter') return; From 1895f5ffb90df8cd5b3573f58d0717744b91e60e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 16 Oct 2026 01:38:22 +0400 Subject: [PATCH 30/90] refac --- src/lib/components/chat/Navbar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte index 2cc17f18b7..274a888899 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -178,7 +178,7 @@ {/if}
-
+
{#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true} From 8191ac30dd79b4a39130fe83ffc16035776be484 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 16 Oct 2026 01:43:50 +0400 Subject: [PATCH 31/90] refac --- backend/open_webui/socket/main.py | 14 ++++++++++---- src/lib/components/common/ChatParticipants.svelte | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index dfbc1d18c0..3d8cf7db8d 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -235,9 +235,11 @@ def get_session_pool_batches(): async def publish_chat_presence(chat_id: str): # Room maps only contain this worker's sockets; the session pool spans workers. lock = ( - REDIS.lock(f'{REDIS_KEY_PREFIX}:chat:{chat_id}:presence:lock', + REDIS.lock( + f'{REDIS_KEY_PREFIX}:chat:{chat_id}:presence:lock', timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT, - blocking_timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT) + blocking_timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT, + ) if REDIS is not None else SOCKET_EVENT_LOCKS.setdefault(('chat', chat_id), asyncio.Lock()) ) @@ -298,7 +300,10 @@ async def periodic_session_pool_cleanup(): if expired: log.warning('Reaping %d orphaned session(s) from the session pool', len(expired)) chat_ids = { - chat_id for sid, entry in batch if sid in expired for chat_id in entry.get('chat_ids', []) + chat_id + for sid, entry in batch + if sid in expired + for chat_id in entry.get('chat_ids', []) } if WEBSOCKET_MANAGER == 'redis': SESSION_POOL.delete_many(*expired) @@ -634,7 +639,8 @@ async def refresh_chat_access(chat_id=None): current = SESSION_POOL.get(sid) if current: SESSION_POOL[sid] = { - **current, 'chat_ids': [id for id in current.get('chat_ids', []) if id != cid] + **current, + 'chat_ids': [id for id in current.get('chat_ids', []) if id != cid], } await sio.emit( 'events', {'chat_id': cid, 'shared': True, 'data': {'type': 'chat:access', 'data': {}}}, to=sid diff --git a/src/lib/components/common/ChatParticipants.svelte b/src/lib/components/common/ChatParticipants.svelte index 910f3b8551..a0e42e6329 100644 --- a/src/lib/components/common/ChatParticipants.svelte +++ b/src/lib/components/common/ChatParticipants.svelte @@ -48,7 +48,7 @@ {/snippet}
-
+
{#each people.slice(0, 3) as person (person.id)}
Date: Fri, 16 Oct 2026 01:47:20 +0400 Subject: [PATCH 32/90] refac --- .../chat/Messages/ResponseMessage.svelte | 425 ++++++++++-------- .../chat/Messages/UserMessage.svelte | 141 ++---- .../components/common/ChatParticipants.svelte | 10 +- 3 files changed, 263 insertions(+), 313 deletions(-) diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 754503b735..2138d2e9af 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -1,4 +1,8 @@ + +{#each parts as part, index}{#if expression && index % 2 === 1}{part}{:else}{part}{/if}{/each} diff --git a/src/lib/components/layout/Search/NotePreview.svelte b/src/lib/components/layout/Search/NotePreview.svelte new file mode 100644 index 0000000000..e9f0736cd4 --- /dev/null +++ b/src/lib/components/layout/Search/NotePreview.svelte @@ -0,0 +1,53 @@ + + +{#if loading}
+ +
+{:else if error}
+

{$i18n.t('Failed to load note preview')}

+ +
+{:else}
+ +
{/if} diff --git a/src/lib/components/layout/Search/PreviewMarkdown.svelte b/src/lib/components/layout/Search/PreviewMarkdown.svelte new file mode 100644 index 0000000000..9ee6be3cba --- /dev/null +++ b/src/lib/components/layout/Search/PreviewMarkdown.svelte @@ -0,0 +1,19 @@ + + +
+ +
diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte index 8eec0b5290..2f32acf689 100644 --- a/src/lib/components/layout/SearchModal.svelte +++ b/src/lib/components/layout/SearchModal.svelte @@ -1,916 +1,568 @@ - - - { - deleteChatHandler(menuChatId); + { + if (panel && event.target instanceof Element && !event.target.closest('[data-search-panel]')) + panel = null; }} +/> + + -
- {$i18n.t('This will delete')} {menuChatTitle}. -
-
- - -
-
- +
+
+ + { - selectedIdx = null; - messages = null; - }} - onKeydown={(e) => { - if (e.code === 'Enter' && (chatList ?? []).length > 0) { - const item = document.querySelector(`[data-arrow-selected="true"]`); - if (item) { - (item.querySelector('a') ?? item).click(); - } - - show = false; - return; - } else if (e.code === 'ArrowDown') { - selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length); - } else if (e.code === 'ArrowUp') { - selectedIdx = Math.max(selectedIdx - 1, 0); - } else { - selectedIdx = 0; - } - - const item = document.querySelector(`[data-arrow-selected="true"]`); - item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); - }} + class="h-8 min-w-0 flex-1 bg-transparent text-[0.8125rem] font-normal outline-hidden placeholder:text-gray-400 dark:placeholder:text-gray-500" + placeholder={$i18n.t('Search workspace…')} + aria-label={$i18n.t('Search your workspace')} + role="combobox" + aria-autocomplete="list" + aria-expanded={results.length > 0} + aria-controls="workspace-search-results" + aria-activedescendant={selected ? `search-result-${selectedIndex}` : undefined} + autocomplete="off" + spellcheck="false" + maxlength="500" /> -
- -
-
{ + query = ''; + input.focus(); + }}>{/if} + -
- {$i18n.t('Actions')} -
- - {#each actions as action, idx (action.label)} - - {/each} - - {#if chatList} -