From 079fb19e75f8be4b44fada0c755cf383b21a625b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 16 Oct 2026 18:58:58 +0400 Subject: [PATCH 001/385] refac --- backend/open_webui/config.py | 3 +++ backend/open_webui/routers/auths.py | 4 ++++ backend/open_webui/utils/middleware.py | 4 +++- src/lib/components/admin/Settings/General.svelte | 7 +++++++ src/lib/components/chat/Chat.svelte | 2 +- src/lib/i18n/locales/en-US/translation.json | 2 ++ 6 files changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 832f1b1ce7..8a67f84539 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2058,6 +2058,8 @@ ENABLE_FOLDERS = os.getenv('ENABLE_FOLDERS', 'True').lower() == 'true' FOLDER_MAX_FILE_COUNT = os.getenv('FOLDER_MAX_FILE_COUNT', '') +ENABLE_SKILL_LAZY_LOADING = os.getenv('ENABLE_SKILL_LAZY_LOADING', 'False').lower() == 'true' + SKILL_CONTENT_MAX_CHARS = int(os.environ['SKILL_CONTENT_MAX_CHARS']) if os.getenv('SKILL_CONTENT_MAX_CHARS') else None if SKILL_CONTENT_MAX_CHARS is not None and SKILL_CONTENT_MAX_CHARS <= 0: raise ValueError('SKILL_CONTENT_MAX_CHARS must be a positive integer or unset') @@ -2900,6 +2902,7 @@ ENABLE_LDAP_GROUP_CREATION = os.getenv('ENABLE_LDAP_GROUP_CREATION', 'False').lo LDAP_ATTRIBUTE_FOR_GROUPS = os.getenv('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf') DEFAULT_CONFIG = { + 'skills.lazy_loading.enable': ENABLE_SKILL_LAZY_LOADING, 'skills.content_max_chars': SKILL_CONTENT_MAX_CHARS, 'skills.preview_max_chars': SKILL_PREVIEW_MAX_CHARS, 'direct.enable': ENABLE_DIRECT_CONNECTIONS, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 701acfb360..5c9ea63455 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -134,6 +134,7 @@ ADMIN_CONFIG_KEYS = { 'ENABLE_CALENDAR': 'calendar.enable', 'ENABLE_MEMORIES': 'memories.enable', 'ENABLE_MEMORY_SYSTEM_CONTEXT': 'memories.system_context.enable', + 'ENABLE_SKILL_LAZY_LOADING': 'skills.lazy_loading.enable', 'SKILL_CONTENT_MAX_CHARS': 'skills.content_max_chars', 'SKILL_PREVIEW_MAX_CHARS': 'skills.preview_max_chars', 'ENABLE_NOTES': 'notes.enable', @@ -1187,6 +1188,7 @@ class AdminConfig(MfaConfigForm): ENABLE_CALENDAR: bool ENABLE_MEMORIES: bool ENABLE_MEMORY_SYSTEM_CONTEXT: bool + ENABLE_SKILL_LAZY_LOADING: bool = False SKILL_CONTENT_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True) SKILL_PREVIEW_MAX_CHARS: int | None = Field(default=None, gt=0, strict=True) ENABLE_NOTES: bool @@ -1237,6 +1239,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep updates.pop('apps.enable', None) if 'I18N' not in form_data.model_fields_set: updates.pop('ui.i18n', None) + if 'ENABLE_SKILL_LAZY_LOADING' not in form_data.model_fields_set: + updates.pop('skills.lazy_loading.enable', None) if 'SKILL_CONTENT_MAX_CHARS' not in form_data.model_fields_set: updates.pop('skills.content_max_chars', None) if 'SKILL_PREVIEW_MAX_CHARS' not in form_data.model_fields_set: diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 9d88eb9a92..7f8436e1cf 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2453,7 +2453,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Otherwise, save any tools that filter inlets added for merging later. inlet_filter_tools = None if payload_tools is not None else form_data.get('tools', None) - # Selected and mentioned skills get bounded content; other skills remain available through view_skill. + # Mentions always load instructions; selected skills can optionally load through view_skill. chat_context = metadata.get('chat_context') or {} metadata['chat_context'] = chat_context skill_versions = chat_context.setdefault('skill_versions', {}) @@ -2462,6 +2462,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) activated_skill_ids = set(form_data.pop('skill_ids', None) or []) | mentioned_skill_ids skill_ids = sorted(activated_skill_ids | set(model.get('info', {}).get('meta', {}).get('skillIds', []))) + if payload_tools is None and await Config.get('skills.lazy_loading.enable', False): + activated_skill_ids = mentioned_skill_ids available_skills = [] terminal_skills = [] if is_note_chat: diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index db9c9ea478..6207fd5b4b 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -339,6 +339,13 @@ /> {/if} + + + mentions both load instructions. + // The backend applies the skill loading setting to menu-selected skills. // Keep mentions in the message so the backend can resolve them. const skillIds = [...selectedSkillIds]; diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 2c4d70a9ac..7fb24dd957 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -2828,6 +2828,8 @@ "settings.admin.general.seeWhatSNew.label": "See what's new", "settings.admin.general.skillContentMaxChars.description": "Maximum characters per skill read. Longer instructions open as a preview. Leave empty to use the default.", "settings.admin.general.skillContentMaxChars.label": "Skill Read Limit", + "settings.admin.general.skillLazyLoading.description": "Load selected and model-attached skills when needed instead of including instructions upfront. Explicit skill mentions still load immediately. Requires built-in skill tools; otherwise instructions load upfront.", + "settings.admin.general.skillLazyLoading.label": "Load selected skills on demand", "settings.admin.general.skillPreviewMaxChars.description": "Characters shown when instructions exceed the read limit. Cannot exceed that limit. Leave empty to use the default.", "settings.admin.general.skillPreviewMaxChars.label": "Skill Preview Length", "settings.admin.general.title": "General", From 32170b96fc5edc8c324c5de188be36e9d32059a2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 19 Oct 2026 03:08:52 +0900 Subject: [PATCH 002/385] refac --- .../components/common/DiffEditorModal.svelte | 43 ++------ .../components/common/diffEditorAlignment.ts | 103 +++++++----------- 2 files changed, 47 insertions(+), 99 deletions(-) diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index 8ba1f7c4ba..554965ae63 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -27,7 +27,7 @@ lineAlignment, alignChangedLines, clearLineAlignment, - pairedLinePosition + alignmentGeometryChanged } from './diffEditorAlignment'; import { diffPresentation, @@ -371,7 +371,7 @@ }; updatePresentation = schedulePresentation; let alignmentDocuments: readonly [Text, Text] | null = null; - let alignmentGeometry = ''; + let alignmentGeometry: readonly number[] | null = null; const themes = [new Compartment(), new Compartment()]; const language = [new Compartment(), new Compartment()]; const paths = ['a', 'b'].map((side) => paneFile(side)?.path ?? session?.path); @@ -423,47 +423,18 @@ view.b.contentDOM.clientWidth, view.a.defaultLineHeight, view.b.defaultLineHeight - ].join(':'); + ]; + const resized = + alignmentGeometry && alignmentGeometryChanged(alignmentGeometry, geometry); const reset = alignmentDocuments && (alignmentDocuments[0] !== documents[0] || alignmentDocuments[1] !== documents[1] || - geometry !== alignmentGeometry); - const resized = alignmentDocuments && geometry !== alignmentGeometry; + resized); alignmentDocuments = documents; - alignmentGeometry = geometry; - const previousSpacersA = view.a.state.field(lineAlignment); - const previousSpacersB = view.b.state.field(lineAlignment); + if (!alignmentGeometry || resized) alignmentGeometry = geometry; if (!reset || !clearLineAlignment(view.a, view.b)) alignChangedLines(view.a, view.b, view.chunks); - if (resized) { - // Reflow can leave one viewport entirely inside an old EOF spacer. - // Measure both panes around the row the reader was looking at. - const top = Math.max(0, node.getBoundingClientRect().top - view.a.documentTop); - const position = Math.min( - view.a.lineBlockAtHeight(top).from, - view.a.state.doc.length - ); - const other = pairedLinePosition( - view.a.state.doc, - view.b.state.doc, - view.chunks, - position - ); - view.a.dispatch({ - effects: EditorView.scrollIntoView(position, { y: 'start', yMargin: 0 }) - }); - view.b.dispatch({ - effects: EditorView.scrollIntoView(other, { y: 'start', yMargin: 0 }) - }); - } - // Remeasuring unchanged spacers feeds CodeMirror's own layout updates back into itself. - if ( - resized || - previousSpacersA !== view.a.state.field(lineAlignment) || - previousSpacersB !== view.b.state.field(lineAlignment) - ) - view.reconfigure({}); } }); } diff --git a/src/lib/components/common/diffEditorAlignment.ts b/src/lib/components/common/diffEditorAlignment.ts index 0e4d2f1e9a..7e5269dca2 100644 --- a/src/lib/components/common/diffEditorAlignment.ts +++ b/src/lib/components/common/diffEditorAlignment.ts @@ -1,4 +1,4 @@ -import { StateEffect, StateField, type Text } from '@codemirror/state'; +import { StateEffect, StateField } from '@codemirror/state'; import { BlockType, Decoration, @@ -9,21 +9,9 @@ import { import type { Chunk } from '@codemirror/merge'; import { changedLinePairs } from './diffEditor'; -export function pairedLinePosition(a: Text, b: Text, chunks: readonly Chunk[], position: number) { - let offset = 0; - for (const chunk of chunks) { - if (position < chunk.fromA) break; - if (position < chunk.toA) { - const pairs = changedLinePairs(a, b, chunk); - const line = a.lineAt(position).from; - return ( - (pairs.find(([from]) => from >= line) ?? pairs.at(-1))?.[1] ?? - Math.min(chunk.fromB, b.length) - ); - } - offset = chunk.toB - chunk.toA; - } - return Math.max(0, Math.min(position + offset, b.length)); +export function alignmentGeometryChanged(previous: readonly number[], next: readonly number[]) { + // DOM line-height measurements can vary by a fraction of a pixel while scrolling. + return next.some((value, index) => Math.abs(value - previous[index]) > (index < 2 ? 0 : 0.5)); } class RowSpacer extends WidgetType { @@ -64,8 +52,8 @@ export function clearLineAlignment(a: EditorView, b: EditorView) { return changed; } -// Only remeasure paired rows present in both virtualized viewports. Recomputing -// offscreen spacers from estimated heights would move already aligned content. +// MergeView owns alignment between chunks. Only align rows inside each changed +// chunk, using local heights so its spacers cannot feed back into ours. export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly Chunk[]) { const previous = (view: EditorView) => { const result = new Map(); @@ -77,8 +65,6 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly oldB = previous(b); const nextA = new Map(oldA), nextB = new Map(oldB); - let offsetA = 0, - offsetB = 0; const textTop = (view: EditorView, pos: number) => { const block = view.lineBlockAt(pos); const text = Array.isArray(block.type) @@ -88,52 +74,43 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly : null; return text?.from === pos ? text.top : null; }; - const align = (posA: number, posB: number) => { + for (const chunk of chunks) { if ( - posA < a.viewport.from || - posB < b.viewport.from || - posA > a.viewport.to || - posB > b.viewport.to + chunk.toA < a.viewport.from || + chunk.toB < b.viewport.from || + chunk.fromA > a.viewport.to || + chunk.fromB > b.viewport.to ) - return; - const topA = textTop(a, posA), - topB = textTop(b, posB); - if (topA === null || topB === null) return; - const previousA = oldA.get(posA) ?? 0, - previousB = oldB.get(posB) ?? 0; - const difference = topA - previousA + offsetA - topB + previousB - offsetB; - const sizeA = difference < -1 ? -difference : 0, - sizeB = difference > 1 ? difference : 0; - nextA.delete(posA); - nextB.delete(posB); - if (sizeA) nextA.set(posA, sizeA); - if (sizeB) nextB.set(posB, sizeB); - offsetA += sizeA - previousA; - offsetB += sizeB - previousB; - }; - let fromA = 0, - fromB = 0; - for (let index = 0; index <= chunks.length; index++) { - const chunk = chunks[index]; - const endA = chunk ? chunk.fromA : a.state.doc.length + 1; - let posA = Math.max( - fromA, - a.state.doc.lineAt( - Math.min( - a.state.doc.length, - Math.max(fromA, a.viewport.from, fromA + b.viewport.from - fromB) - ) - ).from - ); - for (; posA < endA && posA <= a.viewport.to; posA = a.state.doc.lineAt(posA).to + 1) { - const posB = fromB + posA - fromA; - if (posB > b.viewport.to) break; - align(posA, posB); + continue; + let previousA = chunk.fromA, + previousB = chunk.fromB, + first = true; + for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) { + // Both ends of the interval must be rendered; offscreen estimates are not row heights. + if ( + previousA >= a.viewport.from && + previousB >= b.viewport.from && + posA <= a.viewport.to && + posB <= b.viewport.to + ) { + const topA = textTop(a, posA), + topB = textTop(b, posB); + const startA = first ? a.lineBlockAt(previousA).top : textTop(a, previousA); + const startB = first ? b.lineBlockAt(previousB).top : textTop(b, previousB); + if (topA !== null && topB !== null && startA !== null && startB !== null) { + const heightA = topA - startA - (oldA.get(posA) ?? 0); + const heightB = topB - startB - (oldB.get(posB) ?? 0); + const difference = heightA - heightB; + nextA.delete(posA); + nextB.delete(posB); + if (difference < -1) nextA.set(posA, -difference); + if (difference > 1) nextB.set(posB, difference); + } + } + previousA = posA; + previousB = posB; + first = false; } - if (!chunk) break; - for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) align(posA, posB); - fromA = chunk.toA; - fromB = chunk.toB; } const apply = (view: EditorView, old: Map, next: Map) => { if ( From ffbe0ac12da8ca5af9da12f8f00c43e382300b4f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 19 Oct 2026 03:16:41 +0900 Subject: [PATCH 003/385] refac --- src/routes/auth/+page.svelte | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/routes/auth/+page.svelte b/src/routes/auth/+page.svelte index 3e71338a2a..a190a72154 100644 --- a/src/routes/auth/+page.svelte +++ b/src/routes/auth/+page.svelte @@ -249,17 +249,17 @@ }} /> -
+
{#if loaded}
-
+
{#if mfaChallenge}
{:else}
-
+
{#if $config?.metadata?.auth_logo_position === 'center'}
{#if inlineHtml !== null} {@html inlineHtml} -{/if} -{#each inlineHtml === null ? tokens : [] as token, tokenIdx (tokenIdx)} +{/if}{#each inlineHtml === null ? tokens : [] as token, tokenIdx (tokenIdx)} {#if token.type === 'escape'} {unescapeHtml(token.text)} {:else if token.type === 'html'} From c4153a6eeaeb6a0c5ec8cc152a1bdf328b848859 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:53:38 +0200 Subject: [PATCH 118/385] fix: shared chat pages keep reloading and jump back to the bottom (#32725) Since 0.12.3, any chat opened under /s/, whether through a share link or by an admin viewing another user's chat, was fetched again and again from the moment it opened, and each fetch scrolled the page to the bottom. Scrolling up was impossible and older messages never loaded. The chat is now loaded once when the page opens. Fixes #32657 --- src/routes/s/[id]/+page.svelte | 37 ++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/routes/s/[id]/+page.svelte b/src/routes/s/[id]/+page.svelte index 032c78e728..40f7c4ea86 100644 --- a/src/routes/s/[id]/+page.svelte +++ b/src/routes/s/[id]/+page.svelte @@ -48,30 +48,33 @@ $sessionUser && ($sessionUser.role === 'admin' || ($sessionUser.permissions?.chat?.import ?? true)); + // Reading history here would rerun this on every history change. $: if ($page.params.id) { - (async () => { - if (await loadSharedChat()) { - loaded = true; - await tick(); - const target = $page.url.searchParams.get('message'); - if (target && history.messages[target]) { - autoScroll = false; - history.currentId = getDeepestChildId(history, target); - await tick(); - await messagesComponent?.scrollToMessage(target); - } else messagesComponent?.scrollToBottom(); - } else if (localStorage.token) { - await goto('/'); - } else { - await goto(`/auth?redirect=${encodeURIComponent($page.url.pathname)}`); - } - })(); + loadAndScroll(); } ////////////////////////// // Web functions ////////////////////////// + const loadAndScroll = async () => { + if (await loadSharedChat()) { + loaded = true; + await tick(); + const target = $page.url.searchParams.get('message'); + if (target && history.messages[target]) { + autoScroll = false; + history.currentId = getDeepestChildId(history, target); + await tick(); + await messagesComponent?.scrollToMessage(target); + } else messagesComponent?.scrollToBottom(); + } else if (localStorage.token) { + await goto('/'); + } else { + await goto(`/auth?redirect=${encodeURIComponent($page.url.pathname)}`); + } + }; + const loadSharedChat = async () => { const token = localStorage.token ?? ''; const shareId = $page.params.id; From f36490bf2f7a9bdb795f8a7c4c9ed8585dae64f8 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:51:21 -0400 Subject: [PATCH 119/385] fix: tooltips show on keyboard focus and Escape hides them (#32644) --- src/lib/components/common/Tooltip.svelte | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/lib/components/common/Tooltip.svelte b/src/lib/components/common/Tooltip.svelte index 011714d891..e5a8dfaad7 100644 --- a/src/lib/components/common/Tooltip.svelte +++ b/src/lib/components/common/Tooltip.svelte @@ -58,10 +58,29 @@ placement, allowHTML, touch, + trigger: 'mouseenter focusin', ...(theme !== '' ? { theme } : { theme: 'dark' }), arrow: false, offset, ...(interactive ? { interactive: true } : {}), + plugins: [ + { + fn: (instance) => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + instance.hide(); + } + }; + + return { + onShow: () => document.addEventListener('keydown', onKeyDown), + onHide: () => document.removeEventListener('keydown', onKeyDown), + onDestroy: () => document.removeEventListener('keydown', onKeyDown) + }; + } + } + ], ...tippyOptions }); } From 5f6399eb6b63be155febe4ffa0970e5dd4f4e67f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 08:23:20 +0900 Subject: [PATCH 120/385] refac --- src/routes/(app)/+layout.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e11bafb87a..68073c11a0 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -490,7 +490,8 @@ {#if loaded} -
+
+
{:else} From 02974f41e9b7c9d34f2cd7c664c2a04fd6ac12ae Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 08:32:08 +0900 Subject: [PATCH 121/385] refac --- src/lib/components/workspace/Knowledge.svelte | 13 +++++-------- src/lib/components/workspace/Models.svelte | 13 +++++-------- src/lib/components/workspace/Prompts.svelte | 13 +++++-------- src/lib/components/workspace/Skills.svelte | 13 +++++-------- src/lib/components/workspace/Tools.svelte | 13 +++++-------- 5 files changed, 25 insertions(+), 40 deletions(-) diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte index 89fe155938..66c51b7813 100644 --- a/src/lib/components/workspace/Knowledge.svelte +++ b/src/lib/components/workspace/Knowledge.svelte @@ -32,6 +32,7 @@ import Search from '../icons/Search.svelte'; import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import XMark from '../icons/XMark.svelte'; import ViewSelector from './common/ViewSelector.svelte'; import TagSelector from './common/TagSelector.svelte'; @@ -512,17 +513,13 @@ {#if item?.write_access || $user?.role === 'admin'} diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte index a6446ae3e8..1b600559f7 100644 --- a/src/lib/components/workspace/Models.svelte +++ b/src/lib/components/workspace/Models.svelte @@ -54,6 +54,7 @@ import ModelMenu from './Models/ModelMenu.svelte'; import ModelDeleteConfirmDialog from '../common/ConfirmDialog.svelte'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import GarbageBin from '../icons/GarbageBin.svelte'; import Search from '../icons/Search.svelte'; import Switch from '../common/Switch.svelte'; @@ -881,17 +882,13 @@
diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte index 1b6dea48af..9fed1dc34d 100644 --- a/src/lib/components/workspace/Prompts.svelte +++ b/src/lib/components/workspace/Prompts.svelte @@ -36,6 +36,7 @@ import Search from '../icons/Search.svelte'; import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import XMark from '../icons/XMark.svelte'; import GarbageBin from '../icons/GarbageBin.svelte'; import ViewSelector from './common/ViewSelector.svelte'; @@ -640,17 +641,13 @@
diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte index d8d50cbd92..05668794ec 100644 --- a/src/lib/components/workspace/Skills.svelte +++ b/src/lib/components/workspace/Skills.svelte @@ -33,6 +33,7 @@ import TagInput from '$lib/components/common/Tags/TagInput.svelte'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import ConfirmDialog from '../common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; @@ -565,17 +566,13 @@ {#if skill.write_access} diff --git a/src/lib/components/workspace/Tools.svelte b/src/lib/components/workspace/Tools.svelte index 06c7ff80d1..0cf9c773d7 100644 --- a/src/lib/components/workspace/Tools.svelte +++ b/src/lib/components/workspace/Tools.svelte @@ -35,6 +35,7 @@ import { capitalizeFirstLetter } from '$lib/utils'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import ConfirmDialog from '../common/ConfirmDialog.svelte'; import ToolMenu from './Tools/ToolMenu.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; @@ -507,17 +508,13 @@ {#if tool.write_access} From 72cc79b8f336a753b6e75e0f3415630601b14759 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 08:33:40 +0900 Subject: [PATCH 122/385] refac --- src/lib/components/admin/Functions.svelte | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index cacf952036..7a449ea62c 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -25,6 +25,7 @@ } from '$lib/apis/functions'; import Tooltip from '../common/Tooltip.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import ConfirmDialog from '../common/ConfirmDialog.svelte'; import { getModels } from '$lib/apis'; import FunctionMenu from './Functions/FunctionMenu.svelte'; @@ -574,17 +575,13 @@
From 6dbd375c267dd0dafc83c480ec23e3b1a7156406 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 08:34:45 +0900 Subject: [PATCH 123/385] refac --- src/lib/components/chat/Chat.svelte | 10 +++++----- src/lib/components/chat/MessageInput.svelte | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 49f04e7412..5a00d68bc2 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1000,7 +1000,7 @@ if (type === 'prompt') { // Handle prompt selection - messageInput?.setText(data, async () => { + messageInput?.setTextHandler(data, async () => { if (!($settings?.insertSuggestionPrompt ?? false)) { await tick(); submitHandler(prompt); @@ -1849,7 +1849,7 @@ const inputElement = document.getElementById('chat-input'); if (inputElement) { - messageInput?.setText(event.data.text); + messageInput?.setTextHandler(event.data.text); messageInput?.focus({ preventScroll: true }); } } @@ -2844,7 +2844,7 @@ } if (query) { - messageInput?.setText(query, () => submitHandler(prompt)); + messageInput?.setTextHandler(query, () => submitHandler(prompt)); } else if (eventFiles?.length) { await tick(); submitHandler(''); @@ -2853,7 +2853,7 @@ } else if ($page.url.searchParams.get('q')) { const q = $page.url.searchParams.get('q') ?? ''; - messageInput?.setText(q); + messageInput?.setTextHandler(q); } selectedModels = selectedModels.map((modelId) => @@ -5215,7 +5215,7 @@ bind:autoScroll bind:prompt setInputText={(text) => { - messageInput?.setText(text); + messageInput?.setTextHandler(text); }} bind:selectedModels {atSelectedModel} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 714ea8fb65..bbf42209ea 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -432,7 +432,14 @@ chatInputElement?.focus(options); }; - export const setText = async (text?: string, cb?: (text: string) => void) => { + export const setText = (text: string = '') => { + chatInputElement?.setText(text); + if (!$showCallOverlay) { + focus(); + } + }; + + export const setTextHandler = async (text?: string, cb?: (text: string) => void) => { const chatInput = document.getElementById('chat-input'); if (chatInput) { @@ -440,10 +447,7 @@ text = await textVariableHandler(text || ''); } - chatInputElement?.setText(text); - if (!$showCallOverlay) { - focus(); - } + setText(text); if (text !== '') { text = await inputVariableHandler(text); From 56eca192ab7d98dd9d1a9811e4452dc32145848d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:35:13 +0200 Subject: [PATCH 124/385] feat: context compaction now runs on chats with few messages that are over the threshold (#32794) Context compaction summarizes older messages and keeps the most recent ones verbatim, splitting the chat at a user message. It skipped the chat whenever the first follow-up message sat too close to the end to keep the share set in the Retained Messages admin setting. So a fresh chat with one question and one reply carrying a lot of tool output never compacted: the follow-up failed with a context length error, and regenerating or editing that follow-up failed the same way. Now compaction splits at the first follow-up message in that case, keeping fewer messages than Retained Messages asks for, so the chat keeps working. Chats where a split already met that share compact as before, and compaction between tool calls is unchanged. Fixes #32781 --- backend/open_webui/utils/context_compaction.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/utils/context_compaction.py b/backend/open_webui/utils/context_compaction.py index 7f9649bdd7..1c09b8155f 100644 --- a/backend/open_webui/utils/context_compaction.py +++ b/backend/open_webui/utils/context_compaction.py @@ -61,7 +61,7 @@ async def compact_messages_for_request( messages, previous_summary = _apply_latest_summary_checkpoint(messages) system_prompt = system_prompt or (get_content_from_message(system_messages[0]) if system_messages else '') token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata) - if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3: + if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold): return [*system_messages, *messages], previous_summary, False boundary = _find_compaction_boundary(messages, config['retention_percentage']) @@ -374,6 +374,8 @@ def _find_compaction_boundary(messages: list[dict], retention_percentage: int = ][1:] else: boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:] + # The first later user turn stays a valid boundary even inside the retained share. + target = max(target, min(boundaries, default=0)) return next((idx for idx in reversed(boundaries) if idx <= target), 0) From ba34b1c47c1d054d2f9c498d0aed862ec06da41a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 08:41:25 +0900 Subject: [PATCH 125/385] refac --- .../Messages/Message/ProfilePreview.svelte | 3 +- .../workspace/common/AccessControl.svelte | 19 ++++----- .../workspace/common/MemberSelector.svelte | 42 +++++++++++-------- .../workspace/common/VersionMenuItem.svelte | 17 ++++---- 4 files changed, 45 insertions(+), 36 deletions(-) diff --git a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte index 2fe8ff3a58..3e688ae770 100644 --- a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte +++ b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte @@ -19,6 +19,7 @@ export let user: { id?: string | null; name?: string | null } | null = null; + export let className = ''; export let align = 'center'; export let side = 'right'; export let sideOffset = 8; @@ -46,7 +47,7 @@ {#if user?.id && $sessionUser} {#snippet child({ props })} +
{/each}
{/if} diff --git a/src/lib/components/workspace/common/VersionMenuItem.svelte b/src/lib/components/workspace/common/VersionMenuItem.svelte index ef4c1be199..b95e44d00f 100644 --- a/src/lib/components/workspace/common/VersionMenuItem.svelte +++ b/src/lib/components/workspace/common/VersionMenuItem.svelte @@ -5,6 +5,7 @@ import { user } from '$lib/stores'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte'; + import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte'; import Check from '$lib/components/icons/Check.svelte'; import GarbageBin from '$lib/components/icons/GarbageBin.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; @@ -37,23 +38,25 @@
+
+ + + +
+
+ + + + +
+ - - - -
-
- -
-
-
- - - -
- +
+ {#if operation} + + {$i18n.t( + operation === 'download' + ? 'Preparing {{processed}} of {{total}}' + : 'Processed {{processed}} of {{total}}', + { processed: progress.done, total: progress.total } + )} + + {:else} + + + {#if query}{/if} + {#if selectedIds.size} + {$i18n.t('{{count}} selected', { count: selectedIds.size })} + + {/if} + {/if} +
- {#if query} -
- -
- {/if} -
+ {#if selectionStatus === 'loading'} +
+ {$i18n.t('Selecting all matching files...')}
+ {:else if selectionStatus === 'error'} + + {:else if allLoadedSelected && !allFilesLoaded && selectionStatus !== 'all' && !operation} + + {/if} - -
- {#if files !== null} -
- {#if files.length > 0} -
- - -
- {/if} - -
- {#if files.length === 0} -
- {$i18n.t('No files found')} -
- {/if} - - {#each files as file (file.id)} -
openFileViewer(file)} - > -
-
{file.filename}
-
- {formatFileSize(file.meta?.size ?? 0)} -
-
- -
- - {dayjs(file.created_at * 1000) - .locale($i18n.language) - .format('MMM D, YYYY')} - - -
- - - -
-
-
- {/each} - - {#if !allFilesLoaded} - { - if (!filesLoading) { - loadMoreFiles(); - } - }} - > -
- -
{$i18n.t('Loading...')}
-
-
- {/if} + {#if files !== null} + {#if files.length} +
+
+ 0 && !allLoadedSelected} + disabled={busy} + ariaLabel={$i18n.t('Select loaded files')} + on:change={() => + toggleSelection( + (files ?? []).map((file) => file.id), + !allLoadedSelected + )} + /> +
+ + {$i18n.t('Size')} + +
+ {/if} +
+ {#if !files.length && !loadError}
+ {$i18n.t('No files found')} +
{/if} + {#each sortedFiles as file (file.id)} +
+
+ toggleSelection([file.id], !selectedIds.has(file.id))} + />
+ + {formatFileSize(file.meta?.size ?? 0)} + + + +
+ + + + +
+
- {:else} -
- + {/each} + {#if loadError} + + {:else if !allFilesLoaded && !busy} + loadFiles(true)} + >
{/if}
-
+ {:else} +
+ {/if}
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index f1c4765a33..7b6b7c094b 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -4087,5 +4087,23 @@ "YouTube": "", "YouTube video player": "", "Zoom in": "", - "Zoom out": "" + "Zoom out": "", + "Delete {{count}} files?_one": "Delete {{count}} file?", + "Delete {{count}} files?_other": "Delete {{count}} files?", + "This cannot be undone. References to these files may stop working.": "This cannot be undone. References to these files may stop working.", + "{{count}} selected": "{{count}} selected", + "Clear selection": "Clear selection", + "Select all matching files": "Select all matching files", + "Selecting all matching files...": "Selecting all matching files...", + "Could not select all matching files. Your selection has not changed.": "Could not select all matching files. Your selection has not changed.", + "Select loaded files": "Select loaded files", + "Select {{filename}}": "Select {{filename}}", + "Delete {{filename}}": "Delete {{filename}}", + "Could not load files.": "Could not load files.", + "Processed {{processed}} of {{total}}": "Processed {{processed}} of {{total}}", + "Stopping...": "Stopping...", + "{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.": "{{deleted}} deleted, {{failed}} failed, {{remaining}} not processed.", + "Selection actions": "Selection actions", + "Actions for {{filename}}": "Actions for {{filename}}", + "Preparing {{processed}} of {{total}}": "Preparing {{processed}} of {{total}}" } From 131ddc32ecaeb06f9aa8740ff004710bacef72e0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 21 Oct 2026 11:07:10 +0900 Subject: [PATCH 141/385] refac --- backend/open_webui/models/chat_messages.py | 103 ++--- backend/open_webui/routers/analytics.py | 23 +- src/lib/apis/analytics/index.ts | 6 +- .../admin/Analytics/ChartLine.svelte | 14 +- .../admin/Analytics/Dashboard.svelte | 33 +- .../admin/Settings/AdminSettingRow.svelte | 2 +- .../admin/Settings/Apps/AppModal.svelte | 3 +- .../components/admin/Settings/Database.svelte | 22 +- .../admin/Settings/Documents.svelte | 8 + .../admin/Settings/Pipelines.svelte | 2 + .../components/chat/Settings/Account.svelte | 6 +- .../chat/Settings/DataControls.svelte | 12 + .../components/chat/Settings/General.svelte | 2 + .../chat/Settings/UserSettingRow.svelte | 2 +- src/lib/components/chat/SettingsModal.svelte | 85 ++-- .../common/InterfaceSettings.svelte | 416 +++++++++++++----- src/lib/i18n/locales/en-US/translation.json | 5 + 17 files changed, 499 insertions(+), 245 deletions(-) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index de5366ec52..ad90a97b89 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -23,6 +23,7 @@ from sqlalchemy import ( cast, delete, func, + literal, select, ) from sqlalchemy.ext.asyncio import AsyncSession @@ -961,20 +962,36 @@ class ChatMessageTable: result = await db.execute(stmt) return {row.chat_id: row.count for row in result.all()} - async def get_daily_message_counts_by_model( + async def get_usage_over_time_by_model( self, start_date: Optional[int] = None, end_date: Optional[int] = None, group_id: Optional[str] = None, db: Optional[AsyncSession] = None, + granularity: str = 'daily', + metric: str = 'messages', ) -> dict[str, dict[str, int]]: - """Get message counts grouped by day and model.""" - async with get_async_db_context(db) as db: - from datetime import datetime, timedelta + """Get message counts or total tokens grouped by time bucket and model.""" + bucket_minutes = {'10m': 10, 'hourly': 60, 'daily': 1440}[granularity] + date_format = '%Y-%m-%d' if granularity == 'daily' else '%Y-%m-%d %H:%M' + step = timedelta(minutes=bucket_minutes) + def bucket_start(timestamp): + date = datetime.fromtimestamp(_normalize_timestamp(timestamp)) + minutes = date.hour * 60 + date.minute + minutes = minutes // bucket_minutes * bucket_minutes + return date.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0) + + async with get_async_db_context(db) as db: from open_webui.models.groups import group_user_memberships - stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( + value = literal(1) + if metric == 'tokens': + bind = await db.connection() + input_tokens, output_tokens = _token_columns(bind.dialect.name) + value = func.coalesce(input_tokens, 0) + func.coalesce(output_tokens, 0) + + stmt = select(ChatMessage.created_at, ChatMessage.model_id, value).filter( ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ) @@ -988,78 +1005,20 @@ class ChatMessageTable: stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) result = await db.execute(stmt) - results = result.all() + counts: dict[str, dict[str, int]] = {} + for timestamp, model_id, value in result.all(): + date_str = bucket_start(timestamp).strftime(date_format) + bucket = counts.setdefault(date_str, {}) + bucket[model_id] = bucket.get(model_id, 0) + int(value) - # Group by date -> model -> count - daily_counts: dict[str, dict[str, int]] = {} - for timestamp, model_id in results: - date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d') - if date_str not in daily_counts: - daily_counts[date_str] = {} - daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1 - - # Fill in missing days if start_date and end_date: - current = datetime.fromtimestamp(_normalize_timestamp(start_date)) + current = bucket_start(start_date) end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) while current <= end_dt: - date_str = current.strftime('%Y-%m-%d') - if date_str not in daily_counts: - daily_counts[date_str] = {} - current += timedelta(days=1) + counts.setdefault(current.strftime(date_format), {}) + current += step - return daily_counts - - async def get_hourly_message_counts_by_model( - self, - start_date: Optional[int] = None, - end_date: Optional[int] = None, - group_id: Optional[str] = None, - db: Optional[AsyncSession] = None, - ) -> dict[str, dict[str, int]]: - """Get message counts grouped by hour and model.""" - async with get_async_db_context(db) as db: - from datetime import datetime, timedelta - - from open_webui.models.groups import group_user_memberships - - stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter( - ChatMessage.role == 'assistant', - ChatMessage.model_id.isnot(None), - ) - - if start_date: - stmt = stmt.filter(ChatMessage.created_at >= start_date) - if end_date: - stmt = stmt.filter(ChatMessage.created_at <= end_date) - if group_id: - group_users = select(group_user_memberships([group_id], True).c.user_id).scalar_subquery() - stmt = stmt.filter(ChatMessage.user_id.in_(group_users)) - - result = await db.execute(stmt) - results = result.all() - - # Group by hour -> model -> count - hourly_counts: dict[str, dict[str, int]] = {} - for timestamp, model_id in results: - hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00') - if hour_str not in hourly_counts: - hourly_counts[hour_str] = {} - hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1 - - # Fill in missing hours - if start_date and end_date: - current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace( - minute=0, second=0, microsecond=0 - ) - end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) - while current <= end_dt: - hour_str = current.strftime('%Y-%m-%d %H:00') - if hour_str not in hourly_counts: - hourly_counts[hour_str] = {} - current += timedelta(hours=1) - - return hourly_counts + return counts ChatMessages = ChatMessageTable() diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index 7d57ab3262..b6ee4b9f65 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -1,7 +1,7 @@ import logging from collections import defaultdict from datetime import datetime, timedelta -from typing import Optional +from typing import Literal, Optional from fastapi import APIRouter, Depends, Query from open_webui.internal.db import get_async_session @@ -201,19 +201,20 @@ async def get_daily_stats( start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), group_id: Optional[str] = Query(None, description='Filter by user group ID'), - granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"), + granularity: Literal['10m', 'hourly', 'daily'] = Query('daily'), + metric: Literal['messages', 'tokens'] = Query('messages'), user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session), ): - """Get message counts grouped by model for time-series chart.""" - if granularity == 'hourly': - counts = await ChatMessages.get_hourly_message_counts_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) - else: - counts = await ChatMessages.get_daily_message_counts_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) + """Get message counts or total tokens grouped by model for the time-series chart.""" + counts = await ChatMessages.get_usage_over_time_by_model( + start_date=start_date, + end_date=end_date, + group_id=group_id, + db=db, + granularity=granularity, + metric=metric, + ) return DailyStatsResponse( data=[DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())] ) diff --git a/src/lib/apis/analytics/index.ts b/src/lib/apis/analytics/index.ts index 88e16c48e1..a6489a62a5 100644 --- a/src/lib/apis/analytics/index.ts +++ b/src/lib/apis/analytics/index.ts @@ -166,8 +166,9 @@ export const getDailyStats = async ( token: string = '', startDate: number | null = null, endDate: number | null = null, - granularity: 'hourly' | 'daily' = 'daily', - groupId: string | null = null + granularity: '10m' | 'hourly' | 'daily' = 'daily', + groupId: string | null = null, + metric: 'messages' | 'tokens' = 'messages' ) => { let error = null; @@ -175,6 +176,7 @@ export const getDailyStats = async ( if (startDate) searchParams.append('start_date', startDate.toString()); if (endDate) searchParams.append('end_date', endDate.toString()); searchParams.append('granularity', granularity); + searchParams.append('metric', metric); if (groupId) searchParams.append('group_id', groupId); const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/daily?${searchParams.toString()}`, { diff --git a/src/lib/components/admin/Analytics/ChartLine.svelte b/src/lib/components/admin/Analytics/ChartLine.svelte index c78f6dccca..0eafb887dc 100644 --- a/src/lib/components/admin/Analytics/ChartLine.svelte +++ b/src/lib/components/admin/Analytics/ChartLine.svelte @@ -86,7 +86,9 @@ {@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1} {@const isHourly = data[0]?.date?.includes(':')} {@const dateFormat = isHourly - ? 'h A' + ? period === 'hour' + ? 'h:mm A' + : 'M/D h A' : period === 'year' || period === 'all' ? 'M/D/YY' : 'M/D'} @@ -106,15 +108,17 @@ {#if hovered} {@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
{#if hovered.date?.includes(':')} - {dayjs(hovered.date).locale($i18n.language).format('MMM D, h A')} + {dayjs(hovered.date) + .locale($i18n.language) + .format(period === 'hour' ? 'MMM D, h:mm A' : 'MMM D, h A')} {:else} {dayjs(hovered.date).locale($i18n.language).format('MMM D, YYYY')} {/if} diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte index bb6cfe6483..6642c4a12c 100644 --- a/src/lib/components/admin/Analytics/Dashboard.svelte +++ b/src/lib/components/admin/Analytics/Dashboard.svelte @@ -25,6 +25,10 @@ // Time period - persist in localStorage let selectedPeriod = (typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d'; + let granularity: '10m' | 'hourly' | 'daily'; + $: granularity = + selectedPeriod === '24h' ? '10m' : ['7d', '30d'].includes(selectedPeriod) ? 'hourly' : 'daily'; + let selectedMetric: 'messages' | 'tokens' = 'messages'; // Custom date range (YYYY-MM-DD) - persist in localStorage let customStart = @@ -130,12 +134,11 @@ loading = true; try { const { start, end } = getDateRange(selectedPeriod); - const granularity = selectedPeriod === '24h' ? 'hourly' : 'daily'; const [summaryRes, modelsRes, usersRes, dailyRes, tokensRes] = await Promise.all([ getSummary(localStorage.token, start, end, selectedGroupId), getModelAnalytics(localStorage.token, start, end, selectedGroupId), getUserAnalytics(localStorage.token, start, end, 50, selectedGroupId), - getDailyStats(localStorage.token, start, end, granularity, selectedGroupId), + getDailyStats(localStorage.token, start, end, granularity, selectedGroupId, selectedMetric), getTokenUsage(localStorage.token, start, end, selectedGroupId) ]); @@ -181,6 +184,7 @@ customStart; customEnd; selectedGroupId; + selectedMetric; loadDashboard(); } @@ -354,7 +358,7 @@ >
- + {#if dailyStats.length > 1} {@const allModels = [...new Set(dailyStats.flatMap((d) => Object.keys(d.models || {})))]} {@const topModels = allModels.slice(0, 8)} @@ -370,9 +374,26 @@ ]} {@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
-
- {selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')} -
+ {label} diff --git a/src/lib/components/admin/Settings/Apps/AppModal.svelte b/src/lib/components/admin/Settings/Apps/AppModal.svelte index e5def45c87..39c5965d88 100644 --- a/src/lib/components/admin/Settings/Apps/AppModal.svelte +++ b/src/lib/components/admin/Settings/Apps/AppModal.svelte @@ -459,7 +459,7 @@ - +
{$i18n.t('{{COUNT}} selected', { @@ -467,6 +467,7 @@ })} - diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 7f5c4dd93a..a2809beac9 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -1370,10 +1370,12 @@ - -
-
+
+ +
- - +
+ {#if !$mobile || showSearch || search} +
+
+ +
+ + +
+ {/if} +
{search ? filteredSettings.length diff --git a/src/lib/components/common/InterfaceSettings.svelte b/src/lib/components/common/InterfaceSettings.svelte index e92a0cb439..5652531fea 100644 --- a/src/lib/components/common/InterfaceSettings.svelte +++ b/src/lib/components/common/InterfaceSettings.svelte @@ -119,8 +119,9 @@ let fontFamilyInput = ''; let showTextScaleSlider = false; let showFontFamilyInput = false; - const settingRowClass = 'flex items-center justify-between gap-2.5'; - const settingLabelClass = 'min-w-0 text-xs text-gray-600 dark:text-gray-400'; + const settingRowClass = 'group/setting-row flex items-center justify-between gap-2.5'; + const settingLabelClass = + 'min-w-0 text-xs text-gray-600 group-has-[button[id$=-control]:enabled]/setting-row:cursor-pointer group-has-[button[id$=-control]:disabled]/setting-row:cursor-not-allowed transition-colors duration-150 group-hover/setting-row:text-gray-900 group-focus-within/setting-row:text-gray-900 dark:text-gray-400 dark:group-hover/setting-row:text-white dark:group-focus-within/setting-row:text-white'; const settingControlClass = 'flex shrink-0 items-center justify-end gap-1.5'; const sectionHeadingClass = 'mt-4 text-xs text-gray-600 dark:text-gray-500'; const firstSectionHeadingClass = 'text-xs text-gray-600 dark:text-gray-500'; @@ -460,12 +461,13 @@
-