From 25455e943db2e3150770a65337a4394344d760f8 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:05:35 -0400 Subject: [PATCH 001/141] fix(ui): resolve owner avatars against the API base url and add a fallback (#28272) --- src/lib/components/chat/Placeholder/ChatList.svelte | 8 +++++++- src/lib/components/layout/Sidebar/ChatItem.svelte | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte index aaecdbb73f..92f099ccd0 100644 --- a/src/lib/components/chat/Placeholder/ChatList.svelte +++ b/src/lib/components/chat/Placeholder/ChatList.svelte @@ -11,6 +11,7 @@ import dayjs from 'dayjs'; import localizedFormat from 'dayjs/plugin/localizedFormat'; import { getTimeRange } from '$lib/utils'; + import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; @@ -240,9 +241,14 @@ {#if showOwnerInfo && chat.user_id && chat.owner_name} { + if (!e.currentTarget.src.endsWith('/static/favicon.png')) { + e.currentTarget.src = `${WEBUI_BASE_URL}/static/favicon.png`; + } + }} /> {/if} diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte index c59c49ee53..12be41f94a 100644 --- a/src/lib/components/layout/Sidebar/ChatItem.svelte +++ b/src/lib/components/layout/Sidebar/ChatItem.svelte @@ -16,6 +16,7 @@ + + {/if} + + { + updateConnectionsHandler(); + }} + ariaLabelledbyId={labelId} + /> + {:else} diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageMultipleProviderModels.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageMultipleProviderModels.svelte new file mode 100644 index 0000000000..4dfdabf144 --- /dev/null +++ b/src/lib/components/admin/Settings/Models/Manage/ManageMultipleProviderModels.svelte @@ -0,0 +1,56 @@ + + +{#if connections.length > 0} +
{$i18n.t('Model providers')}
+ +
+ + {#each connections as connection} + + {/each} + +
+ +
+ +
+{/if} diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte new file mode 100644 index 0000000000..26d32c5633 --- /dev/null +++ b/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte @@ -0,0 +1,295 @@ + + + + +
+
+
{providerLabel || provider}
+ + + +
+ +
+ + + + +
+ + {#if loading} +
+ +
+ {:else if providerModels.length === 0} +
+ {$i18n.t('No models found')} +
+ {:else} +
+ {#each providerModels as model} + {@const modelId = getModelId(model)} + {@const displayName = getDisplayName(model)} + {@const status = getStatus(model)} +
+
+
+ {displayName} +
+ {#if displayName !== modelId} +
{modelId}
+ {/if} +
+ + {status} + +
+
+ +
+ + + + + + + + + {#if supportsDelete} + + + + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte b/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte index ac68484c42..1a330c2b1c 100644 --- a/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte +++ b/src/lib/components/admin/Settings/Models/ManageModelsModal.svelte @@ -1,38 +1,72 @@ - @@ -64,8 +98,22 @@ {:else if selected !== null}
+ {#if hasOllamaManagement && hasProviderManagement} +
+ + + + +
+ {/if} {#if selected === 'ollama'} + {:else if selected === 'provider'} + {/if}
diff --git a/src/lib/i18n/locales/en-GB/translation.json b/src/lib/i18n/locales/en-GB/translation.json index 7f67b5f81f..f543d59b18 100644 --- a/src/lib/i18n/locales/en-GB/translation.json +++ b/src/lib/i18n/locales/en-GB/translation.json @@ -1716,6 +1716,7 @@ "Model Parameters": "", "Model Params": "", "Model Permissions": "", + "Model list refreshed": "", "Model removed from pinned models": "", "Model removed from selected models": "", "Model Response Mode": "", diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index a01be4d9f3..1e04d27cca 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -1719,6 +1719,7 @@ "Model Parameters": "", "Model Params": "", "Model Permissions": "", + "Model list refreshed": "", "Model removed from pinned models": "", "Model removed from selected models": "", "Model Response Mode": "", From 8260d527ee97372a207ce9bd9c6dab4909061281 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 00:22:58 -0600 Subject: [PATCH 027/141] refac --- src/lib/apis/openai/index.ts | 55 ++++++++++++------- .../Models/Manage/ManageProviderModels.svelte | 7 ++- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index 28bd4c299a..b89571428a 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -1,5 +1,18 @@ import { OPENAI_API_BASE_URL, WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; +export const getErrorMessage = (err: any, fallback = 'Server connection failed') => { + const detail = err?.detail; + if (typeof detail === 'string') return detail; + + return ( + detail?.error?.message ?? + detail?.message ?? + err?.error?.message ?? + err?.message ?? + (typeof err === 'string' ? err : fallback) + ); +}; + export const getOpenAIConfig = async (token: string = '') => { let error = null; @@ -17,11 +30,7 @@ export const getOpenAIConfig = async (token: string = '') => { }) .catch((err) => { console.error(err); - if ('detail' in err) { - error = err.detail; - } else { - error = 'Server connection failed'; - } + error = getErrorMessage(err); return null; }); @@ -59,11 +68,7 @@ export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfi }) .catch((err) => { console.error(err); - if ('detail' in err) { - error = err.detail; - } else { - error = 'Server connection failed'; - } + error = getErrorMessage(err); return null; }); @@ -147,7 +152,7 @@ export const getProviderModelCatalog = async (token: string, urlIdx: number) => return res.json(); }) .catch((err) => { - error = err?.detail ?? err?.error?.message ?? 'Server connection failed'; + error = getErrorMessage(err); return null; }); @@ -158,10 +163,16 @@ export const getProviderModelCatalog = async (token: string, urlIdx: number) => return res; }; -export const downloadProviderModel = async (token: string, urlIdx: number, model: string) => { +export const downloadProviderModel = async ( + token: string, + urlIdx: number, + model: string, + signal?: AbortSignal +) => { let error = null; const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/download`, { + signal, method: 'POST', headers: { Accept: 'application/json', @@ -175,7 +186,7 @@ export const downloadProviderModel = async (token: string, urlIdx: number, model return res.json(); }) .catch((err) => { - error = err?.detail ?? err?.error?.message ?? 'Server connection failed'; + error = getErrorMessage(err); return null; }); @@ -186,12 +197,18 @@ export const downloadProviderModel = async (token: string, urlIdx: number, model return res; }; -export const getProviderModelDownloadStatus = async (token: string, urlIdx: number, jobId: string) => { +export const getProviderModelDownloadStatus = async ( + token: string, + urlIdx: number, + jobId: string, + signal?: AbortSignal +) => { let error = null; const res = await fetch( `${OPENAI_API_BASE_URL}/models/${urlIdx}/download/status/${encodeURIComponent(jobId)}`, { + signal, method: 'GET', headers: { Accept: 'application/json', @@ -205,7 +222,7 @@ export const getProviderModelDownloadStatus = async (token: string, urlIdx: numb return res.json(); }) .catch((err) => { - error = err?.detail ?? err?.error?.message ?? 'Server connection failed'; + error = getErrorMessage(err); return null; }); @@ -233,7 +250,7 @@ export const loadProviderModel = async (token: string, urlIdx: number, model: st return res.json(); }) .catch((err) => { - error = err?.detail ?? err?.error?.message ?? 'Server connection failed'; + error = getErrorMessage(err); return null; }); @@ -266,7 +283,7 @@ export const unloadProviderModel = async ( return res.json(); }) .catch((err) => { - error = err?.detail ?? err?.error?.message ?? 'Server connection failed'; + error = getErrorMessage(err); return null; }); @@ -296,7 +313,7 @@ export const deleteProviderModel = async (token: string, urlIdx: number, model: return res.json(); }) .catch((err) => { - error = err?.detail ?? err?.error?.message ?? 'Server connection failed'; + error = getErrorMessage(err); return null; }); @@ -422,7 +439,7 @@ export const generateOpenAIChatCompletion = async ( return res.json(); }) .catch((err) => { - error = err?.detail ?? err; + error = getErrorMessage(err); return null; }); diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte index 26d32c5633..c61f3a039a 100644 --- a/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte +++ b/src/lib/components/admin/Settings/Models/Manage/ManageProviderModels.svelte @@ -5,6 +5,7 @@ import { deleteProviderModel, downloadProviderModel, + getErrorMessage, getProviderModelCatalog, loadProviderModel, unloadProviderModel @@ -100,7 +101,7 @@ const refreshModels = async () => { loading = true; const res = await getProviderModelCatalog(localStorage.token, urlIdx).catch((error) => { - toast.error(`${error}`); + toast.error(getErrorMessage(error)); return null; }); providerModels = normalizeModels(res); @@ -123,7 +124,7 @@ ) => { actionModel = modelId; const res = await action(localStorage.token, urlIdx, modelId).catch((error) => { - toast.error(`${error}`); + toast.error(getErrorMessage(error)); return null; }); @@ -158,7 +159,7 @@ actionModel = modelId; const res = await unloadProviderModel(localStorage.token, urlIdx, modelId, instanceId).catch( (error) => { - toast.error(`${error}`); + toast.error(getErrorMessage(error)); return null; } ); From 25802c048e6123fa602182949ad2a9f349e1e863 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 00:34:10 -0600 Subject: [PATCH 028/141] refac --- .../chat/ModelSelector/ModelItem.svelte | 23 +- .../chat/ModelSelector/ModelItemMenu.svelte | 4 +- .../chat/ModelSelector/Selector.svelte | 457 ++++++++++++++---- 3 files changed, 378 insertions(+), 106 deletions(-) diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index 5dcf24443a..0103b896dc 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -26,7 +26,7 @@ export let selectedValues: string[] = []; export let compareEnabled = false; - export let unloadModelHandler: (modelValue: string) => void = () => {}; + export let unloadModelHandler: (model: any) => void = () => {}; export let pinModelHandler: (modelId: string) => void = () => {}; export let deleteModelHandler: (model: any) => void = () => {}; export let selectionOnly = false; @@ -44,6 +44,8 @@ } }; + const formatSize = (size?: number) => (size ? `(${(size / 1024 ** 3).toFixed(1)}GB)` : ''); + let showMenu = false; $: isSelected = compareEnabled ? selectedValues.includes(item.value) : value === item.value; @@ -128,6 +130,23 @@ {/if} + {:else if item.model.provider === 'lmstudio' || item.model.provider === 'llama.cpp'} + {@const parameterSize = item.model.params_string ?? item.model.details?.parameter_size ?? ''} + {@const quantization = + item.model.quantization?.name ?? item.model.details?.quantization_level ?? ''} + {@const size = item.model.size_bytes ?? item.model.size} + {#if parameterSize || quantization || size} +
+ + + {parameterSize || quantization || formatSize(size)} + + +
+ {/if} {/if} {#if item.model.loaded} @@ -256,7 +275,7 @@ on:click={(e) => { e.preventDefault(); e.stopPropagation(); - unloadModelHandler(item.value); + unloadModelHandler(item.model); }} > diff --git a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte index 627e207cc6..830fa8fe4c 100644 --- a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte @@ -22,6 +22,8 @@ export let deleteModelHandler: Function = () => {}; export let onClose: Function = () => {}; + + const providerSupportsDelete = (provider = '') => provider === 'llama.cpp'; {$i18n.t('Edit')} - {#if $user?.role === 'admin' && model?.owned_by === 'ollama'} + {#if $user?.role === 'admin' && (model?.owned_by === 'ollama' || providerSupportsDelete(model?.provider))} - {:else} -
-
- {$i18n.t('No results found')} + {:else} +
+
+ {$i18n.t('No results found')} +
-
{/if} {:else} @@ -947,76 +1167,108 @@
{/if} - {#if showPullModelButton} - - - - {/if} - - {#each selectionOnly ? [] : Object.keys($MODEL_DOWNLOAD_POOL) as model} -
-
-
- -
- -
-
-
- Downloading "{model}" -
- -
- {'pullProgress' in $MODEL_DOWNLOAD_POOL[model] - ? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)` - : ''} + {#each downloadTargets as target, targetIndex (target.id)} + {#if target.download} + +
+ +
+ {$i18n.t('Downloading "{{searchValue}}"', { searchValue: searchValue })}
+ {#if 'pullProgress' in target.download} +
+ {target.download.pullProgress}% +
+ {/if} +
+
+ {:else} + + + + {/if} + {/each} - {#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest} -
- {$MODEL_DOWNLOAD_POOL[model].digest} + {#each selectionOnly ? [] : Object.keys($MODEL_DOWNLOAD_POOL).filter((model) => !activeDownloadKeys.has(model)) as model} + {@const download = $MODEL_DOWNLOAD_POOL[model]} + {@const downloadName = download?.model ?? model} + +
+ +
+ Downloading "{downloadName}"{download?.providerLabel + ? ` from ${download.providerLabel}` + : ''} +
+ {#if 'pullProgress' in download} +
+ {download.pullProgress}%
{/if} -
-
- -
- - -
-
- {/each} +
+ + {/each}
{#if showSetDefault} From e1acd7e7ca5085e03babbea16b4770e51c38886e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 00:38:32 -0600 Subject: [PATCH 029/141] refac --- src/lib/components/chat/Chat.svelte | 29 +++++++++++++++++------------ src/routes/(app)/+layout.svelte | 2 +- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 9bc114519e..dc9f6653f7 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1793,6 +1793,22 @@ const defaultModels = $config?.default_models ? $config?.default_models.split(',') : []; + const openModelSelectorWithSearch = async (modelId: string) => { + const modelSelectorButton = document.getElementById('model-selector-model-button'); + modelSelectorButton?.click(); + + await tick(); + + const modelSelectorInput = document.getElementById( + 'model-search-input' + ) as HTMLInputElement | null; + if (modelSelectorInput) { + modelSelectorInput.focus(); + modelSelectorInput.value = modelId; + modelSelectorInput.dispatchEvent(new Event('input', { bubbles: true })); + } + }; + if ($page.url.searchParams.get('models') || $page.url.searchParams.get('model')) { const urlModels = ( $page.url.searchParams.get('models') || @@ -1803,18 +1819,7 @@ if (urlModels.length === 1) { if (!$models.find((m) => m.id === urlModels[0])) { // Model not found; open model selector and prefill - const modelSelectorButton = document.getElementById('model-selector-0-button'); - if (modelSelectorButton) { - modelSelectorButton.click(); - await tick(); - - const modelSelectorInput = document.getElementById('model-search-input'); - if (modelSelectorInput) { - modelSelectorInput.focus(); - modelSelectorInput.value = urlModels[0]; - modelSelectorInput.dispatchEvent(new Event('input')); - } - } + await openModelSelectorWithSearch(urlModels[0]); } else { // Model found; set it as selected selectedModels = urlModels; diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e9931a5839..732ffd76d7 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -338,7 +338,7 @@ } else if (shortcut === Shortcut.OPEN_MODEL_SELECTOR) { console.log('Shortcut triggered: OPEN_MODEL_SELECTOR'); event.preventDefault(); - document.getElementById('model-selector-0-button')?.click(); + document.getElementById('model-selector-model-button')?.click(); } else if (shortcut === Shortcut.NEW_TEMPORARY_CHAT) { console.log('Shortcut triggered: NEW_TEMPORARY_CHAT'); event.preventDefault(); From 31c1ffd55a018a74c7e53a35fb9f9bbe33774b6f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 00:45:02 -0600 Subject: [PATCH 030/141] refac --- .../components/chat/ModelSelector/Selector.svelte | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 707aba4803..2628a3bb5b 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -1169,7 +1169,12 @@ {#each downloadTargets as target, targetIndex (target.id)} {#if target.download} - +
!activeDownloadKeys.has(model)) as model} {@const download = $MODEL_DOWNLOAD_POOL[model]} {@const downloadName = download?.model ?? model} - +
From f8c5fda283e6206d92e81ae3b83ffe5d7a144b03 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 02:03:13 -0600 Subject: [PATCH 031/141] refac --- src/lib/components/common/Image.svelte | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lib/components/common/Image.svelte b/src/lib/components/common/Image.svelte index 6cf0733c19..0f114c00c0 100644 --- a/src/lib/components/common/Image.svelte +++ b/src/lib/components/common/Image.svelte @@ -31,6 +31,7 @@ let failed = false; let attemptedSrc = ''; + $: compactUnavailable = /(?:^|\s)(?:size-|w-|h-)/.test(imageClassName); $: if (_src !== attemptedSrc) { attemptedSrc = _src; failed = false; @@ -50,11 +51,15 @@
{#if failed}
- - {$i18n.t('Image unavailable')} + + + {$i18n.t('Image unavailable')} +
{:else}
{:else} -
+
{#if !loading} {#if type === 'collection'} - + {:else if type === 'note'} - + {:else if type === 'chat'} - + {:else if type === 'folder'} - + {:else} - + {/if} {:else} - + {/if}
{/if} @@ -162,14 +162,22 @@
{:else} - -
-
-
{decodeString(name)}
+ +
+
+
{decodeString(name)}
{#if size} -
{formatFileSize(size)}
+
+ {formatFileSize(size)} +
{:else} -
{type}
+
+ {type} +
{/if}
From 5c8d9e69c4e7e92979df42128198f2087bdeca43 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:55:03 -0400 Subject: [PATCH 033/141] ci: auto-label feature requests with the enhancement label (#28531) --- .github/workflows/issue-label.yaml | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.github/workflows/issue-label.yaml b/.github/workflows/issue-label.yaml index a2a343a022..1a138e2f11 100644 --- a/.github/workflows/issue-label.yaml +++ b/.github/workflows/issue-label.yaml @@ -67,3 +67,73 @@ jobs: issue_number: issue.number, labels: ['bug'] }); + + label-feature-requests: + runs-on: ubuntu-latest + steps: + - name: Add "enhancement" label to unlabeled feature requests + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + + if (issue.labels.some((label) => label.name === 'enhancement')) { + return; + } + + // A human (or the bug form) already classified this as a bug; + // do not stack a second, contradictory classification on it. + if (issue.labels.some((label) => label.name === 'bug')) { + return; + } + + const isEdit = context.payload.action === 'edited'; + const titleWasEdited = Boolean(context.payload.changes?.title); + + if (isEdit && !titleWasEdited) { + return; + } + + const title = issue.title ?? ''; + const body = issue.body ?? ''; + + // Feature requests: "feat: ...", "feature: ...", "feature request: ...", + // "enhancement: ...", "enh: ...", "[Feature Request] ..." — the feature + // request form titles every submission "feat: ", so form submissions are + // covered by the same pattern. + const featureLikeTitle = + /^\s*(\[\s*(feat|feature|enhancement|enh)\b[^\]]*\]|(feat|feature( request)?|enhancement|enh)\s*[:/\-])/i.test( + title + ); + + // API/CLI-created issues that reproduce the feature request form structure. + // Only headings distinctive to that form. + const featureFormBody = /###\s*(Proposed Solution|Alternatives Considered)/i.test(body); + + if (!featureLikeTitle && !featureFormBody) { + return; + } + + if (isEdit) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100 + }); + + const enhancementLabelWasRemoved = events.some( + (event) => event.event === 'unlabeled' && event.label?.name === 'enhancement' + ); + + if (enhancementLabelWasRemoved) { + return; + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['enhancement'] + }); From ba885d0026ad3cce70c7cc36d1d6f59775264a12 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 13:03:56 -0600 Subject: [PATCH 034/141] refac --- .../components/channel/MessageInput.svelte | 3 +- .../channel/MessageInput/MentionList.svelte | 42 ++++++++++++++----- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 4e48c50e62..e500414c08 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -575,7 +575,8 @@ i18n, triggerChar: '@', modelSuggestions: true, - userSuggestions + userSuggestions, + channelId: channel?.id }) }, ...(channelSuggestions diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte index f3d14b7d23..8dab0e932f 100644 --- a/src/lib/components/channel/MessageInput/MentionList.svelte +++ b/src/lib/components/channel/MessageInput/MentionList.svelte @@ -2,12 +2,13 @@ import { getContext, onDestroy, onMount } from 'svelte'; const i18n = getContext('i18n'); - import { channels, models, user } from '$lib/stores'; + import { channels, models } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Hashtag from '$lib/components/icons/Hashtag.svelte'; import Lock from '$lib/components/icons/Lock.svelte'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { searchUsers } from '$lib/apis/users'; + import { getChannelMembersById } from '$lib/apis/channels'; export let query = ''; @@ -20,28 +21,47 @@ export let modelSuggestions = false; export let userSuggestions = false; export let channelSuggestions = false; + export let channelId: string | null = null; let _models = []; let _users = []; let _channels = []; + type UserSuggestion = { id: string; name: string }; + $: filteredItems = [..._users, ..._models, ..._channels].filter( (u) => u.label.toLowerCase().includes(query.toLowerCase()) || u.id.toLowerCase().includes(query.toLowerCase()) ); - const getUserList = async () => { - const res = await searchUsers(localStorage.token, query).catch((error) => { - console.error('Error searching users:', error); - return null; - }); + const toUserItems = (users: UserSuggestion[]) => + [...users] + .map((u) => ({ type: 'user', id: u.id, label: u.name })) + .sort((a, b) => a.label.localeCompare(b.label)); - if (res) { - _users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) => - a.label.localeCompare(b.label) - ); - } + const getUserList = async () => { + const [channelMembers, searchResults] = await Promise.all([ + channelId + ? getChannelMembersById(localStorage.token, channelId, query, 'name', 'asc').catch( + (error) => { + console.error('Error loading channel members:', error); + return null; + } + ) + : Promise.resolve(null), + searchUsers(localStorage.token, query).catch((error) => { + console.error('Error searching users:', error); + return null; + }) + ]); + + const memberUsers = (channelMembers?.users ?? []) as UserSuggestion[]; + const searchedUsers = (searchResults?.users ?? []) as UserSuggestion[]; + const memberIds = new Set(memberUsers.map((u) => u.id)); + const globalUsers = searchedUsers.filter((u) => !memberIds.has(u.id)); + + _users = [...toUserItems(memberUsers), ...toUserItems(globalUsers)]; }; $: if (query !== null && userSuggestions) { From 5cb87e66308c9fee1f71fca336d23f7e989979e4 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:04:13 +0200 Subject: [PATCH 035/141] fix: announce toggle state of integrations menu rows to screen readers (#27667) Every toggle row in the chat integrations menu (filters, Web Search, Image, Code Interpreter, Tools, Skills) is a button whose on/off state was carried only by the decorative Switch inside it. Screen readers announced the row name and nothing else, so there was no way to tell whether a tool or feature was active without looking at it. Each row button now carries aria-pressed, and the Switch wrapper is marked inert so the nested role=switch stops competing with the row for the announcement and stops adding a nameless tab stop. Hit testing skips inert content, so clicking the switch still toggles the row. Tool rows that are not yet authenticated omit aria-pressed: activating those starts an OAuth redirect rather than toggling, so announcing them as an unpressed toggle would be wrong. The Web Search, Image and Code Interpreter rows also had a state-flipping aria-label ("Disable Web Search") on top of aria-pressed, which announces as "Disable Web Search, pressed" and reads as the opposite of the truth. Removed: the visible row text already names each control. Fixes #17150 --- .../chat/MessageInput/IntegrationsMenu.svelte | 58 +++++-------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte index 32ef3f6643..b840eb5c96 100644 --- a/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte +++ b/src/lib/components/chat/MessageInput/IntegrationsMenu.svelte @@ -201,6 +201,7 @@
{/if} -
- { - const state = e.detail; - await tick(); - }} - /> +
+
@@ -272,9 +267,6 @@
-
- { - const state = e.detail; - await tick(); - }} - /> +
+
@@ -308,9 +294,6 @@
-
- { - const state = e.detail; - await tick(); - }} - /> +
+
@@ -343,9 +320,6 @@
-
- { - const state = e.detail; - await tick(); - }} - /> +
+
@@ -394,6 +362,9 @@ {#each Object.keys(tools) as toolId}
{/if} -
+
@@ -517,6 +488,7 @@ {#each Object.keys(skills) as skillId}
-
+
From a17cb174ad6e618558281fd1b9aa1df6b037dac2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 14:46:08 -0600 Subject: [PATCH 036/141] refac --- src/lib/components/chat/FileNav.svelte | 24 ++++++++++--------- .../chat/FileNav/FileEntryRow.svelte | 8 ++++--- .../chat/FileNav/FileNavToolbar.svelte | 13 +++++----- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 9a9a568d69..2c82ef9abe 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -48,7 +48,7 @@ import PortPreview from './FileNav/PortPreview.svelte'; import XTerminal from './XTerminal.svelte'; - const i18n = getContext('i18n'); + const i18n: any = getContext('i18n'); export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null; export let overlay = false; @@ -251,8 +251,9 @@ terminalChatContextPending = chatScoped && !terminalChatContextHidden && !isSavedChatId(chatId); if (terminalChatContextHidden || terminalChatContextPending) return null; - const userTerminal = ($settings?.terminalServers ?? []).find( - (s) => s.url === $selectedTerminalId + const settingsValue: any = $settings; + const userTerminal = (settingsValue?.terminalServers ?? []).find( + (s: any) => s.url === $selectedTerminalId ); const isSystem = !!systemTerminal; @@ -324,11 +325,16 @@ return normalized.endsWith('/') ? normalized : `${normalized}/`; }; + const labelFromPath = (path: string) => { + const parts = normalizePath(path).split('/').filter(Boolean); + return parts.at(-1) ?? '/'; + }; + const setFileRoot = (root?: TerminalFileRoot) => { fileRoot = root?.path ? { path: asDirectoryPath(root.path), - label: root.label || 'Home' + label: root.label || labelFromPath(root.path) } : null; }; @@ -1435,14 +1441,10 @@ {:else if error}
{error}
{:else if entries.length === 0 && !creatingFolder && !creatingFile} -
- +
{$i18n.t('This folder is empty')}
-
- {$i18n.t('Drop files here to upload')} -
{/if} @@ -1520,7 +1522,7 @@ {#if selectedTerminal && !selectedFile && previewPort === null} -
+
{#if terminalEnabled} -
+
{#if terminalExpanded} diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index 2abde52d1e..0dff841d37 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -12,7 +12,7 @@ import Pencil from '../../icons/Pencil.svelte'; import Clipboard from '../../icons/Clipboard.svelte'; - const i18n = getContext('i18n'); + const i18n: any = getContext('i18n'); export let entry: FileEntry; export let currentPath: string; @@ -140,7 +140,7 @@
  • {formatFileSize(entry.size)} {:else if entry.type === 'directory' && showDate && entry.modified && !renaming} - {formatRelativeTime(entry.modified)} + {formatRelativeTime(entry.modified)} {/if} diff --git a/src/lib/components/chat/FileNav/FileNavToolbar.svelte b/src/lib/components/chat/FileNav/FileNavToolbar.svelte index 8ff8af08c8..2972ec24dc 100644 --- a/src/lib/components/chat/FileNav/FileNavToolbar.svelte +++ b/src/lib/components/chat/FileNav/FileNavToolbar.svelte @@ -9,7 +9,7 @@ import Dropdown from '$lib/components/common/Dropdown.svelte'; import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; - const i18n = getContext('i18n'); + const i18n: any = getContext('i18n'); export let breadcrumbs: { label: string; path: string }[] = []; export let selectedFile: string | null = null; @@ -40,6 +40,9 @@ let uploadInput: HTMLInputElement; let breadcrumbEl: HTMLDivElement; + const showSeparator = (index: number) => + index > 0 && (breadcrumbs[0]?.label !== '/' || index > 1); + // Scroll breadcrumb to the end after every DOM update afterUpdate(() => { if (breadcrumbEl) breadcrumbEl.scrollLeft = breadcrumbEl.scrollWidth; @@ -102,11 +105,11 @@ class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none" > {#each breadcrumbs as crumb, i} - {#if i > 1} + {#if showSeparator(i)} / {/if}
    diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index defdaab477..d172d53397 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -6,16 +6,18 @@ import Dropdown from '$lib/components/common/Dropdown.svelte'; import DropdownMenu from '$lib/components/common/DropdownMenu.svelte'; - import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte'; - import GarbageBin from '../../icons/GarbageBin.svelte'; - import Pencil from '../../icons/Pencil.svelte'; - import Clipboard from '../../icons/Clipboard.svelte'; import FileTypeIcon from './FileTypeIcon.svelte'; + import Icon from './Icon.svelte'; const i18n: any = getContext('i18n'); export let entry: FileEntry; export let currentPath: string; + export let fullPath: string | null = null; + export let depth = 0; + export let rowIndex = 0; + export let expanded = false; + export let loadingChildren = false; export let terminalUrl: string = ''; export let terminalKey: string = ''; @@ -29,13 +31,23 @@ export let selected: boolean = false; export let selectionMode: boolean = false; export let selectedPaths: Set = new Set(); - export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {}; + export let onSelect: ( + entry: FileEntry, + event: MouseEvent, + path: string, + index: number + ) => void = () => {}; export let onLongPress: () => void = () => {}; + export let onToggleExpand: (path: string) => void = () => {}; export let showDate: boolean = false; export let parentWritable = true; + $: entryPath = + fullPath ?? (entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`); + $: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`; $: writable = entry.writable !== false; $: canMutate = parentWritable && writable; + $: rowIndent = `${12 + depth * 16}px`; const formatRelativeTime = (epoch: number): string => { const diff = Math.floor(Date.now() / 1000) - epoch; @@ -48,6 +60,13 @@ }; let dragOverFolder = false; + let expandTimer: ReturnType | null = null; + + const clearExpandTimer = () => { + if (!expandTimer) return; + clearTimeout(expandTimer); + expandTimer = null; + }; // ── Rename state ───────────────────────────────────────────────────── let renaming = false; @@ -71,7 +90,7 @@ const newName = renameValue.trim(); renaming = false; if (!newName || newName === entry.name) return; - onRename(`${currentPath}${entry.name}`, newName); + onRename(entryPath.replace(/\/$/, ''), newName); }; const cancelRename = () => { @@ -89,7 +108,7 @@ longPressTimer = setTimeout(() => { didLongPress = true; onLongPress(); - onSelect(entry, e as any); + onSelect(entry, e as any, entryPath, rowIndex); }, 500); }; @@ -109,6 +128,7 @@ onDestroy(() => { if (longPressTimer) clearTimeout(longPressTimer); + clearExpandTimer(); }); // ── Click handler ──────────────────────────────────────────────────── @@ -122,13 +142,13 @@ // Modifier click → toggle/range select if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); - onSelect(entry, e); + onSelect(entry, e, entryPath, rowIndex); return; } // In selection mode (touch) → toggle select if (selectionMode) { - onSelect(entry, e); + onSelect(entry, e, entryPath, rowIndex); return; } @@ -137,14 +157,13 @@ }; -
  • +
  • { if (entry.type !== 'directory') return; if (!writable) return; @@ -152,11 +171,18 @@ e.preventDefault(); e.stopPropagation(); dragOverFolder = true; + if (!expanded && !expandTimer) { + expandTimer = setTimeout(() => { + onToggleExpand(directoryPath); + expandTimer = null; + }, 600); + } }} on:dragleave={(e) => { if (entry.type !== 'directory') return; e.stopPropagation(); dragOverFolder = false; + clearExpandTimer(); }} on:drop={(e) => { if (entry.type !== 'directory') return; @@ -166,10 +192,11 @@ e.preventDefault(); e.stopPropagation(); dragOverFolder = false; + clearExpandTimer(); try { const data = JSON.parse(raw); const paths = data.paths || (data.path ? [data.path] : []); - const destFolder = `${currentPath}${entry.name}/`; + const destFolder = directoryPath; for (const p of paths) { if (p + '/' === destFolder || p === destFolder) continue; onMove(p, destFolder); @@ -177,15 +204,35 @@ } catch {} }} > + {#if entry.type === 'directory'} + + {:else} + + {/if} +
    + {#if entry.type === 'directory'} + + {:else} + + {/if} + diff --git a/src/lib/components/chat/FileNav/FileNavToolbar.svelte b/src/lib/components/chat/FileNav/FileNavToolbar.svelte index 83dbb0afe3..4399f7dbf7 100644 --- a/src/lib/components/chat/FileNav/FileNavToolbar.svelte +++ b/src/lib/components/chat/FileNav/FileNavToolbar.svelte @@ -20,11 +20,13 @@ export let onUploadFiles: (files: File[]) => void = () => {}; export let onDownloadDir: () => void = () => {}; export let onMove: (source: string, destFolder: string) => void = () => {}; + export let showHidden = false; + export let onToggleHidden: () => void = () => {}; // Sort controls - export let sortBy: 'name' | 'date' = 'name'; + export let sortBy: 'name' | 'size' | 'date' = 'name'; export let sortAsc: boolean = true; - export let onSort: (mode: 'name' | 'date') => void = () => {}; + export let onSort: (mode: 'name' | 'size' | 'date') => void = () => {}; // Back / forward navigation export let canGoBack = false; @@ -46,45 +48,45 @@ }); -
    +
    {#each breadcrumbs as crumb, i} {#if showSeparator(i)} - / + / {/if} @@ -144,10 +146,10 @@ @@ -155,7 +157,7 @@ +
    - - - - - - - - - - - - + + + + + +
    + + + + + + + +
    +
    (); @@ -55,21 +56,15 @@
    @@ -111,7 +95,7 @@ {:else} {#each ports as port} From 2befa8f796266e92fa55861bb8eaa81639ff4053 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 15:26:19 -0600 Subject: [PATCH 041/141] refac --- src/lib/components/chat/FileNav.svelte | 22 ------------------ .../chat/FileNav/FileEntryRow.svelte | 19 +++++++++++---- .../chat/FileNav/FileNavToolbar.svelte | 6 ++--- .../chat/FileNav/FilePreview.svelte | 23 +++++++++++++++++++ 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 3548140179..2f193357e2 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -1270,28 +1270,6 @@ onSort={toggleSort} onToggleHidden={toggleHidden} > - {#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)} - - - - {/if} - {#if filePdfData !== null} - - - - {/if} {#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing} + +
    + {/if}
    From f96b717566b2084bd5fe70fb622ab66b5a643c0d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 16:04:53 -0600 Subject: [PATCH 049/141] refac --- src/lib/components/chat/Navbar.svelte | 2 +- src/lib/components/common/PptxPreview.svelte | 2 +- src/lib/utils/pptxToHtml.ts | 153 +++++++++++++------ 3 files changed, 110 insertions(+), 47 deletions(-) diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte index 93f438def2..b6a045ea1c 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -161,7 +161,7 @@ {/if}
    -
    +
    {#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true} diff --git a/src/lib/components/common/PptxPreview.svelte b/src/lib/components/common/PptxPreview.svelte index 097a6a7716..3a40e785c0 100644 --- a/src/lib/components/common/PptxPreview.svelte +++ b/src/lib/components/common/PptxPreview.svelte @@ -261,7 +261,7 @@
    diff --git a/src/lib/components/automations/AutomationEditor.svelte b/src/lib/components/automations/AutomationEditor.svelte index 1bda35ec74..4e42626e7b 100644 --- a/src/lib/components/automations/AutomationEditor.svelte +++ b/src/lib/components/automations/AutomationEditor.svelte @@ -7,8 +7,9 @@ import localizedFormat from 'dayjs/plugin/localizedFormat'; import type i18nType from '$lib/i18n'; - import { WEBUI_NAME, folders } from '$lib/stores'; + import { WEBUI_NAME, channels, folders } from '$lib/stores'; import { getFolders } from '$lib/apis/folders'; + import { getChannels } from '$lib/apis/channels'; import { getAutomationById, @@ -44,6 +45,7 @@ let hasMoreRuns = true; let runsPage = 0; let foldersLoaded = false; + let channelsLoaded = false; const ensureFolders = async () => { if (foldersLoaded || ($folders ?? []).length > 0) return; @@ -52,11 +54,29 @@ foldersLoaded = true; }; + const ensureChannels = async () => { + if (channelsLoaded || ($channels ?? []).length > 0) return; + const res = await getChannels(localStorage.token).catch(() => null); + if (res) channels.set(res); + channelsLoaded = true; + }; + const getFolderName = (folderId: string | null): string => folderId ? (($folders ?? []).find((folder) => folder.id === folderId)?.name ?? $i18n.t('None')) : $i18n.t('None'); + const getDestinationName = (): string => { + const target = automation.data.target; + if (target?.type === 'channel') { + const channel = ($channels ?? []).find((channel) => channel.id === target.channel_id); + return channel?.name ? `#${channel.name}` : $i18n.t('Channel'); + } + return automation.folder_id + ? `${$i18n.t('Folder')}: ${getFolderName(automation.folder_id)}` + : $i18n.t('New chat'); + }; + const formatTime = (ts: number | null): string => { if (!ts) return '-'; return new Date(ts / 1_000_000).toLocaleString(undefined, { @@ -216,6 +236,7 @@ is_active = automation.is_active; await ensureFolders(); + await ensureChannels(); await loadRuns(); }); @@ -283,10 +304,10 @@
    - {$i18n.t('Folder')} + {$i18n.t('Destination')} - {getFolderName(automation.folder_id)} + {getDestinationName()}
    @@ -356,11 +377,19 @@ {/if} diff --git a/src/lib/components/automations/DestinationDropdown.svelte b/src/lib/components/automations/DestinationDropdown.svelte new file mode 100644 index 0000000000..1c2c1ab779 --- /dev/null +++ b/src/lib/components/automations/DestinationDropdown.svelte @@ -0,0 +1,308 @@ + + + { + if (!state) { + tab = ''; + folderSearch = ''; + channelSearch = ''; + } + }} +> + + +
    + + {#if tab === ''} +
    + + + + + +
    + {:else if tab === 'folders'} +
    + + +
    + + +
    + +
    + {#each filteredFolderOptions as folder (folder.id)} + {@const path = folderPath(folder)} + + {:else} +
    + {folderOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No folders')} +
    + {/each} +
    +
    + {:else if tab === 'channels'} +
    + + +
    + + +
    + +
    + {#each filteredChannelOptions as channel (channel.id)} + + {:else} +
    + {channelOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No channels')} +
    + {/each} +
    +
    + {/if} +
    +
    +
    diff --git a/src/lib/components/calendar/CreateCalendarModal.svelte b/src/lib/components/calendar/CreateCalendarModal.svelte index d1dafd558b..077568abe0 100644 --- a/src/lib/components/calendar/CreateCalendarModal.svelte +++ b/src/lib/components/calendar/CreateCalendarModal.svelte @@ -76,7 +76,7 @@
    -
    +
    {$i18n.t('Name')}
    diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index ccdb3778ea..f5ec62e4b6 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -1186,7 +1186,7 @@
    { if (e.target.scrollTop === 0) { scrollTop = 0; @@ -1289,7 +1289,6 @@ @@ -1301,7 +1300,6 @@ { @@ -1320,7 +1318,6 @@ { showCreateFolderModal = true; @@ -1406,7 +1402,6 @@ { selectedFolder.set(null); diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte index 20f7c7d145..a85f446370 100644 --- a/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte +++ b/src/lib/components/workspace/Knowledge/KnowledgeBase/NewDirectoryModal.svelte @@ -43,7 +43,7 @@
    -
    +
    {$i18n.t('Name')}
    { automationsLayout?.setHeader({ @@ -151,6 +153,13 @@ foldersLoaded = true; }; + const ensureChannels = async () => { + if (channelsLoaded || ($channels ?? []).length > 0) return; + const res = await getChannels(localStorage.token).catch(() => null); + if (res) channels.set(res); + channelsLoaded = true; + }; + const toggleHandler = async (automation: AutomationResponse) => { const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => { toast.error(`${err}`); @@ -216,6 +225,16 @@ : $i18n.t('Never'); }; + const formatDestination = (automation: AutomationResponse): string => { + if (automation.data.target?.type === 'channel') { + const channel = ($channels ?? []).find( + (channel) => channel.id === automation.data.target?.channel_id + ); + return channel?.name ? `#${channel.name}` : $i18n.t('Channel'); + } + return automation.folder_id ? $i18n.t('Folder') : $i18n.t('New chat'); + }; + const getAllAutomations = async () => { let currentPage = 1; let allAutomations: AutomationResponse[] = []; @@ -341,6 +360,7 @@ loaded = true; syncHeader(); + ensureChannels(); return () => { clearTimeout(searchDebounceTimer); @@ -580,11 +600,14 @@
    From 759369808065015e2e228e1dc0a4ec187bc3aede Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 16:42:51 -0600 Subject: [PATCH 053/141] refac --- .../layout/Sidebar/ChannelItem.svelte | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte index 834996ef4d..6356e4a9eb 100644 --- a/src/lib/components/layout/Sidebar/ChannelItem.svelte +++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte @@ -44,6 +44,12 @@ } return hasPublicReadGrant(channel?.access_grants); }; + + const formatUnreadCount = (count: number) => + new Intl.NumberFormat(undefined, { + notation: 'compact', + compactDisplay: 'short' + }).format(count); { console.log(channel); @@ -104,7 +110,7 @@ }} draggable="false" > -
    +
    {#if channel?.type === 'dm'} {#if channel?.users} @@ -152,15 +158,13 @@ {/if}
    -
    +
    {#if channel?.name} - + {channel.name} {:else} - + {channel?.users ?.filter((u) => u.id !== $user?.id) .map((u) => u.name) @@ -171,34 +175,30 @@ {@const dmUser = channel.users.find((u) => u.id !== $user?.id)} {#if dmUser?.status_emoji || dmUser?.status_message} - + {#if dmUser?.status_emoji}
    {/if} -
    +
    {dmUser?.status_message}
    {/if} {/if} {/if} -
    -
    -
    - {#if channel?.unread_count > 0} -
    - {new Intl.NumberFormat($i18n.locale, { - notation: 'compact', - compactDisplay: 'short' - }).format(channel.unread_count)} -
    - {/if} + {#if channel?.unread_count > 0} +
    + {formatUnreadCount(channel.unread_count)} +
    + {/if} +
    From 2c01d59335e961d635e70c58ea5fdde2f5a8c607 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 17:26:38 -0600 Subject: [PATCH 054/141] refac --- backend/open_webui/routers/automations.py | 10 +++------- src/lib/components/admin/Evaluations/Feedbacks.svelte | 9 ++++----- src/lib/components/admin/Users/Groups.svelte | 9 ++++----- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index 8039a61e24..caf8879e28 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -15,7 +15,7 @@ from open_webui.models.automations import ( AutomationRuns, Automations, ) -from open_webui.models.access_grants import AccessGrants +from open_webui.models.access_grants import AccessGrants, has_public_write_access_grant from open_webui.models.channels import Channels from open_webui.models.config import Config from open_webui.models.folders import Folders @@ -134,12 +134,8 @@ async def check_automation_channel_access(form_data: AutomationForm, user, db: A if channel.type in ['group', 'dm']: allowed = await Channels.is_user_channel_member(channel.id, user.id, db=db) else: - allowed = await AccessGrants.has_access( - user_id=user.id, - resource_type='channel', - resource_id=channel.id, - permission='write', - db=db, + allowed = has_public_write_access_grant(channel.access_grants) or await AccessGrants.has_access( + user_id=user.id, resource_type='channel', resource_id=channel.id, permission='write', db=db ) if not allowed: raise HTTPException( diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index b61d65e1a8..d9bade0b7e 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -304,11 +304,10 @@
    {#if (items ?? []).length === 0} -
    -
    -
    😕
    -
    {$i18n.t('No feedback found')}
    -
    +
    +
    +
    {$i18n.t('No feedback found')}
    +
    {$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
    diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte index 843da668f2..d4301a49c7 100644 --- a/src/lib/components/admin/Users/Groups.svelte +++ b/src/lib/components/admin/Users/Groups.svelte @@ -184,11 +184,10 @@ {/each}
    {:else} -
    -
    -
    👥
    -
    {$i18n.t('No groups found')}
    -
    +
    +
    +
    {$i18n.t('No groups found')}
    +
    {$i18n.t('Use groups to organize your users and assign permissions.')}
    From 4465f52a3eb521854cf190f91b0ea7cf3fa21830 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 13 Aug 2026 18:13:43 -0600 Subject: [PATCH 055/141] refac --- backend/open_webui/tools/builtin.py | 108 +++++++ backend/open_webui/utils/tools.py | 4 + src/lib/components/chat/AskUserCard.svelte | 296 ++++++++++++++++++ src/lib/components/chat/Chat.svelte | 41 ++- src/lib/components/chat/MessageInput.svelte | 20 ++ .../workspace/Models/BuiltinTools.svelte | 8 +- 6 files changed, 467 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/chat/AskUserCard.svelte diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index ac9a36066d..dc7d9f9dd3 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -498,6 +498,114 @@ async def edit_image( return JSONCodec.dumps({'error': str(e)}) +# ============================================================================= +# USER INPUT TOOLS +# ============================================================================= + + +async def ask_user( + questions: list[dict], + allow_other: bool = True, + __event_call__: callable = None, +) -> str: + """ + Ask the user clarifying questions before continuing. + Use this when the next step depends on user intent, preference, or a tradeoff that cannot be inferred safely. + + :param questions: 1-3 question objects, each with id, header, question, and 2-3 options. Each option needs label and description. + :param allow_other: Whether users may enter a free-form answer instead of choosing one of the options + :return: JSON with status and answers keyed by question id + """ + try: + if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + raise ValueError('ask_user requires 1-3 questions.') + + normalized_questions = [] + seen_ids = set() + for index, question in enumerate(questions): + if not isinstance(question, dict): + raise ValueError('Each question must be an object.') + + question_id = str(question.get('id') or '').strip()[:64] + if not question_id: + raise ValueError('Each question requires a non-empty id.') + if question_id in seen_ids: + raise ValueError(f'Duplicate question id: {question_id}') + seen_ids.add(question_id) + + options = question.get('options') + if not isinstance(options, list) or not 2 <= len(options) <= 3: + raise ValueError('Each question requires 2-3 options.') + + normalized_options = [] + for option in options: + if not isinstance(option, dict): + raise ValueError('Each option must be an object.') + + label = str(option.get('label') or '').strip()[:80] + description = str(option.get('description') or '').strip()[:240] + if not label or not description: + raise ValueError('Each option requires a label and description.') + + normalized_options.append( + { + 'label': label, + 'description': description, + } + ) + + question_text = str(question.get('question') or '').strip()[:500] + if not question_text: + raise ValueError('Each question requires question text.') + + normalized_questions.append( + { + 'id': question_id, + 'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}', + 'question': question_text, + 'options': normalized_options, + 'allow_other': bool(question.get('allow_other', allow_other)), + } + ) + + if __event_call__ is None: + return JSONCodec.dumps( + { + 'status': 'error', + 'error': 'User input requires an active browser session with WebSocket connection.', + }, + ensure_ascii=False, + ) + + output = await __event_call__( + { + 'type': 'request:user_input', + 'data': { + 'questions': normalized_questions, + 'allow_other': allow_other, + }, + } + ) + + if not isinstance(output, dict): + return JSONCodec.dumps({'status': 'error', 'error': 'Invalid user input response.'}, ensure_ascii=False) + if output.get('error'): + return JSONCodec.dumps({'status': 'error', 'error': output.get('error')}, ensure_ascii=False) + if output.get('status') == 'cancelled': + return JSONCodec.dumps({'status': 'cancelled', 'answers': {}}, ensure_ascii=False) + + return JSONCodec.dumps( + { + 'status': 'answered', + 'answers': output.get('answers', {}), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'ask_user error: {e}') + return JSONCodec.dumps({'status': 'error', 'error': str(e)}, ensure_ascii=False) + + # ============================================================================= # CODE INTERPRETER TOOLS # ============================================================================= diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 5a3852b89d..bb682d4cdb 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -47,6 +47,7 @@ from open_webui.models.tools import Tools from open_webui.models.users import UserModel from open_webui.tools.builtin import ( add_memory, + ask_user, calculate_timestamp, create_automation, create_calendar_event, @@ -583,6 +584,9 @@ async def get_builtin_tools( if is_builtin_tool_enabled('time'): builtin_functions.extend([get_current_timestamp, calculate_timestamp]) + if is_builtin_tool_enabled('user_input'): + builtin_functions.append(ask_user) + metadata = extra_params.get('__metadata__') or {} chat_files = metadata.get('files') or extra_params.get('__files__') or [] has_chat_files = any( diff --git a/src/lib/components/chat/AskUserCard.svelte b/src/lib/components/chat/AskUserCard.svelte new file mode 100644 index 0000000000..4c69789c04 --- /dev/null +++ b/src/lib/components/chat/AskUserCard.svelte @@ -0,0 +1,296 @@ + + +{#if show} +
    +
    +
    + {$i18n.t('Planning question')} +
    +
    + {$i18n.t('Question')} + {questionIndex + 1} + {$i18n.t('of')} + {questions.length} · + {$i18n.t('Paused while visible')} +
    +
    + +
    + {#if question} + {#key question.id} +
    +
    +
    + {question.header} +
    +
    + {question.question} +
    +
    + +
    + {#each question.options || [] as option, optionIndex} + + + + {/each} + + {#if questionAllowsOther(question)} + + {#if selectedAnswer?.type === 'other'} + + updateOther(question, (event.currentTarget as HTMLInputElement).value)} + /> + {/if} + {/if} +
    +
    + {/key} + {/if} + +
    + + {#if questionIndex < questions.length - 1} + + {:else} + + {/if} +
    +
    +
    +{/if} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index dc9f6653f7..9f168455dc 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -162,7 +162,10 @@ let eventConfirmationInputValue = ''; let eventConfirmationInputType = ''; let eventConfirmationInputOptions: ({ label?: string; value: string } | string)[] = []; - let eventCallback = null; + let eventCallback: (value: any) => void = () => {}; + let showAskUserDialog = false; + let askUserQuestions: any[] = []; + let askUserAllowOther = true; let selectedModels = ['']; let atSelectedModel: Model | undefined; @@ -1144,6 +1147,11 @@ eventConfirmationInputValue = data?.value ?? ''; eventConfirmationInputType = data?.input?.type ?? data?.type ?? ''; eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? []; + } else if (type === 'request:user_input') { + eventCallback = cb; + askUserQuestions = data?.questions ?? []; + askUserAllowOther = data?.allow_other ?? true; + showAskUserDialog = true; } else if (type.startsWith('terminal:')) { terminalEventHandler(type, data); } else { @@ -1600,7 +1608,9 @@ fileItem.content_type = uploadedFile.meta?.content_type; fileItem.size = uploadedFile.meta?.size; fileItem.collection_name = - res.collection_name ?? uploadedFile.meta?.collection_name ?? uploadedFile.collection_name; + res.collection_name ?? + uploadedFile.meta?.collection_name ?? + uploadedFile.collection_name; } else { fileItem.type = 'text'; fileItem.file = { @@ -2249,9 +2259,7 @@ chatRequestQueues.update((q) => ({ ...q, - [targetChatId]: (q[targetChatId] ?? []).filter( - (m) => !queuedMessageIds.has(m.id) - ) + [targetChatId]: (q[targetChatId] ?? []).filter((m) => !queuedMessageIds.has(m.id)) })); await submitPrompt(combinedPrompt, combinedFiles); @@ -4165,6 +4173,15 @@ {onUpdate} messageQueue={$chatRequestQueues[$chatId] ?? []} {chatTasks} + askUser={{ + show: showAskUserDialog, + questions: askUserQuestions, + allowOther: askUserAllowOther, + onConfirm: (value) => { + showAskUserDialog = false; + eventCallback(value); + } + }} onQueueSendNow={sendQueuedMessageNow} onQueueEdit={editQueuedMessage} onQueueDelete={deleteQueuedMessage} @@ -4219,10 +4236,7 @@
    {/if} -
    +
    { + showAskUserDialog = false; + eventCallback(value); + } + }} onQueueSendNow={sendQueuedMessageNow} onQueueEdit={editQueuedMessage} onQueueDelete={deleteQueuedMessage} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 0b6be97771..6b40cec2b9 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -104,6 +104,7 @@ import Knobs from '../icons/Knobs.svelte'; import ValvesModal from '../workspace/common/ValvesModal.svelte'; import Note from '../icons/Note.svelte'; + import AskUserCard from './AskUserCard.svelte'; import { goto } from '$app/navigation'; import InputModal from '../common/InputModal.svelte'; import Expand from '../icons/Expand.svelte'; @@ -170,6 +171,12 @@ export let onQueueEdit: (id: string) => void = () => {}; export let onQueueDelete: (id: string) => void = () => {}; export let onUpdate: (data?: { file?: any }) => void = () => {}; + export let askUser = { + show: false, + questions: [], + allowOther: true, + onConfirm: (_value: any) => {} + }; export let chatTasks = []; @@ -1580,6 +1587,19 @@ on:click={() => createMessagePair(prompt)} /> + {#if askUser?.show} +
    + { + askUser.onConfirm(e.detail); + }} + /> +
    + {/if} + {#if isActive && chatTasks.length > 0}
    diff --git a/src/lib/components/workspace/Models/BuiltinTools.svelte b/src/lib/components/workspace/Models/BuiltinTools.svelte index 38e37cbc3e..b91626aa94 100644 --- a/src/lib/components/workspace/Models/BuiltinTools.svelte +++ b/src/lib/components/workspace/Models/BuiltinTools.svelte @@ -1,16 +1,22 @@ {#if show} -
    +
    -
    - {$i18n.t('Planning question')} +
    + {$i18n.t('Assistant needs your input')}
    -
    +
    {$i18n.t('Question')} {questionIndex + 1} {$i18n.t('of')} - {questions.length} · - {$i18n.t('Paused while visible')} + {questions.length}
    @@ -177,12 +203,12 @@
    {/if} From 3df485582df932f1df28714db65373093fd6734d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:52:16 +0200 Subject: [PATCH 107/141] fix: reject skill IDs that are not URL path safe (#27660) A skill ID goes straight into the path of every mutating skill endpoint (/api/v1/skills/id/{id}/...), but create only replaced spaces with hyphens. An ID containing a "/" was stored verbatim as the primary key, so the route never matched, the request fell through to the SPA static mount and the client got 405 Method Not Allowed. The skill could not be opened, edited, toggled or deleted, by admins either, and since skill.name is UNIQUE it could not be recreated under a corrected ID. Percent-encoding does not help: uvicorn decodes the path before Starlette routes it, so the only remaining fix was a direct database write. Create now rejects any ID outside [a-z0-9_-] with 400 instead of silently storing an unreachable one. Two frontend paths that fed unsanitized IDs into it are fixed as well: the manual "Skill ID" field, which was bound with no sanitization at all and is the path that reproduces on every version, and the markdown import, which put the raw frontmatter name into the ID before opening the editor in clone mode, where the reactive slugify is disabled. Existing rows with an unreachable ID are not repaired here; rewriting a primary key would also have to re-point the access grants keyed on it. Fixes #27655 --- backend/open_webui/routers/skills.py | 8 ++++++++ src/lib/components/workspace/Skills.svelte | 4 ++-- src/lib/components/workspace/Skills/SkillEditor.svelte | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index 7d2daabf8e..9c268d8dcc 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -1,4 +1,5 @@ import logging +import re from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status @@ -180,6 +181,13 @@ async def create_new_skill( form_data.id = form_data.id.lower().replace(' ', '-') + # The id goes into /id/{id}/... paths, so anything outside the slug charset is unreachable once stored. + if not re.fullmatch(r'[a-z0-9_-]+', form_data.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT('Invalid skill ID'), + ) + existing = await Skills.get_skill_by_id(form_data.id, db=db) if existing is not None: raise HTTPException( diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte index fdc6f2a465..1395b56350 100644 --- a/src/lib/components/workspace/Skills.svelte +++ b/src/lib/components/workspace/Skills.svelte @@ -21,7 +21,7 @@ deleteSkillById, toggleSkillById } from '$lib/apis/skills'; - import { capitalizeFirstLetter, parseFrontmatter, formatSkillName } from '$lib/utils'; + import { capitalizeFirstLetter, parseFrontmatter, formatSkillName, slugify } from '$lib/utils'; import TagInput from '$lib/components/common/Tags/TagInput.svelte'; import Tooltip from '../common/Tooltip.svelte'; @@ -306,7 +306,7 @@ const displayName = formatSkillName(rawName); sessionStorage.skill = JSON.stringify({ name: displayName, - id: fm.name || '', + id: slugify(rawName), description: fm.description || '', content: mdContent, is_active: true, diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte index 894107bc19..06495ac2f5 100644 --- a/src/lib/components/workspace/Skills/SkillEditor.svelte +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -39,7 +39,6 @@ const fm = parseFrontmatter(content); if (fm.name && !name) { name = formatSkillName(fm.name); - id = fm.name; } if (fm.description && !description) { description = fm.description; @@ -52,6 +51,7 @@ return; } loading = true; + if (!edit) id = slugify(id); await onSubmit({ id, From 686d8dc54c58ff2ad94a4a86da5acfa804f150b0 Mon Sep 17 00:00:00 2001 From: xyonium <38782737+xyonium@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:53:00 +0800 Subject: [PATCH 108/141] fix: strip prefix id from model name in /responses endpoint (#28575) The /openai/responses endpoint forwarded the prefixed model id (e.g. "myprovider.gpt-4o") to the upstream provider instead of the stripped native name, causing "model not found" errors when a connection has a Prefix ID configured. generate_chat_completion() already strips the prefix before forwarding; apply the same strip_provider_model_prefix() call in responses() after the urlIdx routing (which needs the prefixed id) and re-serialize the body afterwards. Also fixes the Azure non-v1 deployment path, which built the deployment URL from the prefixed model name. Co-authored-by: Claude --- backend/open_webui/routers/openai.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 9aed4a8f13..70e37a2123 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -1847,8 +1847,6 @@ async def responses( # Enforce per-model access control await check_model_access(user, await Models.get_model_by_id(model_id), BYPASS_MODEL_ACCESS_CONTROL) - body = JSONCodec.dumps(payload) - if model_id: models = request.app.state.OPENAI_MODELS if not models or model_id not in models: @@ -1859,6 +1857,9 @@ async def responses( url, key, api_config = await get_openai_connection(idx) + payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id')) + body = JSONCodec.dumps(payload) + r = None streaming = False From 54cefd2b990d62e9c289ed19ed2a5ef9ca7315c2 Mon Sep 17 00:00:00 2001 From: Damien S Date: Mon, 17 Aug 2026 08:56:48 +0200 Subject: [PATCH 109/141] fix: preserve complete user context in agentic retrieval (#27642) * fix: preserve user info in agentic RAG tools * fix: preserve user info in file access checks --------- Co-authored-by: Damien SPINELLI --- backend/open_webui/tools/builtin.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index c7d36992aa..f4150cfea9 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -103,11 +103,12 @@ async def _emit_note_updated(request: Request, user: dict, note) -> None: async def _has_read_access_to_file( file, - user_id: str, - user_role: str, + user: dict, model_knowledge: Optional[list[dict]] = None, ) -> bool: """Check if a user can read a file via ownership, admin role, model attachment, or access grants.""" + user_id = user.get('id') + user_role = user.get('role', 'user') if file.user_id == user_id or user_role == 'admin': return True if model_knowledge and any(item.get('type') == 'file' and item.get('id') == file.id for item in model_knowledge): @@ -117,7 +118,7 @@ async def _has_read_access_to_file( return await has_access_to_file( file_id=file.id, access_type='read', - user=UserModel(**{'id': user_id, 'role': user_role}), + user=UserModel(**user), ) @@ -2314,8 +2315,6 @@ async def _get_accessible_chat_files( ) -> list[tuple[dict, object]]: from open_webui.models.files import Files - user_id = user.get('id') - user_role = user.get('role', 'user') accessible = [] seen = set() @@ -2337,7 +2336,7 @@ async def _get_accessible_chat_files( seen.add(fid) file = await Files.get_file_by_id(fid) - if file and await _has_read_access_to_file(file, user_id, user_role): + if file and await _has_read_access_to_file(file, user): accessible.append((normalized, file)) return accessible @@ -2559,10 +2558,7 @@ async def query_chat_files( if not embedding_function and not full_context: return JSONCodec.dumps({'error': 'Embedding function not configured'}) - user_model = UserModel.model_construct( - id=__user__.get('id'), - role=__user__.get('role', 'user'), - ) + user_model = UserModel(**__user__) sources = await get_sources_from_items( request=__request__, items=file_items, @@ -2655,7 +2651,7 @@ async def grep_knowledge_files( # Single file mode — verify access file = await Files.get_file_by_id(file_id) if file: - if not await _has_read_access_to_file(file, user_id, user_role, __model_knowledge__): + if not await _has_read_access_to_file(file, __user__, __model_knowledge__): return JSONCodec.dumps({'error': 'File not found'}) files_to_search.append(file) elif __model_knowledge__: @@ -2777,14 +2773,11 @@ async def view_file( try: from open_webui.models.files import Files - user_id = __user__.get('id') - user_role = __user__.get('role', 'user') - file = await Files.get_file_by_id(file_id) if not file: return JSONCodec.dumps({'error': 'File not found'}) - if not await _has_read_access_to_file(file, user_id, user_role, __model_knowledge__): + if not await _has_read_access_to_file(file, __user__, __model_knowledge__): return JSONCodec.dumps({'error': 'File not found'}) content = '' @@ -3192,7 +3185,7 @@ async def query_knowledge_files( embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None) if not embedding_function: return JSONCodec.dumps({'error': 'Embedding function not configured'}) - user_model = UserModel.model_construct(id=user_id, role=user_role) + user_model = UserModel(**__user__) collection_names = [] external_knowledges = [] @@ -3386,7 +3379,7 @@ async def query_knowledge_bases( embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None) if not embedding_function: return JSONCodec.dumps({'error': 'Embedding function not configured'}) - user_model = UserModel.model_construct(id=user_id, role=__user__.get('role', 'user')) + user_model = UserModel(**__user__) query_embedding = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user_model) # Min-heap of (distance, knowledge_base_id) - only holds top `count` results From 927ce0eae67af1b4a856d3e82fba0601c3b40d02 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 16 Aug 2026 23:58:56 -0700 Subject: [PATCH 110/141] refac Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com> --- backend/open_webui/utils/oauth.py | 35 ++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 69cf203c3e..8fb1f72a9b 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1978,14 +1978,18 @@ class OAuthManager: data={'role': determined_role, 'provider': provider}, ) + updated_fields = [] + if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: username_claim = auth_config.OAUTH_USERNAME_CLAIM if username_claim: new_name = user_data.get(username_claim) if new_name and new_name != user.name: - await Users.update_user_by_id(user.id, {'name': new_name}, db=db) - user.name = new_name - log.debug('Updated name for user %s', user.email) + updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db) + if updated_user: + user = updated_user + updated_fields.append('name') + log.debug('Updated name for user %s', user.email) if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: email_claim = auth_config.OAUTH_EMAIL_CLAIM @@ -1997,9 +2001,9 @@ class OAuthManager: log.error( f'Cannot update email to {new_email} for user {user.id} because it is already taken.' ) - else: - await Auths.update_email_by_id(user.id, new_email.lower(), db=db) - user.email = new_email.lower() + elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db): + user = await Users.get_user_by_id(user.id, db=db) or user + updated_fields.append('email') log.debug('Updated email for user %s', user.id) # Update profile picture if enabled and different from current @@ -2014,8 +2018,23 @@ class OAuthManager: new_picture_url, token.get('access_token') ) if processed_picture_url != user.profile_image_url: - await Users.update_user_profile_image_url_by_id(user.id, processed_picture_url, db=db) - log.debug('Updated profile picture for user %s', user.email) + updated_user = await Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url, db=db + ) + if updated_user: + user = updated_user + updated_fields.append('profile_image_url') + log.debug('Updated profile picture for user %s', user.email) + + if updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'updated_fields': updated_fields, 'provider': provider}, + ) else: # If the user does not exist, check if signups are enabled if auth_config.ENABLE_OAUTH_SIGNUP: From 0480ca9653f0d566eaedadf0af0d785a9938480b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:01:38 -0700 Subject: [PATCH 111/141] refac Co-Authored-By: Solaris-star <67425364+solaris-star@users.noreply.github.com> --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 9345441984..9ac31300f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -162,6 +162,7 @@ RUN set -e; \ fi; \ fi; \ mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \ + if [ -d /app/backend/data/cache ]; then chmod -R a+rX /app/backend/data/cache; fi; \ rm -rf /var/lib/apt/lists/*; # Optional: PPTX parsing through unstructured may need spaCy's English model. From 8fc5ffe26eac08d0d715945af335c539945c72ef Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:03:07 -0400 Subject: [PATCH 112/141] fix: persist the Open Sharing permission in default user permissions (#27609) --- backend/open_webui/routers/users.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 0220c141fc..6194feb1ca 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -234,6 +234,7 @@ class SharingPermissions(BaseModel): public_notes: bool = False folders: bool = False public_chats: bool = False + open_chats: bool = False public_calendars: bool = False From 90724cdee01bd9260d15f8482d4e0d2fbe2ebffb Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:03:37 -0400 Subject: [PATCH 113/141] fix: drop the white backdrop behind model icons in the admin Models list (#27612) --- src/lib/components/admin/Settings/Models.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 616969f0e1..0ce65e5c34 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -937,7 +937,7 @@ }} >
    -
    +
    UserModel | None: - """Look up a user by SCIM provider + external ID (dialect-aware JSON filter).""" + """Look up a user by SCIM provider + external ID.""" async with get_async_db_context(db) as session: - dialect = session.bind.dialect.name - query = select(User) - if dialect == 'sqlite': - scim_match = User.scim.contains({provider: {'external_id': external_id}}) - query = query.where(scim_match) - elif dialect == 'postgresql': - scim_match = User.scim[provider].cast(JSONB)['external_id'].astext == external_id - query = query.where(scim_match) + # Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE. + query = select(User).where(User.scim[provider]['external_id'].as_string() == external_id) row = (await session.execute(query)).scalars().first() return UserModel.model_validate(row) if row else None From 27402ff210bfa253445720920dfb86b15a00327b Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:06:12 +0200 Subject: [PATCH 115/141] refac: issue Playwright web loader requests from the shared HTTP clients (#28634) The Playwright loader's route interceptor now performs each intercepted request with the same requests/aiohttp clients the other web loader paths already use and fulfills the page with that response, rather than having the browser issue it. Redirect handling, header forwarding and cookie delivery to the browser are unchanged. Two consequences worth knowing. Page requests now leave from the backend instead of the browser, so with PLAYWRIGHT_WS_URL set they originate from a different host, and TLS is verified against certifi plus AIOHTTP_CLIENT_SSL_CERT_FILE rather than the browser's own trust store. And because the synchronous interceptor blocks, sub-resources on that path fetch one at a time: 30 assets at 40ms went from 2.01s to 3.01s, and 8 assets at 500ms from 1.05s to 4.50s. The asynchronous path is unaffected, at 0.65s and 1.05s respectively. --- backend/open_webui/retrieval/utils.py | 6 +- backend/open_webui/retrieval/web/utils.py | 175 ++++++++++++++++++---- 2 files changed, 150 insertions(+), 31 deletions(-) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 1e159b01a5..ed53c35db5 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -217,7 +217,7 @@ async def get_content_from_url(request, url: str) -> str: def _get_content_from_url_sync(request, url: str, loader_config): - from open_webui.retrieval.web.utils import validate_url, _SSRFSafeAdapter + from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session # Validate URL before making any request (blocks private IPs, non-HTTP, filter list) validate_url(url) @@ -241,9 +241,7 @@ def _get_content_from_url_sync(request, url: str, loader_config): # cloud-metadata 169.254.169.254) via a public host that redirects internally. try: # Probe through the connect-time SSRF guard; bare requests.get re-resolves (DNS-rebinding gap). - session = requests.Session() - session.mount('http://', _SSRFSafeAdapter()) - session.mount('https://', _SSRFSafeAdapter()) + session = get_ssrf_safe_requests_session() response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS) response.raise_for_status() content_type = response.headers.get('Content-Type', '') diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 0fa6e26fd6..ace34b062c 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -1,4 +1,5 @@ import asyncio +import http.cookiejar import ipaddress import logging import socket @@ -11,17 +12,20 @@ from typing import ( Any, AsyncIterator, Dict, + Iterable, Iterator, List, Literal, Optional, Sequence, + Tuple, Union, ) import aiohttp import aiohttp.resolver import certifi +import requests import urllib3.connection import urllib3.connectionpool import validators @@ -52,6 +56,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.env import ( AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, + AIOHTTP_CLIENT_SSL_CERT_FILE, AIOHTTP_CLIENT_TIMEOUT, USER_AGENT, ) @@ -240,19 +245,61 @@ class _SSRFSafeResolver(aiohttp.resolver.DefaultResolver): return results -def get_ssrf_safe_session() -> aiohttp.ClientSession: +def get_ssrf_safe_session(trust_env: bool = True, store_cookies: bool = True) -> aiohttp.ClientSession: """A one-off aiohttp session that re-validates the connect-time IP via _SSRFSafeResolver, defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed: ``async with get_ssrf_safe_session() as session: ...``. + + trust_env also enables environment proxies, and proxied traffic bypasses the connect-time + IP check, because the proxy resolves the hostname instead. """ return aiohttp.ClientSession( connector=aiohttp.TCPConnector(resolver=_SSRFSafeResolver()), timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), - trust_env=True, + trust_env=trust_env, + cookie_jar=None if store_cookies else aiohttp.DummyCookieJar(), ) +def get_ssrf_safe_requests_session(trust_env: bool = True, store_cookies: bool = True) -> requests.Session: + """The requests counterpart of get_ssrf_safe_session, with the same proxy caveat.""" + session = requests.Session() + session.trust_env = trust_env + if not store_cookies: + session.cookies.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[])) + session.mount('http://', _SSRFSafeAdapter()) + session.mount('https://', _SSRFSafeAdapter()) + return session + + +# accept-encoding goes because the client must advertise only codecs it can decode, the rest +# because the client derives them from the URL and body it is actually given. content-encoding +# stays: the browser's body is forwarded byte for byte, so its own labelling still applies. +_DROPPED_REQUEST_HEADERS = {'accept-encoding', 'connection', 'content-length', 'host', 'transfer-encoding'} + +# The clients hand us a decoded body, so the sender's framing no longer describes it. +_DROPPED_RESPONSE_HEADERS = {'connection', 'content-encoding', 'content-length', 'transfer-encoding'} + + +def _forwardable_request_headers(headers: Dict[str, str]) -> Dict[str, str]: + return {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS} + + +def _fulfillable_response_headers(header_pairs: Iterable[Tuple[str, str]]) -> Dict[str, str]: + """Collapse repeated headers the way route.fulfill expects: set-cookie by newline, rest by comma. + + Takes pairs rather than a mapping because reading either client's headers as a mapping loses + duplicate Set-Cookie values, leaving one malformed cookie or one of the two. + """ + collected: Dict[str, List[str]] = {} + for name, value in header_pairs: + name = name.lower() # grouping by the sender's case would split a repeated header + if name not in _DROPPED_RESPONSE_HEADERS: + collected.setdefault(name, []).append(value) + return {name: ('\n' if name == 'set-cookie' else ', ').join(values) for name, values in collected.items()} + + def extract_metadata(soup, url): metadata = {'source': url} if title := soup.find('title'): @@ -583,7 +630,9 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing requests_per_second (Optional[float]): Number of requests per second to limit to. continue_on_failure (bool): If True, continue loading other URLs on failure. headless (bool): If True, the browser will run in headless mode. - proxy (dict): Proxy override settings for the Playwright session. + proxy (dict): Proxy override settings for the Playwright session. Page requests are + issued outside the browser, so they follow the environment proxy via trust_env + rather than this setting. playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection. playwright_timeout (Optional[int]): Maximum operation time in milliseconds. """ @@ -628,14 +677,49 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing self.trust_env = trust_env self.playwright_timeout = playwright_timeout - def _intercept_navigation_sync(self, route, request=None): - req = request or route.request + def _request_timeout(self) -> float: + # per-hop budget, since page.goto's timeout cannot reach into our own fetch and 0 disables + # it. aiohttp treats it as a total where requests only caps each read, so sync runs looser. + return (self.playwright_timeout or 30000) / 1000 + + def _requests_verify(self) -> Union[bool, str]: + """requests takes a CA path where aiohttp takes the parsed SSLContext. + + A bundle named directly in AIOHTTP_CLIENT_SESSION_SSL reaches us already parsed and + cannot be expressed here, so that form falls back to the global bundle or certifi. + """ + if not self.verify_ssl or AIOHTTP_CLIENT_SESSION_SSL is False: + return False + if AIOHTTP_CLIENT_SESSION_SSL is True: + return True # no usable global CA bundle, so both clients land on certifi + return AIOHTTP_CLIENT_SSL_CERT_FILE or True + + def _intercept_navigation_sync(self, route, session): + req = route.request + + hop_cookies: List[Tuple[str, str]] = [] try: - validate_url(req.url) - resp = route.fetch(max_redirects=0) + headers = _forwardable_request_headers(req.all_headers()) + post_data = req.post_data_buffer + verify, timeout = self._requests_verify(), self._request_timeout() - if 300 <= resp.status < 400: + # The browser would resolve the hostname again, after the check; fetch it ourselves. + def fetch(url): + validate_url(url) + return session.request( + req.method, + url, + headers=headers, + data=post_data, + allow_redirects=False, + verify=verify, + timeout=timeout, + ) + + resp = fetch(req.url) + + if 300 <= resp.status_code < 400: for _ in range(20): if not AIOHTTP_CLIENT_ALLOW_REDIRECTS: route.abort() @@ -645,26 +729,50 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing if not location: break - url = urllib.parse.urljoin(resp.url, location) - validate_url(url) - resp = route.fetch(url=url, max_redirects=0) - if not 300 <= resp.status < 400: + # only the last hop is fulfilled, so carry each hop's cookies to the browser + hop_cookies += [('set-cookie', v) for v in resp.raw.headers.getlist('set-cookie')] + resp = fetch(urllib.parse.urljoin(resp.url, location)) + if not 300 <= resp.status_code < 400: break else: route.abort() return - except Exception: + except Exception as e: + log.debug('Playwright loader could not fetch %s: %s', req.url, e) route.abort() return - route.fulfill(response=resp) + route.fulfill( + status=resp.status_code, + headers=_fulfillable_response_headers(hop_cookies + list(resp.raw.headers.items())), + body=resp.content, + ) - async def _intercept_navigation(self, route, request=None): - req = request or route.request + async def _intercept_navigation(self, route, session): + req = route.request + + hop_cookies: List[Tuple[str, str]] = [] try: - await run_in_threadpool(validate_url, req.url) - resp = await route.fetch(max_redirects=0) + headers = _forwardable_request_headers(await req.all_headers()) + post_data = req.post_data_buffer + + # The browser would resolve the hostname again, after the check; fetch it ourselves. + async def fetch(url): + await run_in_threadpool(validate_url, url) + response = await session.request( + req.method, + url, + headers=headers, + data=post_data, + allow_redirects=False, + ssl=AIOHTTP_CLIENT_SESSION_SSL if self.verify_ssl else False, + timeout=aiohttp.ClientTimeout(total=self._request_timeout()), + ) + # aiohttp only returns the connection to the pool once the body is buffered + return response, await response.read() + + resp, body = await fetch(req.url) if 300 <= resp.status < 400: for _ in range(20): @@ -676,19 +784,24 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing if not location: break - url = urllib.parse.urljoin(resp.url, location) - await run_in_threadpool(validate_url, url) - resp = await route.fetch(url=url, max_redirects=0) + # only the last hop is fulfilled, so carry each hop's cookies to the browser + hop_cookies += [('set-cookie', v) for v in resp.headers.getall('Set-Cookie', [])] + resp, body = await fetch(urllib.parse.urljoin(str(resp.url), location)) if not 300 <= resp.status < 400: break else: await route.abort() return - except Exception: + except Exception as e: + log.debug('Playwright loader could not fetch %s: %s', req.url, e) await route.abort() return - await route.fulfill(response=resp) + await route.fulfill( + status=resp.status, + headers=_fulfillable_response_headers(hop_cookies + list(resp.headers.items())), + body=body, + ) def lazy_load(self) -> Iterator[Document]: """Safely load URLs synchronously with support for remote browser.""" @@ -705,8 +818,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing for url in self.urls: try: self._safe_process_url_sync(url) - with browser.new_page(service_workers='block') as page: - page.route('**/*', self._intercept_navigation_sync) + # opened before the page so it outlives any route still in flight at teardown + with ( + get_ssrf_safe_requests_session(self.trust_env, store_cookies=False) as session, + browser.new_page(service_workers='block') as page, + ): + page.route('**/*', lambda route: self._intercept_navigation_sync(route, session)) page.route_web_socket('**/*', lambda ws_route: ws_route.close()) response = page.goto(url, timeout=self.playwright_timeout) if response is None: @@ -736,8 +853,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing for url in self.urls: try: await self._safe_process_url(url) - async with await browser.new_page(service_workers='block') as page: - await page.route('**/*', self._intercept_navigation) + # opened before the page so it outlives any route still in flight at teardown + async with ( + get_ssrf_safe_session(self.trust_env, store_cookies=False) as session, + await browser.new_page(service_workers='block') as page, + ): + await page.route('**/*', lambda route: self._intercept_navigation(route, session)) await page.route_web_socket('**/*', lambda ws_route: ws_route.close()) response = await page.goto(url, timeout=self.playwright_timeout) if response is None: From f100edb70874808c93fab84eae1810595f9e9dc3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:12:13 -0700 Subject: [PATCH 116/141] refac --- src/lib/components/calendar/CalendarEventModal.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/components/calendar/CalendarEventModal.svelte b/src/lib/components/calendar/CalendarEventModal.svelte index 5eaa3c8d08..8c0b724683 100644 --- a/src/lib/components/calendar/CalendarEventModal.svelte +++ b/src/lib/components/calendar/CalendarEventModal.svelte @@ -121,6 +121,11 @@ return; } + if (!startDate) { + toast.error($i18n.t('Date is required')); + return; + } + loading = true; try { const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime); From b933292d63d12be3fd1416fe55519ddc7aa336bc Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:13:51 +0200 Subject: [PATCH 117/141] refactor: track visited ids when resolving a chat's current message (#28035) `delete_message_from_history` follows `childrenIds` down to the deepest leaf without recording where it has been. Record it. --- backend/open_webui/models/chats.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 8e39edf456..258d5b7cc6 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -902,8 +902,10 @@ class ChatTable: if current_id is None else messages.get(current_id, {}).get('childrenIds', []) ) - while child_ids: + visited_ids = set() + while child_ids and child_ids[-1] not in visited_ids: current_id = child_ids[-1] + visited_ids.add(current_id) child_ids = messages.get(current_id, {}).get('childrenIds', []) history['currentId'] = current_id if current_id in messages else None return deleted_ids From 3d630491c61a846e0f8341b4c76d09dce2d819a8 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:14:11 +0200 Subject: [PATCH 118/141] fix: re-syncing an existing model no longer fails silently (#28036) POST /api/v1/models/sync only worked when every model in the payload was new. As soon as one id already existed, the whole call blew up and the endpoint still answered HTTP 200 with an empty list, so nothing was updated and well-behaved clients saw a success. Only a first-ever sync into an empty catalogue went through. The update branch splatted the model dump (which already carries user_id and updated_at) and then passed both again as explicit keyword arguments, which is a duplicate-keyword TypeError before SQLAlchemy ever sees it. The insert branch right below merged the same values into a dict first, so it never collided. Fixed by building that dict once and using it for both branches, matching how sync_functions already does it. Left the broad exception handler alone: it is the reason the failure was silent, but changing the error contract of sync_models is a separate call. Fixes #28033 --- backend/open_webui/models/models.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 839e47c6d3..5146724a93 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -605,25 +605,16 @@ class ModelsTable: # Update or insert models for model in models: + model_data = { + **model.model_dump(exclude={'access_grants'}), + 'user_id': user_id, + 'updated_at': int(time.time()), + } + if model.id in existing_ids: - await db.execute( - update(Model) - .filter_by(id=model.id) - .values( - **model.model_dump(exclude={'access_grants'}), - user_id=user_id, - updated_at=int(time.time()), - ) - ) + await db.execute(update(Model).filter_by(id=model.id).values(**model_data)) else: - new_model = Model( - **{ - **model.model_dump(exclude={'access_grants'}), - 'user_id': user_id, - 'updated_at': int(time.time()), - } - ) - db.add(new_model) + db.add(Model(**model_data)) await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db) # Remove models that are no longer present From d2af19ae3c685adaaa2eb9a275dead6e1648fe53 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:15:36 -0700 Subject: [PATCH 119/141] refac --- src/lib/apis/users/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index b48c3b27cd..a6da772d18 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -286,7 +286,7 @@ export const getUserSettings = async (token: string, raw = false) => { }) .catch((err) => { console.error(err); - error = err.detail; + error = err?.detail ?? err; return null; }); From b6dc70c93b0d36e2659438e7a55aa8b21fee27f8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:16:07 -0700 Subject: [PATCH 120/141] refac --- backend/open_webui/utils/middleware.py | 5 +++-- backend/open_webui/utils/misc.py | 23 ++++++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index b85407ebe3..96166e0ac3 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -110,6 +110,7 @@ from open_webui.utils.misc import ( get_last_user_message_item, get_message_list, get_output_text, + get_reasoning_details, get_system_message, is_string_allowed, merge_system_messages, @@ -3916,7 +3917,7 @@ async def non_streaming_chat_response_handler(response, ctx): if not response_output: choice_message = choices[0].get('message', {}) reasoning_content = choice_message.get('reasoning_content') or choice_message.get('reasoning') - reasoning_details = choice_message.get('reasoning_details') + reasoning_details = get_reasoning_details(choice_message) response_output = [] if reasoning_content or reasoning_details: reasoning_item = { @@ -4957,7 +4958,7 @@ async def streaming_chat_response_handler(response, ctx): or delta.get('reasoning') or delta.get('thinking') ) - reasoning_details = delta.get('reasoning_details') + reasoning_details = get_reasoning_details(delta) reasoning_detail_items = ( [item for item in reasoning_details if isinstance(item, dict)] if isinstance(reasoning_details, list) diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index bfb1eff5e9..ac3eec866d 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -258,6 +258,17 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]: return reconciled_messages +def get_reasoning_details(payload: dict): + if not isinstance(payload, dict): + return None + + provider_fields = payload.get('provider_specific_fields') or {} + provider_details = ( + provider_fields.get('reasoning_details') if isinstance(provider_fields, dict) else None + ) + return payload.get('reasoning_details') or provider_details + + def convert_output_to_messages( output: list, raw: bool = False, @@ -447,6 +458,14 @@ def convert_output_to_messages( elif item_type == 'reasoning': reasoning_details = item.get('reasoning_details') if raw else None + if reasoning_details: + reasoning_details = reasoning_details if isinstance(reasoning_details, list) else [reasoning_details] + reasoning_details = [ + detail + for detail in reasoning_details + if isinstance(detail, dict) + and (detail.get('format') != 'anthropic-claude-v1' or detail.get('signature')) + ] if not reasoning_format and not reasoning_details: continue @@ -469,9 +488,7 @@ def convert_output_to_messages( pending_reasoning.append(reasoning_text) if reasoning_details: - pending_reasoning_details.extend( - reasoning_details if isinstance(reasoning_details, list) else [reasoning_details] - ) + pending_reasoning_details.extend(reasoning_details) elif item_type == 'open_webui:code_interpreter': # Always include code interpreter content so the LLM knows From 0007369f4e3d77b7c0d9ed90d88a6caee47f9c06 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:16:11 -0700 Subject: [PATCH 121/141] refac --- src/lib/i18n/locales/en-US/translation.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 1e04d27cca..95fb38127e 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -709,6 +709,7 @@ "Database": "", "Datalab Marker API": "", "Datalab Marker service endpoint used for document parsing.": "", + "Date is required": "", "Date Modified": "", "Day": "", "DD/MM/YYYY": "", From 211906d7990759ea3b43fbcbd5d35ceabc6b3185 Mon Sep 17 00:00:00 2001 From: Lin Junrong Date: Mon, 17 Aug 2026 15:18:22 +0800 Subject: [PATCH 122/141] fix: keep references to lifespan background tasks (#28053) periodic_usage_pool_cleanup, periodic_session_pool_cleanup and scheduler_worker_loop were started with asyncio.create_task and their handles discarded. The event loop keeps only a weak reference to a task, so a task with no other referent can be garbage collected while it is suspended at an await. All three are while True loops meant to run for the process lifetime, and if one is collected the failure is silent: pool entries stop being cleaned up, or automations and calendar alerts stop firing, with nothing logged. Six lines above, redis_task_command_listener is already stored on app.state and cancelled on shutdown. This applies the same treatment to the other three. Closes #28052 --- backend/open_webui/main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 4b7622a833..9c5f538562 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -382,12 +382,12 @@ async def lifespan(app: FastAPI): limiter = anyio.to_thread.current_default_thread_limiter() limiter.total_tokens = THREAD_POOL_SIZE - asyncio.create_task(periodic_usage_pool_cleanup()) - asyncio.create_task(periodic_session_pool_cleanup()) + app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup()) + app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup()) from open_webui.utils.automations import scheduler_worker_loop - asyncio.create_task(scheduler_worker_loop(app)) + app.state.scheduler_worker_loop = asyncio.create_task(scheduler_worker_loop(app)) if await Config.get('models.base_models_cache'): try: @@ -468,6 +468,10 @@ async def lifespan(app: FastAPI): if hasattr(app.state, 'redis_task_command_listener'): app.state.redis_task_command_listener.cancel() + app.state.periodic_usage_pool_cleanup.cancel() + app.state.periodic_session_pool_cleanup.cancel() + app.state.scheduler_worker_loop.cancel() + await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system') From ad8c79f68657bd3bcf5db6be650e498bb904b36b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:18:35 -0700 Subject: [PATCH 123/141] refac --- backend/open_webui/routers/users.py | 2 +- src/routes/(app)/+layout.svelte | 36 ++++++++++++----------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 6194feb1ca..55a2a57a76 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -508,7 +508,7 @@ async def update_user_settings_by_session_user( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - updated_user_settings = form_data.model_dump() + updated_user_settings = form_data.model_dump(exclude_unset=True) ui_settings = updated_user_settings.get('ui') if ( user.role != 'admin' diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index f9e34036f3..eec8049922 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -90,19 +90,7 @@ }; const setUserSettings = async (cb?: () => Promise) => { - let userSettings = await getUserSettings(localStorage.token).catch((error) => { - console.error(error); - return null; - }); - - if (!userSettings) { - try { - userSettings = JSON.parse(localStorage.getItem('settings') ?? '{}'); - } catch (e: unknown) { - console.error('Failed to parse settings from localStorage', e); - userSettings = {}; - } - } + const userSettings = await getUserSettings(localStorage.token); if (userSettings?.ui) { settings.set(userSettings.ui); @@ -254,14 +242,20 @@ } clearChatInputStorage(); - await Promise.all([ - checkLocalDBChats(), - setBanners().catch((e) => console.error('Failed to load banners:', e)), - setTools().catch((e) => console.error('Failed to load tools:', e)), - setUserSettings(async () => { - await setModels().catch((e) => console.error('Failed to load models:', e)); - }).catch((e) => console.error('Failed to load user settings:', e)) - ]); + try { + await Promise.all([ + checkLocalDBChats(), + setBanners().catch((e) => console.error('Failed to load banners:', e)), + setTools().catch((e) => console.error('Failed to load tools:', e)), + setUserSettings(async () => { + await setModels().catch((e) => console.error('Failed to load models:', e)); + }) + ]); + } catch (e) { + console.error('Failed to load user settings:', e); + toast.error($i18n.t('Failed to load Interface settings')); + return; + } selectedTerminalId.set(localStorage.selectedTerminalId ?? null); From 31897b7e3442852b6a4706b5e27d702456e564f3 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:20:54 -0400 Subject: [PATCH 124/141] fix: stop channel message hover actions overlapping code and table toolbars (#27737) --- src/lib/components/channel/Messages/Message.svelte | 2 +- src/lib/components/channel/PinnedMessagesModal.svelte | 2 +- src/lib/components/channel/Thread.svelte | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index b0ad2084e2..a6bfcb6dfa 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -209,7 +209,7 @@ : 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}" > {#if !edit && !disabled} -
    +
    diff --git a/src/lib/components/channel/PinnedMessagesModal.svelte b/src/lib/components/channel/PinnedMessagesModal.svelte index ab171f9f96..45316c3609 100644 --- a/src/lib/components/channel/PinnedMessagesModal.svelte +++ b/src/lib/components/channel/PinnedMessagesModal.svelte @@ -94,7 +94,7 @@
    {:else}
    {#if pinnedMessages.length === 0}
    diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte index 9026d0d84c..39203eb7dd 100644 --- a/src/lib/components/channel/Thread.svelte +++ b/src/lib/components/channel/Thread.svelte @@ -188,7 +188,7 @@
    -
    +
    {#if messages !== null} Date: Mon, 17 Aug 2026 03:22:47 -0400 Subject: [PATCH 125/141] fix: record an error state when a timer's chat completion raises (#27785) --- backend/open_webui/utils/timers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/utils/timers.py b/backend/open_webui/utils/timers.py index 2546b311ae..46d1e3312f 100644 --- a/backend/open_webui/utils/timers.py +++ b/backend/open_webui/utils/timers.py @@ -404,7 +404,11 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> ) request.state.token = None request.state.enable_api_keys = False - await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + try: + await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user) + except Exception as exc: + log.exception(f'Timer {timer_id} completion failed') + await _set_timer_state(timer_id, 'error', timer_error=str(exc)[:500]) async def _set_timer_state(timer_id: str, status: str, **fields) -> None: From 695d33aa7c2c0a3a4edae5b856f91ef5e8afe48f Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:23:25 -0400 Subject: [PATCH 126/141] fix: let the chat column shrink so the collapsed sidebar rail is not pushed off screen (#28501) --- src/lib/components/chat/Chat.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index dee368c489..ed8d94d511 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -4223,7 +4223,7 @@ : 'h-screen max-h-[100dvh]'} transition-width duration-200 ease-in-out {$showSidebar && !embedded ? ' md:max-w-[calc(100%-var(--sidebar-width))]' - : ' '} w-full max-w-full flex flex-col" + : ' '} w-full max-w-full min-w-0 flex flex-col" id={chatContainerId} > {#if !loading} From 189c14fc4d91d89b27f5333283f551c3b175c967 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:24:05 +0200 Subject: [PATCH 127/141] fix: match both JSON text spellings when searching serialised JSON columns (#28399) Three searches LIKE against cast(json_col AS text), which means they have to match bytes a JSON encoder wrote. Encoders disagree on non-ASCII: stdlib escapes it to \uXXXX, orjson writes it raw. Which one produced a row depends on the codec in force when it was written, so any single pattern finds only half the table. models.py hard-codes the stdlib spelling, with a comment asserting SQLite stores JSON via json.dumps(ensure_ascii=True). Model.meta is a JSONField, which has serialised through JSONCodec since ENABLE_ORJSON was introduced, so on that setting it stores raw UTF-8 and the escaped pattern matches nothing: non-ASCII workspace model tag search is broken today. prompts.py and automations.py hard-code the opposite spelling and miss rows written the other way. json_text_variants returns both spellings a string can take inside serialised JSON, collapsing to one for ASCII, and the three call sites OR over them. Rows written under either setting are now found under either setting, which also covers a database holding a mix of the two. Case handling is unchanged. models.py keeps matching non-ASCII tags case-sensitively on SQLite, whose LOWER() is ASCII-only and would not fold the stored text the way str.lower() folds the tag. ASCII tags collapse to a single variant and take exactly the query they took before. Verified on SQLite across every combination of codec-that-wrote-the-row and codec-the-app-is-running, for an ASCII and a CJK tag, over all three call sites: 24 of 24 match, against 12 of 24 before. Quoting still bounds whole-tag matches, so searching "weather" does not match a row tagged "weathervane". Co-authored-by: Claude --- backend/open_webui/models/automations.py | 9 +++++---- backend/open_webui/models/models.py | 20 +++++++------------- backend/open_webui/models/prompts.py | 9 +++++---- backend/open_webui/utils/misc.py | 11 +++++++++++ 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 1eaaca7175..11a906670b 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -4,6 +4,7 @@ from typing import Literal, Optional from uuid import uuid4 from open_webui.internal.db import Base, get_async_db_context +from open_webui.utils.misc import json_text_variants from pydantic import BaseModel, ConfigDict from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -189,12 +190,12 @@ class AutomationTable: stmt = stmt.filter(Automation.folder_id == folder_id) if query: - search = f'%{query}%' - # Search in name and prompt inside JSON data + # Search the name column and the prompt inside the JSON data. + data_text = cast(Automation.data, String) stmt = stmt.filter( or_( - Automation.name.ilike(search), - cast(Automation.data, String).ilike(search), + Automation.name.ilike(f'%{query}%'), + *(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)), ) ) diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 5146724a93..eac1812c98 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import logging import time from copy import deepcopy @@ -10,6 +9,7 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse, Users +from open_webui.utils.misc import json_text_variants from open_webui.utils.validate import validate_profile_image_url from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update @@ -374,20 +374,14 @@ class ModelsTable: tag = filter.get('tag') if tag: - # SQLite stores JSON text via json.dumps(ensure_ascii=True), - # so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB - # stores literal Unicode. Use the right pattern for each. - if db.bind.dialect.name == 'sqlite': - if tag.isascii(): - meta_text = func.lower(cast(Model.meta, String)) - pattern = f'%{json.dumps(tag.lower())}%' - else: - meta_text = cast(Model.meta, String) - pattern = f'%{json.dumps(tag)}%' + if db.bind.dialect.name == 'sqlite' and not tag.isascii(): + # SQLite's LOWER() is ASCII-only, so match non-ASCII tags exact-case. + meta_text = cast(Model.meta, String) + variants = json_text_variants(tag) else: meta_text = func.lower(cast(Model.meta, String)) - pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%' - stmt = stmt.filter(meta_text.like(pattern)) + variants = json_text_variants(tag.lower()) + stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants))) order_by = filter.get('order_by') direction = filter.get('direction') diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index e985bcc70d..54e67ac2f4 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -14,7 +14,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.prompt_history import PromptHistories from open_webui.models.users import User, UserModel, UserResponse, Users -from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.misc import json_text_variants from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, text, update from sqlalchemy.ext.asyncio import AsyncSession @@ -342,9 +342,10 @@ class PromptsTable: 'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)' ) else: - # Fallback: LIKE on serialised JSON text (ASCII-safe only) - tag_clause = func.lower(cast(Prompt.tags, String)).like( - f'%{JSONCodec.dumps(tag_lower, ensure_ascii=False)}%' + # Fallback for dialects with no JSON array function: LIKE on the text. + tags_text = func.lower(cast(Prompt.tags, String)) + tag_clause = or_( + *(tags_text.like(f'%"{variant}"%') for variant in json_text_variants(tag_lower)) ) tag_lower = None diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index ac3eec866d..c24e6568d8 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -833,6 +833,17 @@ def sanitize_filename(file_name): return final_file_name +def json_text_variants(value: str) -> list[str]: + """Both spellings ``value`` can take inside a serialized JSON column, unquoted. + + Encoders disagree on non-ASCII — stdlib escapes it to ``\\uXXXX``, orjson writes it + raw — so a LIKE against the stored text has to accept either. ASCII collapses to one. + """ + raw = JSONCodec.dumps(value, ensure_ascii=False)[1:-1] + escaped = JSONCodec.dumps(value, ensure_ascii=True)[1:-1] + return [raw] if raw == escaped else [raw, escaped] + + def sanitize_text_for_db(text: str) -> str: """Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage.""" if not isinstance(text, str): From 9550731cc17759f6862595b8cd849ae48695b5c1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:24:47 -0700 Subject: [PATCH 128/141] refac --- backend/open_webui/tools/builtin.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f4150cfea9..68e68d8808 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -4056,6 +4056,9 @@ async def delete_automation( # ============================================================================= +MAX_CALENDAR_RANGE_END_NS = 2**63 - 1 + + def _get_user_tz(user_dict: dict): """Get the user's timezone as a ZoneInfo, falling back to UTC.""" from zoneinfo import ZoneInfo @@ -4155,11 +4158,7 @@ async def search_calendar_events( return JSONCodec.dumps({'error': f'Invalid start datetime: {e}'}) try: - end_ns = ( - _dt_to_ns(end, tz) - if end - else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000 - ) + end_ns = _dt_to_ns(end, tz) if end else MAX_CALENDAR_RANGE_END_NS except (ValueError, TypeError) as e: return JSONCodec.dumps({'error': f'Invalid end datetime: {e}'}) From 76d01602950a9e40823b53278557708d5bcd1036 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:31:01 -0700 Subject: [PATCH 129/141] refac --- src/lib/components/channel/Messages/Message.svelte | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index a6bfcb6dfa..b14d79f9ec 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -10,7 +10,7 @@ dayjs.extend(isYesterday); dayjs.extend(localizedFormat); - import { getContext, onMount } from 'svelte'; + import { getContext } from 'svelte'; const i18n = getContext>('i18n'); import { formatDate } from '$lib/utils'; @@ -146,11 +146,9 @@ } }; - onMount(async () => { - if (message && message?.data === true) { - await loadMessageData(); - } - }); + $: if (message?.data === true) { + loadMessageData(); + } $: messageOutput = Array.isArray(message?.data?.output) ? message.data.output : []; $: hasStructuredOutput = buildOutputDisplayItems(messageOutput).length > 0; @@ -454,7 +452,6 @@ {/if} {#if message?.data === true} -
    From f1a64ccfc2eb2a58086c55fe413a455bb488c35a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:35:12 -0700 Subject: [PATCH 130/141] refac --- backend/open_webui/utils/tools.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 8a8e980d06..49f6c46aec 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1170,15 +1170,17 @@ async def set_tool_servers(request: Request): async def get_tool_servers(request: Request): try: - tool_servers = [] + tool_servers = None if request.app.state.redis is not None: try: - tool_servers = JSONCodec.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')) - request.app.state.TOOL_SERVERS = tool_servers + data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers') + if data is not None: + tool_servers = JSONCodec.loads(data) + request.app.state.TOOL_SERVERS = tool_servers except Exception as e: log.error(f'Error fetching tool_servers from Redis: {e}') - if not tool_servers: + if tool_servers is None: tool_servers = await set_tool_servers(request) return tool_servers @@ -1313,15 +1315,17 @@ async def set_terminal_servers(request: Request): async def get_terminal_servers(request: Request): """Return cached terminal server specs, loading if needed.""" - terminal_servers = [] + terminal_servers = None if request.app.state.redis is not None: try: - terminal_servers = JSONCodec.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')) - request.app.state.TERMINAL_SERVERS = terminal_servers + data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers') + if data is not None: + terminal_servers = JSONCodec.loads(data) + request.app.state.TERMINAL_SERVERS = terminal_servers except Exception as e: log.error(f'Error fetching terminal_servers from Redis: {e}') - if not terminal_servers: + if terminal_servers is None: terminal_servers = await set_terminal_servers(request) return terminal_servers From 44f4d5b94f8ae9d2b3b7cb2ce049a03c3e366a59 Mon Sep 17 00:00:00 2001 From: James Kerrane Date: Mon, 17 Aug 2026 03:39:13 -0400 Subject: [PATCH 131/141] chore: refresh outdated version examples in bug report template (#28188) * refactor: remove unused optional assignees key According to the GitHub docs (https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms#top-level-syntax) this key is optional. Since it's unused, it is fine to remove. * chore: bump version examples for software Older versions might confuse people filing new issues, so newer versions of mentioned software are used as examples. --- .github/ISSUE_TEMPLATE/bug_report.yaml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 420633a0f6..ad4a3e3f11 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -2,7 +2,6 @@ name: Bug Report description: Create a detailed bug report to help us improve Open WebUI. title: 'issue: ' labels: ['bug', 'triage'] -assignees: [] body: - type: markdown attributes: @@ -55,7 +54,7 @@ body: id: open-webui-version attributes: label: Open WebUI Version - description: Specify the version (e.g., v0.6.26) + description: Specify the version (e.g., v0.11.0) validations: required: true @@ -63,7 +62,7 @@ body: id: ollama-version attributes: label: Ollama Version (if applicable) - description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1) + description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0) validations: required: false @@ -71,7 +70,7 @@ body: id: operating-system attributes: label: Operating System - description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12) + description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13) validations: required: true @@ -79,7 +78,7 @@ body: id: browser attributes: label: Browser (if applicable) - description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0) + description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3) validations: required: false @@ -138,11 +137,11 @@ body: placeholder: | Example (include every detail): - 1. Start with a clean Ubuntu 22.04 install. - 2. Install Docker v24.0.5 and start the service. + 1. Start with a clean Ubuntu 26.04 install. + 2. Install Docker v29.7.1 and start the service. 3. Clone the Open WebUI repo (git clone ...). 4. Use the Docker Compose file without modifications. - 5. Open browser Chrome 115.0 in incognito mode. + 5. Open browser Chrome 151.0 in incognito mode. 6. Go to http://localhost:8080 and log in with user "test@example.com". 7. Set the language to "English" and theme to "Dark". 8. Attempt to connect to Ollama at "http://localhost:11434". From c0d09a5de93d48517efe043ea2f4a2f6a7a44fc3 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:40:56 -0400 Subject: [PATCH 132/141] fix: list publicly shared read-only notes in the Read Only view (#27637) --- backend/open_webui/models/access_grants.py | 29 ++++++++-------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 7cca23546c..03a7ef7202 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -839,7 +839,8 @@ class AccessGrantsTable: ): """ Filter for items where user has read BUT NOT write access. - Public items are NOT considered read_only. + A public (user:*) read grant counts as read access, so publicly shared + read-only items are listed rather than being reachable only by direct link. Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself, so it remains synchronous. The caller is responsible for executing the query @@ -850,7 +851,6 @@ class AccessGrantsTable: from sqlalchemy import exists as sa_exists - # Has read grant (not public) read_grant_exists = ( select(AccessGrant.id) .where( @@ -858,6 +858,10 @@ class AccessGrantsTable: AccessGrant.resource_id == DocumentModel.id, AccessGrant.permission == 'read', or_( + and_( + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', + ), *( [ and_( @@ -884,7 +888,6 @@ class AccessGrantsTable: .exists() ) - # Does NOT have write grant write_grant_exists = ( select(AccessGrant.id) .where( @@ -892,6 +895,10 @@ class AccessGrantsTable: AccessGrant.resource_id == DocumentModel.id, AccessGrant.permission == 'write', or_( + and_( + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', + ), *( [ and_( @@ -918,21 +925,7 @@ class AccessGrantsTable: .exists() ) - # Is NOT public - public_grant_exists = ( - select(AccessGrant.id) - .where( - AccessGrant.resource_type == resource_type, - AccessGrant.resource_id == DocumentModel.id, - AccessGrant.permission == 'read', - AccessGrant.principal_type == 'user', - AccessGrant.principal_id == '*', - ) - .correlate(DocumentModel) - .exists() - ) - - conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists] + conditions = [read_grant_exists, ~write_grant_exists] # Not owner if user_id: From d799e81edbdc971c6deb096b6474cd95b93504bf Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:42:16 -0700 Subject: [PATCH 133/141] refac --- backend/open_webui/routers/auths.py | 17 +++ backend/open_webui/utils/oauth.py | 168 ++++++++++++++++------------ 2 files changed, 111 insertions(+), 74 deletions(-) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 86de689458..4d08ef1022 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1705,4 +1705,21 @@ async def token_exchange( detail='User not found. Please sign in via the web interface first.', ) + user = await oauth_manager.update_user_from_oauth( + request=request, + user=user, + user_data=user_data, + provider=provider, + token=token_data, + db=db, + ) + if await Config.get('oauth.enable_group_mapping'): + await oauth_manager.update_user_groups( + request=request, + user=user, + user_data=user_data, + default_permissions=await Config.get('user.permissions'), + db=db, + ) + return await create_session_response(request, user, db, source='oauth') diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 8fb1f72a9b..c37545a141 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1519,8 +1519,8 @@ class OAuthManager: oauth_allowed_roles = auth_config.OAUTH_ALLOWED_ROLES oauth_admin_roles = auth_config.OAUTH_ADMIN_ROLES oauth_roles = [] - # Default/fallback role if no matching roles are found - role = auth_config.DEFAULT_USER_ROLE + # Keep existing users at their current role unless the provider sent roles. + role = user.role if user else auth_config.DEFAULT_USER_ROLE # Next block extracts the roles from the user data, accepting nested claims of any depth if oauth_claim and oauth_allowed_roles and oauth_admin_roles: @@ -1585,6 +1585,90 @@ class OAuthManager: return role + async def update_user_from_oauth( + self, + request, + user, + user_data, + provider, + token: dict | None = None, + *, + db=None, + ): + auth_config = await get_oauth_runtime_config() + + determined_role = await self.get_user_role(user, user_data) + if user.role != determined_role: + updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db) + user = updated_user or user + user.role = determined_role + await publish_event( + request, + EVENTS.USER_ROLE_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'role': determined_role, 'provider': provider}, + ) + + updated_fields = [] + + if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: + username_claim = auth_config.OAUTH_USERNAME_CLAIM + if username_claim: + new_name = user_data.get(username_claim) + if new_name and new_name != user.name: + updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db) + if updated_user: + user = updated_user + updated_fields.append('name') + log.debug('Updated name for user %s', user.email) + + if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: + email_claim = auth_config.OAUTH_EMAIL_CLAIM + if email_claim: + new_email = user_data.get(email_claim) + if new_email and new_email.lower() != user.email.lower(): + existing_user = await Users.get_user_by_email(new_email, db=db) + if existing_user: + log.error( + f'Cannot update email to {new_email} for user {user.id} because it is already taken.' + ) + elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db): + user = await Users.get_user_by_id(user.id, db=db) or user + updated_fields.append('email') + log.debug('Updated email for user %s', user.id) + + if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: + picture_claim = auth_config.OAUTH_PICTURE_CLAIM + if picture_claim: + new_picture_url = user_data.get( + picture_claim, + OAUTH_PROVIDERS[provider].get('picture_url', ''), + ) + access_token = token.get('access_token') if token else None + processed_picture_url = await self._process_picture_url(new_picture_url, access_token) + if processed_picture_url != user.profile_image_url: + updated_user = await Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url, db=db + ) + if updated_user: + user = updated_user + updated_fields.append('profile_image_url') + log.debug('Updated profile picture for user %s', user.email) + + if updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'updated_fields': updated_fields, 'provider': provider}, + ) + + return user + async def update_user_groups(self, request, user, user_data, default_permissions, db=None): auth_config = await get_oauth_runtime_config() log.debug('Running OAUTH Group management') @@ -1963,78 +2047,14 @@ class OAuthManager: await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) if user: - determined_role = await self.get_user_role(user, user_data) - if user.role != determined_role: - updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db) - # Update the user object in memory as well, - # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below - user.role = determined_role - await publish_event( - request, - EVENTS.USER_ROLE_UPDATED, - actor=updated_user or user, - subject_id=user.id, - source='oauth', - data={'role': determined_role, 'provider': provider}, - ) - - updated_fields = [] - - if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: - username_claim = auth_config.OAUTH_USERNAME_CLAIM - if username_claim: - new_name = user_data.get(username_claim) - if new_name and new_name != user.name: - updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db) - if updated_user: - user = updated_user - updated_fields.append('name') - log.debug('Updated name for user %s', user.email) - - if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: - email_claim = auth_config.OAUTH_EMAIL_CLAIM - if email_claim: - new_email = user_data.get(email_claim) - if new_email and new_email.lower() != user.email.lower(): - existing_user = await Users.get_user_by_email(new_email, db=db) - if existing_user: - log.error( - f'Cannot update email to {new_email} for user {user.id} because it is already taken.' - ) - elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db): - user = await Users.get_user_by_id(user.id, db=db) or user - updated_fields.append('email') - log.debug('Updated email for user %s', user.id) - - # Update profile picture if enabled and different from current - if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: - picture_claim = auth_config.OAUTH_PICTURE_CLAIM - if picture_claim: - new_picture_url = user_data.get( - picture_claim, - OAUTH_PROVIDERS[provider].get('picture_url', ''), - ) - processed_picture_url = await self._process_picture_url( - new_picture_url, token.get('access_token') - ) - if processed_picture_url != user.profile_image_url: - updated_user = await Users.update_user_profile_image_url_by_id( - user.id, processed_picture_url, db=db - ) - if updated_user: - user = updated_user - updated_fields.append('profile_image_url') - log.debug('Updated profile picture for user %s', user.email) - - if updated_fields: - await publish_event( - request, - EVENTS.USER_UPDATED, - actor=user, - subject_id=user.id, - source='oauth', - data={'updated_fields': updated_fields, 'provider': provider}, - ) + user = await self.update_user_from_oauth( + request=request, + user=user, + user_data=user_data, + provider=provider, + token=token, + db=db, + ) else: # If the user does not exist, check if signups are enabled if auth_config.ENABLE_OAUTH_SIGNUP: From e9684458125c3202ebc3378aef32f0b6015171d8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:43:47 -0700 Subject: [PATCH 134/141] refac --- backend/open_webui/routers/auths.py | 3 +- backend/open_webui/utils/oauth.py | 146 ++++++++++++++-------------- 2 files changed, 74 insertions(+), 75 deletions(-) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 4d08ef1022..63aebf1b60 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1705,12 +1705,11 @@ async def token_exchange( detail='User not found. Please sign in via the web interface first.', ) - user = await oauth_manager.update_user_from_oauth( + user = await oauth_manager.update_user_role_from_oauth( request=request, user=user, user_data=user_data, provider=provider, - token=token_data, db=db, ) if await Config.get('oauth.enable_group_mapping'): diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index c37545a141..fee218d086 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1585,87 +1585,30 @@ class OAuthManager: return role - async def update_user_from_oauth( + async def update_user_role_from_oauth( self, request, user, user_data, provider, - token: dict | None = None, *, db=None, ): - auth_config = await get_oauth_runtime_config() - determined_role = await self.get_user_role(user, user_data) - if user.role != determined_role: - updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db) - user = updated_user or user - user.role = determined_role - await publish_event( - request, - EVENTS.USER_ROLE_UPDATED, - actor=user, - subject_id=user.id, - source='oauth', - data={'role': determined_role, 'provider': provider}, - ) + if user.role == determined_role: + return user - updated_fields = [] - - if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: - username_claim = auth_config.OAUTH_USERNAME_CLAIM - if username_claim: - new_name = user_data.get(username_claim) - if new_name and new_name != user.name: - updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db) - if updated_user: - user = updated_user - updated_fields.append('name') - log.debug('Updated name for user %s', user.email) - - if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: - email_claim = auth_config.OAUTH_EMAIL_CLAIM - if email_claim: - new_email = user_data.get(email_claim) - if new_email and new_email.lower() != user.email.lower(): - existing_user = await Users.get_user_by_email(new_email, db=db) - if existing_user: - log.error( - f'Cannot update email to {new_email} for user {user.id} because it is already taken.' - ) - elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db): - user = await Users.get_user_by_id(user.id, db=db) or user - updated_fields.append('email') - log.debug('Updated email for user %s', user.id) - - if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: - picture_claim = auth_config.OAUTH_PICTURE_CLAIM - if picture_claim: - new_picture_url = user_data.get( - picture_claim, - OAUTH_PROVIDERS[provider].get('picture_url', ''), - ) - access_token = token.get('access_token') if token else None - processed_picture_url = await self._process_picture_url(new_picture_url, access_token) - if processed_picture_url != user.profile_image_url: - updated_user = await Users.update_user_profile_image_url_by_id( - user.id, processed_picture_url, db=db - ) - if updated_user: - user = updated_user - updated_fields.append('profile_image_url') - log.debug('Updated profile picture for user %s', user.email) - - if updated_fields: - await publish_event( - request, - EVENTS.USER_UPDATED, - actor=user, - subject_id=user.id, - source='oauth', - data={'updated_fields': updated_fields, 'provider': provider}, - ) + updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db) + user = updated_user or user + user.role = determined_role + await publish_event( + request, + EVENTS.USER_ROLE_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'role': determined_role, 'provider': provider}, + ) return user @@ -2047,14 +1990,71 @@ class OAuthManager: await Users.update_user_oauth_by_id(user.id, provider, sub, db=db) if user: - user = await self.update_user_from_oauth( + user = await self.update_user_role_from_oauth( request=request, user=user, user_data=user_data, provider=provider, - token=token, db=db, ) + + updated_fields = [] + + if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN: + username_claim = auth_config.OAUTH_USERNAME_CLAIM + if username_claim: + new_name = user_data.get(username_claim) + if new_name and new_name != user.name: + updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db) + if updated_user: + user = updated_user + updated_fields.append('name') + log.debug('Updated name for user %s', user.email) + + if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: + email_claim = auth_config.OAUTH_EMAIL_CLAIM + if email_claim: + new_email = user_data.get(email_claim) + if new_email and new_email.lower() != user.email.lower(): + existing_user = await Users.get_user_by_email(new_email, db=db) + if existing_user: + log.error( + f'Cannot update email to {new_email} for user {user.id} because it is already taken.' + ) + elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db): + user = await Users.get_user_by_id(user.id, db=db) or user + updated_fields.append('email') + log.debug('Updated email for user %s', user.id) + + # Update profile picture if enabled and different from current + if auth_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: + picture_claim = auth_config.OAUTH_PICTURE_CLAIM + if picture_claim: + new_picture_url = user_data.get( + picture_claim, + OAUTH_PROVIDERS[provider].get('picture_url', ''), + ) + processed_picture_url = await self._process_picture_url( + new_picture_url, token.get('access_token') + ) + if processed_picture_url != user.profile_image_url: + updated_user = await Users.update_user_profile_image_url_by_id( + user.id, processed_picture_url, db=db + ) + if updated_user: + user = updated_user + updated_fields.append('profile_image_url') + log.debug('Updated profile picture for user %s', user.email) + + if updated_fields: + await publish_event( + request, + EVENTS.USER_UPDATED, + actor=user, + subject_id=user.id, + source='oauth', + data={'updated_fields': updated_fields, 'provider': provider}, + ) else: # If the user does not exist, check if signups are enabled if auth_config.ENABLE_OAUTH_SIGNUP: From ba0c4b3932ea2eafd968b436fdd69e73e6d19ae3 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:46:54 +0200 Subject: [PATCH 135/141] fix: don't hold a database connection for the lifetime of an SSE stream (#28183) With database session sharing enabled, which the docs recommend for PostgreSQL and for multi-replica deployments, the knowledge pending-files and file process-status endpoints each pinned one pooled connection for as long as their SSE stream stayed open, up to one and two hours respectively. A file wedged in processing keeps a stream open for the full duration, so a handful of users sitting on that page can consume every connection in the pool, and the held transactions sit idle and block autovacuum on those tables. Both handlers took a request-scoped session for their access checks, and FastAPI only releases a yield dependency once the response body has finished streaming, so the session outlived the handler by the whole life of the stream. Neither generator ever used it. They no longer take that dependency, and the queries they run already open their own short-lived sessions when none is passed. This is the approach the chat completion endpoints already use for the same long-response problem. Measured against a pool with capacity 11: before, at most 11 concurrent streams could ever be open and every further attempt failed, deterministically across repeat runs. After, 25 of 25 opened. Non-stream latency is unchanged, within run-to-run noise, and behaviour is identical whether session sharing is on or off. --- backend/open_webui/routers/files.py | 14 +++++++------- backend/open_webui/routers/knowledge.py | 10 ++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 811c223aaa..b349eeacd5 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -613,9 +613,12 @@ async def get_file_process_status( id: str, stream: bool = Query(False), user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): - file = await Files.get_file_by_id(id, db=db) + # NOTE: We intentionally do NOT use Depends(get_async_session) here. + # Database operations manage their own short-lived sessions internally. + # Holding a session here would keep a connection for the entire stream + # (up to two hours) and exhaust the connection pool under concurrent load. + file = await Files.get_file_by_id(id) if not file: raise HTTPException( @@ -623,16 +626,13 @@ async def get_file_process_status( detail=ERROR_MESSAGES.NOT_FOUND, ) - if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db): + if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user): if stream: MAX_FILE_PROCESSING_DURATION = 3600 * 2 async def event_stream(file_id): - # NOTE: We intentionally do NOT capture the request's db session here. - # Each poll creates its own short-lived session to avoid holding a - # connection for hours. A WebSocket push would be more efficient. for _ in range(MAX_FILE_PROCESSING_DURATION): - file_item = await Files.get_file_by_id(file_id) # Creates own session + file_item = await Files.get_file_by_id(file_id) if file_item: data = file_item.model_dump().get('data', {}) status = data.get('status') diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 8ff3297681..d8f64d2181 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -1248,7 +1248,6 @@ async def get_pending_knowledge_files( id: str, stream: bool = Query(False), user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): """Return files that are being processed for this knowledge base but not yet linked. @@ -1261,7 +1260,11 @@ async def get_pending_knowledge_files( When ``stream=true``, returns an SSE stream that polls every 3 seconds and emits the current pending file list. Closes when no files remain. """ - knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db) + # NOTE: We intentionally do NOT use Depends(get_async_session) here. + # Database operations manage their own short-lived sessions internally. + # Holding a session here would keep a connection for the entire stream + # (up to an hour) and exhaust the connection pool under concurrent load. + knowledge = await Knowledges.get_knowledge_by_id(id=id) if not knowledge: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1276,7 +1279,6 @@ async def get_pending_knowledge_files( resource_type='knowledge', resource_id=knowledge.id, permission='read', - db=db, ) ): raise HTTPException( @@ -1285,7 +1287,7 @@ async def get_pending_knowledge_files( ) if not stream: - return await Files.get_pending_files_for_knowledge(id, db=db) + return await Files.get_pending_files_for_knowledge(id) async def event_stream(knowledge_id: str): MAX_POLL_DURATION = 3600 # 1 hour max From 4ec6ee14418edd04eaba9e34bd5868453f61df40 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:47:32 -0700 Subject: [PATCH 136/141] refac Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com> --- backend/open_webui/config.py | 1 + backend/open_webui/main.py | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 262e7c0818..75b4b6d284 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2097,6 +2097,7 @@ ENABLE_USER_WEBHOOKS = os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'tr # FastAPI / AnyIO settings THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None) +THREAD_POOL_THREAD_NAME_PREFIX = os.getenv('THREAD_POOL_THREAD_NAME_PREFIX', '') if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str): try: diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 9c5f538562..6cbf0155cb 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -7,6 +7,7 @@ import mimetypes import os import sys import time +from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from uuid import uuid4 @@ -64,6 +65,7 @@ from open_webui.config import ( ONEDRIVE_SHAREPOINT_URL, STATIC_DIR, THREAD_POOL_SIZE, + THREAD_POOL_THREAD_NAME_PREFIX, WEBUI_AUTH, WEBUI_NAME, async_reset_config, @@ -343,6 +345,16 @@ async def lifespan(app: FastAPI): # This allows sync functions to schedule work on the main loop without blocking health checks app.state.main_loop = asyncio.get_running_loop() + if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: + # asyncio offloads bypass AnyIO's limiter, so configure both before the first offload. + anyio.to_thread.current_default_thread_limiter().total_tokens = THREAD_POOL_SIZE + app.state.main_loop.set_default_executor( + ThreadPoolExecutor( + max_workers=THREAD_POOL_SIZE, + thread_name_prefix=THREAD_POOL_THREAD_NAME_PREFIX, + ) + ) + app.state.instance_id = INSTANCE_ID start_logger() @@ -378,10 +390,6 @@ async def lifespan(app: FastAPI): if app.state.redis is not None: app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app)) - if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: - limiter = anyio.to_thread.current_default_thread_limiter() - limiter.total_tokens = THREAD_POOL_SIZE - app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup()) app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup()) From 6e468c5b9539d3b6057bab63d81e8d2974acff7b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:50:54 -0700 Subject: [PATCH 137/141] refac --- src/lib/components/chat/Overview/Node.svelte | 12 ++++++++++-- src/lib/components/chat/Overview/View.svelte | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/lib/components/chat/Overview/Node.svelte b/src/lib/components/chat/Overview/Node.svelte index ecc20a32da..49725a84df 100644 --- a/src/lib/components/chat/Overview/Node.svelte +++ b/src/lib/components/chat/Overview/Node.svelte @@ -89,6 +89,14 @@
    {/if} - - + +
    diff --git a/src/lib/components/chat/Overview/View.svelte b/src/lib/components/chat/Overview/View.svelte index edd0f48571..3559154238 100644 --- a/src/lib/components/chat/Overview/View.svelte +++ b/src/lib/components/chat/Overview/View.svelte @@ -105,7 +105,8 @@ data: { user: chatUser ?? $user, message: history.messages[id], - model: $models.find((model) => model.id === history.messages[id].model) + model: $models.find((model) => model.id === history.messages[id].model), + direction }, position: { x, y } }); From 87d9b7e84e71b097eadf1df4f9852359104f17ed Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:51:04 -0700 Subject: [PATCH 138/141] refac --- backend/open_webui/routers/retrieval.py | 39 ++++++++----------- src/lib/apis/retrieval/index.ts | 11 ++++-- .../admin/Settings/Documents.svelte | 39 +++++++++++-------- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index d81e1e4e2c..9023c8b634 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -490,19 +490,19 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)): class OpenAIConfigForm(BaseModel): - url: str - key: str + url: str | None = None + key: str | None = None class OllamaConfigForm(BaseModel): - url: str - key: str + url: str | None = None + key: str | None = None class AzureOpenAIConfigForm(BaseModel): - url: str - key: str - version: str + url: str | None = None + key: str | None = None + version: str | None = None class EmbeddingModelUpdateForm(BaseModel): @@ -544,23 +544,18 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS - if config.RAG_EMBEDDING_ENGINE in [ - 'ollama', - 'openai', - 'azure_openai', - ]: - if form_data.openai_config is not None: - config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url - config.RAG_OPENAI_API_KEY = form_data.openai_config.key + if config.RAG_EMBEDDING_ENGINE == 'openai' and form_data.openai_config is not None: + config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url or '' + config.RAG_OPENAI_API_KEY = form_data.openai_config.key or '' - if form_data.ollama_config is not None: - config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url - config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key + if config.RAG_EMBEDDING_ENGINE == 'ollama' and form_data.ollama_config is not None: + config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url or '' + config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key or '' - if form_data.azure_openai_config is not None: - config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url - config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key - config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version + if config.RAG_EMBEDDING_ENGINE == 'azure_openai' and form_data.azure_openai_config is not None: + config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url or '' + config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key or '' + config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version or '' request.app.state.ef = get_ef( config.RAG_EMBEDDING_ENGINE, diff --git a/src/lib/apis/retrieval/index.ts b/src/lib/apis/retrieval/index.ts index 99801dfedb..f913e3e868 100644 --- a/src/lib/apis/retrieval/index.ts +++ b/src/lib/apis/retrieval/index.ts @@ -188,6 +188,8 @@ type OpenAIConfigForm = { url: string; }; +type OllamaConfigForm = OpenAIConfigForm; + type AzureOpenAIConfigForm = { key: string; url: string; @@ -196,10 +198,13 @@ type AzureOpenAIConfigForm = { type EmbeddingModelUpdateForm = { openai_config?: OpenAIConfigForm; + ollama_config?: OllamaConfigForm; azure_openai_config?: AzureOpenAIConfigForm; - embedding_engine: string; - embedding_model: string; - embedding_batch_size?: number; + RAG_EMBEDDING_ENGINE: string; + RAG_EMBEDDING_MODEL: string; + RAG_EMBEDDING_BATCH_SIZE?: number; + ENABLE_ASYNC_EMBEDDING?: boolean; + RAG_EMBEDDING_CONCURRENT_REQUESTS?: number; }; export const updateEmbeddingConfig = async (token: string, payload: EmbeddingModelUpdateForm) => { diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index ba4b112a64..8425f338ea 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -122,26 +122,33 @@ }); updateEmbeddingModelLoading = true; - const res = await updateEmbeddingConfig(localStorage.token, { + const payload: Parameters[1] = { RAG_EMBEDDING_ENGINE: RAG_EMBEDDING_ENGINE, RAG_EMBEDDING_MODEL: RAG_EMBEDDING_MODEL, RAG_EMBEDDING_BATCH_SIZE: RAG_EMBEDDING_BATCH_SIZE, ENABLE_ASYNC_EMBEDDING: ENABLE_ASYNC_EMBEDDING, - RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS, - ollama_config: { + RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS + }; + + if (RAG_EMBEDDING_ENGINE === 'ollama') { + payload.ollama_config = { key: OllamaKey, url: OllamaUrl - }, - openai_config: { + }; + } else if (RAG_EMBEDDING_ENGINE === 'openai') { + payload.openai_config = { key: OpenAIKey, url: OpenAIUrl - }, - azure_openai_config: { + }; + } else if (RAG_EMBEDDING_ENGINE === 'azure_openai') { + payload.azure_openai_config = { key: AzureOpenAIKey, url: AzureOpenAIUrl, version: AzureOpenAIVersion - } - }).catch(async (error) => { + }; + } + + const res = await updateEmbeddingConfig(localStorage.token, payload).catch(async (error) => { toast.error(`${error}`); await setEmbeddingConfig(); return null; @@ -300,15 +307,15 @@ ENABLE_ASYNC_EMBEDDING = embeddingConfig.ENABLE_ASYNC_EMBEDDING ?? true; RAG_EMBEDDING_CONCURRENT_REQUESTS = embeddingConfig.RAG_EMBEDDING_CONCURRENT_REQUESTS ?? 0; - OpenAIKey = embeddingConfig.openai_config.key; - OpenAIUrl = embeddingConfig.openai_config.url; + OpenAIKey = embeddingConfig.openai_config.key ?? ''; + OpenAIUrl = embeddingConfig.openai_config.url ?? ''; - OllamaKey = embeddingConfig.ollama_config.key; - OllamaUrl = embeddingConfig.ollama_config.url; + OllamaKey = embeddingConfig.ollama_config.key ?? ''; + OllamaUrl = embeddingConfig.ollama_config.url ?? ''; - AzureOpenAIKey = embeddingConfig.azure_openai_config.key; - AzureOpenAIUrl = embeddingConfig.azure_openai_config.url; - AzureOpenAIVersion = embeddingConfig.azure_openai_config.version; + AzureOpenAIKey = embeddingConfig.azure_openai_config.key ?? ''; + AzureOpenAIUrl = embeddingConfig.azure_openai_config.url ?? ''; + AzureOpenAIVersion = embeddingConfig.azure_openai_config.version ?? ''; } }; onMount(async () => { From 017075a2d7a725e8fe09d2c0e40876015ffb13d5 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:53:00 +0200 Subject: [PATCH 139/141] perf: drop unused database session dependencies from seven endpoints (#28178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven route handlers declare a request-scoped database session as a FastAPI dependency and then never touch it. Three of them are `GET /api/v1/users/user/settings`, `/user/status` and `/user/info`, which the frontend hits on every page load, and all three carry a comment saying the user object is already available, so the parameter is leftover from the refactor that removed the refetch. The other four are admin-only external-knowledge connection endpoints that read their data from the config store. Measured on a route with and without the dependency, 20k requests, best of 5: | | µs per request | | --- | --- | | no dependency | 16.18 | | unused session dependency | 62.85 | The dependency costs about three times as much as everything else the request does put together. It is worth being precise about why, because the obvious guess is wrong: this is not database I/O and not connection pool pressure. SQLAlchemy connects lazily, so a session that is never used checks out zero connections, verified by watching the pool's counter stay at zero across the request. The cost is FastAPI resolving an extra async-generator dependency onto the request's exit stack, plus constructing and closing the session object. Deleting the seven parameters is the whole change. An AST scan over the backend finds exactly these seven handlers before and none after. --- backend/open_webui/routers/knowledge.py | 8 +------- backend/open_webui/routers/users.py | 4 +--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index d8f64d2181..6c1fcf9fb8 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -645,10 +645,7 @@ async def _count_external_connection_mappings(connection_id: str, db: Optional[A @router.get('/external/connections', response_model=ExternalKnowledgeConnectionListResponse) -async def get_external_knowledge_connections( - user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), -): +async def get_external_knowledge_connections(user=Depends(get_admin_user)): connections = [_sanitize_external_connection(connection) for connection in await _get_external_connections()] return ExternalKnowledgeConnectionListResponse(items=connections, total=len(connections)) @@ -678,7 +675,6 @@ async def create_external_knowledge_connection( async def get_external_knowledge_connection( id: str, user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), ): connection = await _get_external_connection(id) if not connection: @@ -745,7 +741,6 @@ async def delete_external_knowledge_connection( async def test_external_knowledge_connection( id: str, user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), ): connection = await _get_external_connection(id) if not connection: @@ -843,7 +838,6 @@ async def test_external_knowledge_retrieval( id: str, form_data: ExternalKnowledgeRetrieveTestForm, user=Depends(get_admin_user), - db: AsyncSession = Depends(get_async_session), ): connection = await _get_external_connection(id) if not connection: diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 55a2a57a76..24c2433687 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -471,7 +471,6 @@ async def get_default_user_permissions_defaults(user=Depends(get_admin_user)): async def get_user_settings_by_session_user( raw: bool = False, user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): # user already fetched by get_verified_user — no need to refetch if raw: @@ -570,7 +569,6 @@ async def update_user_settings_by_session_user( async def get_user_status_by_session_user( request: Request, user=Depends(get_verified_user), - db: AsyncSession = Depends(get_async_session), ): if not await Config.get('users.enable_status'): raise HTTPException( @@ -620,7 +618,7 @@ async def update_user_status_by_session_user( @router.get('/user/info', response_model=dict | None) -async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)): +async def get_user_info_by_session_user(user=Depends(get_verified_user)): # user already fetched by get_verified_user — no need to refetch return user.info From 0b27fa5e873c9f3cff7b14e4eeab8321bdd8970c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:57:57 -0700 Subject: [PATCH 140/141] refac --- .../open_webui/utils/context_compaction.py | 57 +++++++------------ backend/open_webui/utils/response.py | 10 +--- 2 files changed, 22 insertions(+), 45 deletions(-) diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index ff84481a5e..1b0470dc17 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -235,6 +235,23 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d return min(configured_threshold or global_threshold, global_cap) +def _usage_token_count(usage: dict) -> int: + prompt_tokens = int(usage.get('prompt_tokens') or usage.get('prompt_eval_count') or 0) + if not prompt_tokens and (usage.get('prompt_n') is not None or usage.get('cache_n') is not None): + prompt_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0) + if not prompt_tokens: + prompt_tokens = int(usage.get('input_tokens') or 0) + + completion_tokens = int( + usage.get('completion_tokens') + or usage.get('output_tokens') + or usage.get('eval_count') + or usage.get('predicted_n') + or 0 + ) + return prompt_tokens + completion_tokens + + async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None: chat_data = chat.chat or {} history = chat_data.get('history') or {} @@ -263,25 +280,7 @@ async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict for idx in range(len(messages) - 1, -1, -1): usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') - if isinstance(usage, dict) and ( - tokens := ( - int( - usage.get('prompt_tokens') - or usage.get('input_tokens') - or usage.get('prompt_eval_count') - or usage.get('prompt_n') - or 0 - ) - + int( - usage.get('completion_tokens') - or usage.get('output_tokens') - or usage.get('eval_count') - or usage.get('predicted_n') - or 0 - ) - + int(usage.get('cache_n') or 0) - ) - ): + if isinstance(usage, dict) and (tokens := _usage_token_count(usage)): tokens += _estimate_messages_tokens(messages[idx + 1 :]) return _build_context_usage(tokens, threshold) @@ -320,25 +319,7 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary: for idx in range(len(messages) - 1, -1, -1): usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage') - if isinstance(usage, dict) and ( - tokens := ( - int( - usage.get('prompt_tokens') - or usage.get('input_tokens') - or usage.get('prompt_eval_count') - or usage.get('prompt_n') - or 0 - ) - + int( - usage.get('completion_tokens') - or usage.get('output_tokens') - or usage.get('eval_count') - or usage.get('predicted_n') - or 0 - ) - + int(usage.get('cache_n') or 0) - ) - ): + if isinstance(usage, dict) and (tokens := _usage_token_count(usage)): return tokens + _estimate_messages_tokens(messages[idx + 1 :]) > threshold estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages) diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index ef229b1729..3f34986903 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -24,13 +24,9 @@ def normalize_usage(usage: dict) -> dict: return {} # Map various field names to standard names - input_tokens = ( - usage.get('input_tokens') # Already standard - or usage.get('prompt_tokens') # OpenAI - or usage.get('prompt_eval_count') # Ollama - or usage.get('prompt_n') # llama.cpp - or 0 - ) + input_tokens = usage.get('input_tokens') or usage.get('prompt_tokens') or usage.get('prompt_eval_count') + if input_tokens is None: + input_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0) output_tokens = ( usage.get('output_tokens') # Already standard From b1dc945bd6cf97a7067e603fc1b797301e626fb6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 17 Aug 2026 00:59:13 -0700 Subject: [PATCH 141/141] refac --- .../Markdown/MarkdownInlineTokens/CodespanToken.svelte | 6 +++++- .../Messages/Markdown/MarkdownInlineTokens/TextToken.svelte | 4 +++- src/lib/components/chat/Messages/ResponseMessage.svelte | 4 +--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte index 48144f9a0d..deb0eee54b 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte @@ -4,6 +4,8 @@ import { getContext } from 'svelte'; + import { settings } from '$lib/stores'; + const i18n = getContext('i18n'); export let token; @@ -13,7 +15,9 @@ { copyToClipboard(unescapeHtml(token.text)); toast.success($i18n.t('Copied to clipboard')); diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte index b057133774..38e5dd144b 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte @@ -1,11 +1,13 @@ -{#if done} +{#if done || !($settings?.chatFadeStreamingText ?? true)} {raw} {:else} {#each raw.split(' ') as text} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 2e9488ae6c..1a9719bf45 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -833,9 +833,7 @@ {compactPreview} {editCodeBlock} {topPadding} - done={($settings?.chatFadeStreamingText ?? true) - ? (message?.done ?? false) - : true} + done={message?.done ?? false} {model} onTaskClick={async (e) => { console.log(e);