From 7c398a625a8d51f79d80217f6d329fc30c72b782 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 03:45:56 +0900 Subject: [PATCH 001/352] refac --- backend/open_webui/main.py | 11 +++++++++++ src/lib/components/chat/Chat.svelte | 18 ++++++------------ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index c437365312..cfb8cb3b1a 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1829,6 +1829,7 @@ async def chat_completion( ] if user_message_id else [], + 'files': metadata.get('files') or [], 'tags': [], 'timestamp': int(time.time() * 1000), }, @@ -1861,6 +1862,16 @@ async def chat_completion( detail=ERROR_MESSAGES.DEFAULT(), ) + # Persist chat-level files (knowledge collections, docs, etc.) + # The old frontend saveChatHandler did this on every message; + # now the backend owns persistence. + chat_files = metadata.get('files') + if chat_files is not None: + existing_chat = await Chats.get_chat_by_id(chat_id) + if existing_chat: + updated = {**existing_chat.chat, 'files': chat_files} + await Chats.update_chat_by_id(chat_id, updated) + # Save user message to DB user_message = metadata.get('user_message') or {} if user_message and user_message.get('id'): diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index e7cf14a567..637596fdf8 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2179,7 +2179,7 @@ files.push( ...(userMessage?.files ?? []).filter( (item) => - ['doc', 'text', 'note', 'chat', 'collection'].includes(item.type) || + ['doc', 'text', 'note', 'chat', 'collection', 'folder'].includes(item.type) || (item.type === 'file' && !(item?.content_type ?? '').startsWith('image/')) ) ); @@ -2433,18 +2433,12 @@ await chats.set(await getChatList(localStorage.token, $currentChatPage)); // Persist chat-level params (system prompt, advanced - // params) and files that the backend doesn't have when - // it creates the chat shell. Without this, reloading - // loses them. Only patch these fields — avoid writing - // history/messages which the backend is updating - // concurrently via streaming. - if ( - Object.keys(params).length > 0 || - chatFiles.length > 0 - ) { + // params) that the backend doesn't receive in the + // chat completion request. Files are now persisted + // by the backend at chat creation time. + if (Object.keys(params).length > 0) { await updateChatById(localStorage.token, res.chat_id, { - params: params, - files: chatFiles + params: params }); } } From c6763521c00f042a28829e33fb6f1b7355054046 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 03:46:08 +0900 Subject: [PATCH 002/352] refac --- src/lib/components/playground/Chat.svelte | 46 ++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/lib/components/playground/Chat.svelte b/src/lib/components/playground/Chat.svelte index 1ac0678892..22d817a4f3 100644 --- a/src/lib/components/playground/Chat.svelte +++ b/src/lib/components/playground/Chat.svelte @@ -23,10 +23,14 @@ import DropdownSub from '../common/DropdownSub.svelte'; import Messages from '$lib/components/playground/Chat/Messages.svelte'; + import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte'; import ChevronUp from '../icons/ChevronUp.svelte'; import ChevronDown from '../icons/ChevronDown.svelte'; import Pencil from '../icons/Pencil.svelte'; import Cog6 from '../icons/Cog6.svelte'; + import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte'; + import Modal from '../common/Modal.svelte'; + import XMark from '../icons/XMark.svelte'; import Sidebar from '../common/Sidebar.svelte'; import ArrowRight from '../icons/ArrowRight.svelte'; import Download from '../icons/Download.svelte'; @@ -45,6 +49,9 @@ let showSystem = false; let showSettings = false; + let showControls = false; + + let params: Record = {}; let system = ''; @@ -90,6 +97,11 @@ return; } + // Build params object, filtering out null/undefined values + const activeParams = Object.fromEntries( + Object.entries(params).filter(([_, v]) => v !== null && v !== undefined) + ); + const [res, controller] = await chatCompletion( localStorage.token, { @@ -103,7 +115,8 @@ } : undefined, ...messages - ].filter((message) => message) + ].filter((message) => message), + ...(Object.keys(activeParams).length > 0 ? activeParams : {}) }, `${WEBUI_BASE_URL}/api` ); @@ -304,6 +317,24 @@ }); + +
+
+
{$i18n.t('Controls')}
+ +
+
+ +
+
+
+
@@ -475,6 +506,19 @@
+ +
{#if !loading} + +
{#if camera} -
-
-
- -
+ + -
@@ -190,8 +197,19 @@ > {$i18n.t('Name')} {#if sortBy === 'name'} - - + + {/if} @@ -202,8 +220,19 @@ > {$i18n.t('Date Modified')} {#if sortBy === 'date'} - - + + {/if} diff --git a/src/lib/components/chat/MessageInput/CallOverlay.svelte b/src/lib/components/chat/MessageInput/CallOverlay.svelte index 008845c67a..77b912de6c 100644 --- a/src/lib/components/chat/MessageInput/CallOverlay.svelte +++ b/src/lib/components/chat/MessageInput/CallOverlay.svelte @@ -671,7 +671,11 @@ // Only handle M key when not typing in an input/textarea if (e.key === 'm' || e.key === 'M') { const target = e.target as HTMLElement; - if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable) { + if ( + target.tagName !== 'INPUT' && + target.tagName !== 'TEXTAREA' && + !target.isContentEditable + ) { e.preventDefault(); toggleMute(); } @@ -1030,7 +1034,9 @@ - + {:else} diff --git a/src/lib/components/chat/MessageInput/VoiceRecording.svelte b/src/lib/components/chat/MessageInput/VoiceRecording.svelte index b0d19d0ddb..3bd6652960 100644 --- a/src/lib/components/chat/MessageInput/VoiceRecording.svelte +++ b/src/lib/components/chat/MessageInput/VoiceRecording.svelte @@ -238,7 +238,13 @@ return; } - const mineTypes = ['audio/webm; codecs=opus', 'audio/webm', 'audio/ogg; codecs=opus', 'audio/mp4', 'audio/wav']; + const mineTypes = [ + 'audio/webm; codecs=opus', + 'audio/webm', + 'audio/ogg; codecs=opus', + 'audio/mp4', + 'audio/wav' + ]; mediaRecorder = new MediaRecorder(stream, { mimeType: mineTypes.find((type) => MediaRecorder.isTypeSupported(type)) diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index 8f46619868..d387bbbe0f 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -125,10 +125,11 @@ {#if item.model.loaded}
new Date() + content={item.model.ollama?.expires_at && + new Date(item.model.ollama?.expires_at * 1000) > new Date() ? `${$i18n.t('Unloads {{FROM_NOW}}', { - FROM_NOW: dayjs(item.model.ollama?.expires_at * 1000).fromNow() - })}` + FROM_NOW: dayjs(item.model.ollama?.expires_at * 1000).fromNow() + })}` : `${$i18n.t('Loaded')}`} className="self-end" > @@ -236,7 +237,7 @@
- {#if $user?.role === 'admin' && item.model.loaded} + {#if $user?.role === 'admin' && item.model.loaded} - +
{/if} diff --git a/src/lib/components/playground/Chat.svelte b/src/lib/components/playground/Chat.svelte index 22d817a4f3..c5be2c6ecd 100644 --- a/src/lib/components/playground/Chat.svelte +++ b/src/lib/components/playground/Chat.svelte @@ -324,7 +324,9 @@ diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 5432c3b211..804ea05b19 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -750,8 +750,7 @@ const convertOpenAIMessages = (convo) => { continue; } - const model = - message['message']?.['metadata']?.['model_slug'] || 'gpt-3.5-turbo'; + const model = message['message']?.['metadata']?.['model_slug'] || 'gpt-3.5-turbo'; const timestamp = message['message']?.['create_time'] ? Math.floor(message['message']['create_time']) : undefined; From 064fdecb675c176a04b024c16ce179f4dda45236 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 05:29:15 +0900 Subject: [PATCH 030/352] refac --- .../Messages/Markdown/KatexRenderer.svelte | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte b/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte index efbb46df45..0c9fd6e2c7 100644 --- a/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte +++ b/src/lib/components/chat/Messages/Markdown/KatexRenderer.svelte @@ -16,7 +16,11 @@ {#if renderToString} - {@html renderToString(content, { displayMode, throwOnError: false })} + + + { + copyToClipboard(content); + toast.success($i18n.t('Copied to clipboard')); + }} + > + {@html renderToString(content, { displayMode, throwOnError: false })} + {/if} From ae0827cec09dedf72bdde756e1a9ba5296d1bdee Mon Sep 17 00:00:00 2001 From: Shamil Date: Fri, 8 May 2026 23:30:09 +0300 Subject: [PATCH 031/352] style(env): satisfy ruff (datetime alias, line length, identity check) (#24118) --- backend/open_webui/env.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index e734a2f865..deca6193dd 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -1,21 +1,21 @@ +import datetime as dt import importlib.metadata import json import logging import os import pkgutil -import sys +import re import shutil +import sys import traceback -from datetime import datetime, timezone +from pathlib import Path from typing import Any from uuid import uuid4 -from pathlib import Path -from cryptography.hazmat.primitives import serialization -import re - import markdown from bs4 import BeautifulSoup +from cryptography.hazmat.primitives import serialization + from open_webui.constants import ERROR_MESSAGES #################################### @@ -43,7 +43,8 @@ except ImportError: DOCKER = os.environ.get('DOCKER', 'False').lower() == 'true' -# device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance +# device type for embedding models - "cpu" (default), "cuda" (nvidia gpu required), or "mps" (apple silicon) +# choosing this correctly can lead to better performance USE_CUDA = os.environ.get('USE_CUDA_DOCKER', 'false') if USE_CUDA.lower() == 'true': @@ -87,7 +88,7 @@ class JSONFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: log_entry: dict[str, Any] = { - 'ts': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec='milliseconds'), + 'ts': dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(timespec='milliseconds'), 'level': _LEVEL_MAP.get(record.levelname, record.levelname.lower()), 'msg': record.getMessage(), 'caller': record.name, @@ -180,7 +181,7 @@ def parse_section(section): try: changelog_path = BASE_DIR / 'CHANGELOG.md' - with open(str(changelog_path.absolute()), 'r', encoding='utf8') as file: + with open(str(changelog_path.absolute()), encoding='utf8') as file: changelog_content = file.read() except Exception: @@ -339,7 +340,7 @@ DATABASE_SCHEMA = os.environ.get('DATABASE_SCHEMA', None) DATABASE_POOL_SIZE = os.environ.get('DATABASE_POOL_SIZE', None) -if DATABASE_POOL_SIZE != None: +if DATABASE_POOL_SIZE is not None: try: DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE) except Exception: @@ -652,7 +653,7 @@ if LICENSE_PUBLIC_KEY: -----BEGIN PUBLIC KEY----- {LICENSE_PUBLIC_KEY} -----END PUBLIC KEY----- -""".encode('utf-8') +""".encode() ) From d78c24703693989c20c9b11174dc458e555b3841 Mon Sep 17 00:00:00 2001 From: Cyp Date: Sat, 9 May 2026 05:31:49 +0900 Subject: [PATCH 032/352] Korean Translation Update (#24087) Signed-off-by: Adam Tao Co-authored-by: Tim Baek Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com> Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Co-authored-by: Kylapaallikko Co-authored-by: Teay Co-authored-by: tcx4c70 Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com> Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com> Co-authored-by: RomualdYT Co-authored-by: Lucas Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com> Co-authored-by: Constantine --- src/lib/i18n/locales/ko-KR/translation.json | 68 ++++++++++----------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/lib/i18n/locales/ko-KR/translation.json b/src/lib/i18n/locales/ko-KR/translation.json index 83ff4863f7..d9a40103df 100644 --- a/src/lib/i18n/locales/ko-KR/translation.json +++ b/src/lib/i18n/locales/ko-KR/translation.json @@ -1,4 +1,4 @@ -{ +{ "-1 for no limit, or a positive integer for a specific limit": "-1은 제한 없음을 의미하며, 양의 정수는 특정 제한을 나타냅니다", "'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.": "'s(초)', 'm(분)', 'h(시간)', 'd(일)', 'w(주)' 또는 '-1'(만료없음) 중 하나를 사용하세요.", "(e.g. `sh webui.sh --api --api-auth username_password`)": "(예: `sh webui.sh --api --api-auth 사용자이름_비밀번호`)", @@ -32,13 +32,13 @@ "{{user}}'s Chats": "{{user}}의 채팅", "{{webUIName}} Backend Required": "{{webUIName}} 백엔드가 필요합니다.", "*Prompt node ID(s) are required for image generation": "이미지 생성에는 프롬프트 노드 ID가 필요합니다.", - "1 hour before": "", + "1 hour before": "1시간 전", "1 Source": "소스 1", - "10 minutes before": "", - "15 minutes before": "", + "10 minutes before": "10분 전", + "15 minutes before": "15분 전", "1m_time_ago": "1분 전", - "30 minutes before": "", - "5 minutes before": "", + "30 minutes before": "30분 전", + "5 minutes before": "5분 전", "A collaboration channel where people join as members": "사람들이 멤버로 참여하는 협업 채널", "A discussion channel where access is controlled by groups and permissions": "그룹과 권한으로 접근이 제어되는 토론 채널", "A new version (v{{LATEST_VERSION}}) is now available.": "새로운 버전 (v{{LATEST_VERSION}})을 사용할 수 있습니다.", @@ -76,11 +76,11 @@ "Add content here": "여기에 내용을 추가하세요", "Add Custom Parameter": "사용자 정의 매개변수 추가", "Add Custom Prompt": "사용자 정의 프롬프트 추가", - "Add description": "", + "Add description": "설명 추가", "Add Details": "디테일 추가", "Add Files": "파일 추가", "Add Image": "이미지 추가", - "Add location": "", + "Add location": "위치 추가", "Add Member": "멤버 추가", "Add Members": "멤버 추가", "Add Memory": "메모리 추가", @@ -116,7 +116,7 @@ "AI": "AI", "All": "전체", "All chats have been unarchived.": "모든 채팅이 보관 해제되었습니다.", - "All day": "", + "All day": "하루 종일", "All models are now hidden": "모든 모델이 이제 숨김 처리되었습니다", "All models are now visible": "모든 모델이 이제 표시됩니다", "All models deleted successfully": "성공적으로 모든 모델이 삭제되었습니다", @@ -188,7 +188,7 @@ "Are you sure you want to archive all chats? This action cannot be undone.": "정말 모든 채팅을 보관하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "Are you sure you want to clear all memories? This action cannot be undone.": "정말 모든 메모리를 지우시겠습니까? 이 작업은 되돌릴 수 없습니다.", "Are you sure you want to delete \"{{NAME}}\"?": "정말 \"{{NAME}}\"을 삭제하시겠습니까?", - "Are you sure you want to delete **{{modelName}}**?": "", + "Are you sure you want to delete **{{modelName}}**?": "정말 **{{modelName}}**을(를) 삭제하시겠습니까?", "Are you sure you want to delete all chats? This action cannot be undone.": "정말 모든 채팅을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "Are you sure you want to delete this channel?": "정말 이 채널을 삭제하시겠습니까?", "Are you sure you want to delete this connection? This action cannot be undone.": "정말 이 연결을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", @@ -204,7 +204,7 @@ "Ask a question": "질문하기", "Assistant": "어시스턴트", "Async Embedding Processing": "비동기 임베딩 처리", - "At time of event": "", + "At time of event": "이벤트 발생 시", "Attach File From Knowledge": "지식 기반에서 파일 첨부", "Attach Files": "첨부 파일", "Attach Knowledge": "지식 기반 첨부", @@ -279,8 +279,8 @@ "Bypass Web Loader": "웹 콘텐츠 불러오기 생략", "Cache Base Model List": "기본 모델 목록 캐시", "Calendar": "캘린더", - "Calendar deleted": "", - "Calendars": "", + "Calendar deleted": "캘린더가 삭제되었습니다.", + "Calendars": "캘린더", "Call": "음성 기능", "Call feature is not supported when using Web STT engine": "웹 STT 엔진 사용 시, 음성 기능은 지원되지 않습니다.", "Camera": "카메라", @@ -417,7 +417,7 @@ "Connect to your own OpenAPI compatible external tool servers.": "OpenAPI 호환 외부 도구 서버에 연결합니다.", "Connected ({{type}})": "{{type}}에 연결됨", "Connection failed": "연결 실패", - "Connection lost. Reconnecting...": "", + "Connection lost. Reconnecting...": "연결이 끊겼습니다. 재연결 중...", "Connection successful": "연결 성공", "Connection Type": "연결 방식", "Connections": "연결", @@ -530,11 +530,11 @@ "Delete All Chats": "모든 채팅 삭제", "Delete all contents inside this folder": "이 폴더 내 모든 콘텐츠 삭제", "Delete automation?": "자동 삭제하시겠습니까?", - "Delete calendar": "", - "Delete Calendar": "", + "Delete calendar": "캘린더 삭제", + "Delete Calendar": "캘린더 삭제", "Delete Chat": "채팅 삭제", "Delete chat?": "채팅을 삭제하시겠습니까?", - "Delete Event": "", + "Delete Event": "이벤트 삭제", "Delete File": "파일 삭제", "Delete folder?": "폴더를 삭제하시겠습니까?", "Delete function?": "함수를 삭제하시겠습니까?", @@ -844,10 +844,10 @@ "Error: A model with the ID '{{modelId}}' already exists. Please select a different ID to proceed.": "오류: ID가 '{{modelId}}'인 모델이 이미 존재합니다. 계속하려면 다른 ID를 선택하세요.", "Error: Model ID cannot be empty. Please enter a valid ID to proceed.": "오류: 모델 ID는 비워둘 수 없습니다. 계속하려면 유효한 ID를 입력하세요.", "Evaluations": "평가", - "Event created": "", - "Event deleted": "", - "Event title": "", - "Event updated": "", + "Event created": "이벤트가 생성되었습니다.", + "Event deleted": "이벤트가 삭제되었습니다.", + "Event title": "이벤트 제목", + "Event updated": "이벤트가 업데이트되었습니다.", "Exa API Key": "Exa API 키", "Example: (&(objectClass=inetOrgPerson)(uid=%s))": "예: (&(objectClass=inetOrgPerson)(uid=%s))", "Example: ALL": "예: 전체", @@ -896,7 +896,7 @@ "Failed to connect to {{URL}} terminal server": "{{URL}} 터미널 서버 연결에 실패했습니다", "Failed to copy link": "링크 복사 실패", "Failed to create API Key.": "API 키 생성에 실패했습니다.", - "Failed to delete calendar": "", + "Failed to delete calendar": "캘린더 삭제에 실패했습니다.", "Failed to delete note": "노트 삭제 실패", "Failed to disconnect": "", "Failed to download image": "이미지 다운로드에 실패했습니다", @@ -1230,7 +1230,7 @@ "local": "로컬", "Local": "로컬", "Local Task Model": "로컬 작업 모델", - "Location": "", + "Location": "위치", "Location access not allowed": "위치 접근이 허용되지 않습니다", "Lost": "패배", "Low": "낮음", @@ -1340,7 +1340,7 @@ "Models Sharing": "모델 공유", "Mojeek": "Mojeek", "Mojeek Search API Key": "Mojeek Search API 키", - "Month": "", + "Month": "월", "Monthly": "월간", "More": "더보기", "More Concise": "더 간결하게", @@ -1359,7 +1359,7 @@ "New Automation": "새로운 자동", "New Button": "새 버튼", "New Chat": "새 채팅", - "New Event": "", + "New Event": "새 이벤트", "New File": "새 파일", "New Folder": "새 폴더", "New Function": "새 함수", @@ -1640,7 +1640,7 @@ "Reasoning Effort": "추론 난이도", "Reasoning Tags": "추론 태그", "Recently Used": "최근 사용", - "Reconnected": "", + "Reconnected": "재연결됨", "Record": "녹음", "Record voice": "음성 녹음", "Redirecting you to Open WebUI Community": "OpenWebUI 커뮤니티로 리디렉션 중", @@ -1664,7 +1664,7 @@ "Relevance": "관련도", "Relevance Threshold": "관련성 임계값", "Remember Dismissal": "다시 보지 않기", - "Reminder": "", + "Reminder": "알림", "Remove": "삭제", "Remove {{MODELID}} from list.": "{{MODELID}}를 목록에서 제거.", "Remove action": "작업 제거", @@ -1910,10 +1910,10 @@ "Start a new conversation": "새 대화 시작", "Start of the channel": "채널 시작", "Start Tag": "시작 태그", - "Starting in {{count}} minutes_other": "", - "Starting in 1 minute": "", + "Starting in {{count}} minutes_other": "{{count}}분 후 시작", + "Starting in 1 minute": "1분 후 시작", "Starting kernel...": "커널 시작 중...", - "Starting now": "", + "Starting now": "지금 시작", "State": "상태", "Status": "상태", "Status cleared successfully": "상태 초기화에 성공했습니다", @@ -2027,7 +2027,7 @@ "This will delete {{NAME}} and all its contents.": "{{NAME}} 와 모든 내용을 삭제합니다.", "This will delete all models including custom models": "이렇게 하면 사용자 지정 모델을 포함한 모든 모델이 삭제됩니다", "This will delete all models including custom models and cannot be undone.": "이렇게 하면 사용자 지정 모델을 포함한 모든 모델이 삭제되며 실행 취소할 수 없습니다.", - "This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "", + "This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "캘린더 \"{{name}}\"과 모든 이벤트가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.", "This will reset the knowledge base and sync all files. Do you wish to continue?": "지식 기반과 모든 파일 연동을 초기화합니다. 계속 하시겠습니까?", "Thorough explanation": "완전한 설명", "Thought": "생각", @@ -2047,7 +2047,7 @@ "Title cannot be an empty string.": "제목은 빈 문자열일 수 없습니다.", "Title Generation": "제목 생성", "Title Generation Prompt": "제목 생성 프롬프트", - "Title is required": "", + "Title is required": "제목이 필요합니다.", "TLS": "TLS", "To access the available model names for downloading,": "다운로드 가능한 모델명을 확인하려면,", "To access the GGUF models available for downloading,": "다운로드 가능한 GGUF 모델을 확인하려면,", @@ -2114,7 +2114,7 @@ "Unloads {{FROM_NOW}}": "{{FROM_NOW}} 언로드", "Unlock mysteries": "미스터리 풀기", "Unpin": "고정 해제", - "Unpin from Sidebar": "", + "Unpin from Sidebar": "사이드바 고정 해제", "Unravel secrets": "비밀 풀기", "Unshare Chat": "채팅 공유 해제", "Unsupported file type.": "지원하지 않는 파일 형식", @@ -2219,7 +2219,7 @@ "WebUI will make requests to \"{{url}}\"": "WebUI가 \"{{url}}\"로 요청을 보냅니다", "WebUI will make requests to \"{{url}}/api/chat\"": "WebUI가 \"{{url}}/api/chat\"로 요청을 보냅니다", "WebUI will make requests to \"{{url}}/chat/completions\"": "WebUI가 \"{{url}}/chat/completions\"로 요청을 보냅니다", - "Week": "", + "Week": "주", "Weekly": "주간", "What are you trying to achieve?": "무엇을 성취하고 싶으신가요?", "What are you working on?": "어떤 작업을 하고 계신가요?", From 9907c0a25ae830d134af70022238715f834d20c6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:01:02 +0900 Subject: [PATCH 033/352] refac --- backend/open_webui/routers/configs.py | 18 +++++++++++----- backend/open_webui/routers/openai.py | 5 +++-- backend/open_webui/utils/headers.py | 22 ++++++++++++++++++++ backend/open_webui/utils/tools.py | 7 ++++--- src/lib/apis/configs/index.ts | 1 + src/lib/components/AddToolServerModal.svelte | 18 +++++++++++++--- 6 files changed, 58 insertions(+), 13 deletions(-) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 21721a4a8b..901d0320e7 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -8,6 +8,7 @@ from typing import Optional from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.headers import get_custom_headers from open_webui.config import get_config, save_config, async_save_config from open_webui.config import BannerModel @@ -103,6 +104,7 @@ class OAuthClientRegistrationForm(BaseModel): client_id: str client_name: Optional[str] = None client_secret: Optional[str] = None + oauth_server_url: Optional[str] = None @router.post('/oauth/clients/register') @@ -117,18 +119,20 @@ async def register_oauth_client( if type: oauth_client_id = f'{type}:{form_data.client_id}' + oauth_server_url = form_data.oauth_server_url if form_data.oauth_server_url else form_data.url + if form_data.client_secret: # Static credentials: skip dynamic registration, build from provided credentials oauth_client_info = await get_oauth_client_info_with_static_credentials( request, oauth_client_id, - form_data.url, + oauth_server_url, oauth_client_id=form_data.client_id, oauth_client_secret=form_data.client_secret, ) else: oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( - request, oauth_client_id, form_data.url + request, oauth_client_id, oauth_server_url ) return { 'status': True, @@ -155,6 +159,7 @@ class ToolServerConnection(BaseModel): headers: Optional[dict | str] = None key: Optional[str] config: Optional[dict] + info: Optional[dict] = None model_config = ConfigDict(extra='allow') @@ -369,7 +374,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn try: if form_data.type == 'mcp': if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'): - discovery_urls = await get_discovery_urls(form_data.url) + oauth_server_url = form_data.info.get('oauth_server_url') if form_data.info and form_data.info.get('oauth_server_url') else form_data.url + discovery_urls = await get_discovery_urls(oauth_server_url) for discovery_url in discovery_urls: log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}') async with aiohttp.ClientSession( @@ -428,7 +434,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - headers.update(form_data.headers) + custom_headers = get_custom_headers(form_data.headers, user) + headers.update(custom_headers) await client.connect(form_data.url, headers=headers) specs = await client.list_tool_specs() @@ -472,7 +479,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn if form_data.headers and isinstance(form_data.headers, dict): if headers is None: headers = {} - headers.update(form_data.headers) + custom_headers = get_custom_headers(form_data.headers, user) + headers.update(custom_headers) url = get_tool_server_url(form_data.url, form_data.path) return await get_tool_server_data(url, headers=headers) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index b85a8f83b3..3105653eb1 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -62,7 +62,7 @@ from open_webui.utils.session_pool import ( ) from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.headers import include_user_info_headers, get_custom_headers from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models log = logging.getLogger(__name__) @@ -215,7 +215,8 @@ async def get_headers_and_cookies( headers['Authorization'] = f'Bearer {token}' if config.get('headers') and isinstance(config.get('headers'), dict): - headers = {**headers, **config.get('headers')} + custom_headers = get_custom_headers(config.get('headers'), user, metadata) + headers.update(custom_headers) return headers, cookies diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index 0baee5edb9..fabd13d7d5 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -16,3 +16,25 @@ def include_user_info_headers(headers, user): FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email, FORWARD_USER_INFO_HEADER_USER_ROLE: user.role, } + +def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -> dict: + if not custom_headers or not isinstance(custom_headers, dict): + return {} + + metadata = metadata or {} + template_vars = { + '{{CHAT_ID}}': metadata.get('chat_id', '') or '', + '{{MESSAGE_ID}}': metadata.get('message_id', '') or '', + '{{USER_ID}}': (user.id if user else '') or '', + '{{USER_NAME}}': (user.name if user else '') or '', + } + + parsed_headers = {} + for key, value in custom_headers.items(): + if not isinstance(value, str): + value = str(value) + for token, val in template_vars.items(): + value = value.replace(token, val) + parsed_headers[key] = value + + return parsed_headers diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index f1b4fb1d1f..3b449984f9 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -54,7 +54,7 @@ from open_webui.env import ( FORWARD_SESSION_INFO_HEADER_MESSAGE_ID, REDIS_KEY_PREFIX, ) -from open_webui.utils.headers import include_user_info_headers +from open_webui.utils.headers import include_user_info_headers, get_custom_headers from open_webui.tools.builtin import ( search_web, fetch_url, @@ -337,8 +337,9 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr connection_headers = tool_server_connection.get('headers', None) if connection_headers and isinstance(connection_headers, dict): - for key, value in connection_headers.items(): - headers[key] = value + metadata = extra_params.get('__metadata__', {}) + custom_headers = get_custom_headers(connection_headers, user, metadata) + headers.update(custom_headers) # Add user info headers if enabled if ENABLE_FORWARD_USER_INFO_HEADERS and user: diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 6b7bf6f47b..7859a4e787 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -378,6 +378,7 @@ type RegisterOAuthClientForm = { client_id: string; client_name?: string; client_secret?: string; + oauth_server_url?: string; }; export const registerOAuthClient = async ( diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 74571c3086..97a6c2bc53 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -60,6 +60,7 @@ let oauthClientId = ''; let oauthClientSecret = ''; + let oauthServerUrl = ''; let enable = true; let loading = false; @@ -86,10 +87,10 @@ // client_id is the tool server ID (used as the internal lookup key for both flows). // For static, client_secret signals the backend to use the static credential path. // The actual OAuth client_id/secret come from the connection info at save time. - const formData: { url: string; client_id: string; client_secret?: string } = { + const formData: { url: string; client_id: string; client_secret?: string; oauth_server_url?: string } = { url: url, client_id: id, - ...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {}) + ...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret, oauth_server_url: oauthServerUrl } : {}) }; const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => { @@ -336,7 +337,7 @@ description: description, ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}), ...(auth_type === 'oauth_2.1_static' - ? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret } + ? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret, oauth_server_url: oauthServerUrl } : {}) } }; @@ -364,6 +365,7 @@ oauthClientInfo = null; oauthClientId = ''; oauthClientSecret = ''; + oauthServerUrl = ''; enable = true; functionNameFilterList = ''; @@ -390,6 +392,7 @@ oauthClientInfo = connection.info?.oauth_client_info ?? null; oauthClientId = connection.info?.oauth_client_id ?? ''; oauthClientSecret = connection.info?.oauth_client_secret ?? ''; + oauthServerUrl = connection.info?.oauth_server_url ?? ''; enable = connection.config?.enable ?? true; functionNameFilterList = connection.config?.function_name_filter_list ?? ''; @@ -730,6 +733,15 @@ placeholder={$i18n.t('Client Secret')} required={false} /> +
+ +
{/if}
From af5628f8ef50a910a69102b3436b1b8e67c76d36 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:13:58 +0900 Subject: [PATCH 034/352] refac --- backend/open_webui/tools/builtin.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 736402d0c8..cce818fec4 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2928,8 +2928,9 @@ async def search_calendar_events( __user__: dict = None, ) -> str: """ - Search calendar events by text and/or date range. - Returns matching events across all accessible calendars. + Search calendar events, reminders, and scheduled items by text and/or date range. + Use this to check what's coming up, find a specific event or reminder, or list + the user's schedule for a time period. :param query: Search text to match against event title, description, or location (optional) :param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional) @@ -3025,17 +3026,19 @@ async def create_calendar_event( __user__: dict = None, ) -> str: """ - Create a new calendar event. If no calendar_id is provided, the event is - added to the user's default calendar. + Create a calendar event, reminder, or alarm. Use this when the user wants to + schedule an event, set a reminder, create an alarm, or says things like + "remind me", "don't let me forget", "notify me at", or "add to my calendar". + For simple reminders, omit end/location/all_day and set reminder_minutes to 0. - :param title: Event title - :param start: Start datetime string in your local time (e.g. "2026-04-20 09:00" or "2026-04-20T09:00:00") - :param end: End datetime string in your local time (optional, omit for point-in-time events) - :param description: Event description (optional) + :param title: Event or reminder title (e.g. "Team standup", "Take medicine", "Call mom") + :param start: Start datetime in the user's local time (e.g. "2026-04-20 09:00") + :param end: End datetime in the user's local time (optional — omit for reminders or point-in-time events) + :param description: Event description or notes (optional) :param calendar_id: Target calendar ID (optional, uses default calendar if omitted) :param all_day: Whether this is an all-day event (default: false) :param location: Event location (optional) - :param reminder_minutes: Minutes before the event to send a reminder notification (optional, default: 10). Use 0 for "at time of event", -1 for no reminder. Accepts any positive integer for custom timing (e.g. 120 for 2 hours before). + :param reminder_minutes: Minutes before the event to send a notification (optional, default: 10). Use 0 for "at time of event", -1 for no notification. :return: JSON with the created event details including id """ if __request__ is None: @@ -3137,6 +3140,8 @@ async def create_calendar_event( return json.dumps({'error': str(e)}) + + async def update_calendar_event( event_id: str, title: Optional[str] = None, From f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:16:27 +0900 Subject: [PATCH 035/352] refac --- src/lib/utils/index.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 804ea05b19..82ca9caaba 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -511,28 +511,32 @@ export const copyToClipboard = async (text, html = null, formatted = false) => { } else { let result = false; if (!navigator.clipboard) { - const textArea = document.createElement('textarea'); - textArea.value = text; + const span = document.createElement('span'); + span.textContent = text; + span.style.whiteSpace = 'pre'; + span.style.position = 'fixed'; + span.style.top = '0'; + span.style.left = '0'; + span.style.opacity = '0'; + document.body.appendChild(span); - // Avoid scrolling to bottom - textArea.style.top = '0'; - textArea.style.left = '0'; - textArea.style.position = 'fixed'; - - document.body.appendChild(textArea); - textArea.focus({ preventScroll: true }); - textArea.select(); + const range = document.createRange(); + range.selectNodeContents(span); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); try { const successful = document.execCommand('copy'); const msg = successful ? 'successful' : 'unsuccessful'; console.log('Fallback: Copying text command was ' + msg); - result = true; + result = successful; } catch (err) { console.error('Fallback: Oops, unable to copy', err); } - document.body.removeChild(textArea); + selection?.removeAllRanges(); + document.body.removeChild(span); return result; } From 34146ab60f5dc1a2f8bdda8e61ce02797233a25d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:20:27 +0900 Subject: [PATCH 036/352] refac --- .../calendar/CreateCalendarModal.svelte | 150 ++++++++++++++++++ src/lib/components/layout/Navbar/Menu.svelte | 5 + .../components/layout/Sidebar/ChatMenu.svelte | 5 + 3 files changed, 160 insertions(+) create mode 100644 src/lib/components/calendar/CreateCalendarModal.svelte diff --git a/src/lib/components/calendar/CreateCalendarModal.svelte b/src/lib/components/calendar/CreateCalendarModal.svelte new file mode 100644 index 0000000000..2b4dc540e2 --- /dev/null +++ b/src/lib/components/calendar/CreateCalendarModal.svelte @@ -0,0 +1,150 @@ + + + +
+ +
+

{$i18n.t('New Calendar')}

+ +
+ + +
+ +
+
{$i18n.t('Name')}
+ { + if (e.key === 'Enter') submitHandler(); + }} + /> +
+ + +
+
{$i18n.t('Color')}
+
+ {#each PRESET_COLORS as c} +
+
+
+ + +
+ + +
+
+
diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index f9ea09104e..70ae564de8 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -100,6 +100,11 @@ clonedElement.style.height = 'auto'; document.body.appendChild(clonedElement); + // Override content-visibility so html2canvas can capture all messages + clonedElement.querySelectorAll('.message-listitem').forEach((el) => { + el.style.contentVisibility = 'visible'; + }); + // Wait for DOM update/layout await new Promise((r) => setTimeout(r, 100)); diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 8be9c6710a..156449b7b8 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -112,6 +112,11 @@ clonedElement.style.height = 'auto'; document.body.appendChild(clonedElement); + // Override content-visibility so html2canvas can capture all messages + clonedElement.querySelectorAll('.message-listitem').forEach((el) => { + el.style.contentVisibility = 'visible'; + }); + // Wait for DOM update/layout await new Promise((r) => setTimeout(r, 100)); From 38a382ef888685650135d61dcc8ec0e29eb65573 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:23:51 +0900 Subject: [PATCH 037/352] refac --- .../a0b1c2d3e4f5_add_memory_user_id_index.py | 22 +++++++++++++++++++ backend/open_webui/models/memories.py | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py diff --git a/backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py b/backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py new file mode 100644 index 0000000000..a52ade7711 --- /dev/null +++ b/backend/open_webui/migrations/versions/a0b1c2d3e4f5_add_memory_user_id_index.py @@ -0,0 +1,22 @@ +"""Add memory user_id index + +Revision ID: a0b1c2d3e4f5 +Revises: 4de81c2a3af1 +Create Date: 2025-09-15 03:00:00.000000 + +""" + +from alembic import op + +revision = 'a0b1c2d3e4f5' +down_revision = '4de81c2a3af1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_index('ix_memory_user_id', 'memory', ['user_id']) + + +def downgrade(): + op.drop_index('ix_memory_user_id', table_name='memory') diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index e956826800..1ec52eeb6a 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -19,7 +19,7 @@ class Memory(Base): __tablename__ = 'memory' id = Column(String, primary_key=True, unique=True) - user_id = Column(String) + user_id = Column(String, index=True) content = Column(Text) updated_at = Column(BigInteger) created_at = Column(BigInteger) From ee3b82926b37843f2771c6a8d432781a557ea96a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:25:38 +0900 Subject: [PATCH 038/352] refac --- src/lib/components/chat/Chat.svelte | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 6aa1542449..d469c58729 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1353,6 +1353,30 @@ ? chatContent.history : convertMessagesToHistory(chatContent.messages); + // Sanitize history: repair orphaned references from failed regenerations (#24424) + for (const message of Object.values(history.messages)) { + if (message.childrenIds) { + message.childrenIds = message.childrenIds.filter( + (childId) => history.messages[childId] + ); + } + } + if (history.currentId && !history.messages[history.currentId]) { + const messageIds = Object.keys(history.messages); + let lastMessageId = null; + for (const messageId of messageIds) { + const message = history.messages[messageId]; + if ( + (message.childrenIds ?? []).length === 0 && + (!lastMessageId || + (message.timestamp ?? 0) > (history.messages[lastMessageId].timestamp ?? 0)) + ) { + lastMessageId = messageId; + } + } + history.currentId = lastMessageId ?? messageIds[0] ?? null; + } + chatTitle.set(chatContent.title); params = chatContent?.params ?? {}; From 794b97025d4c56f91d49c9d1ec4775d2ea07b53a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:32:34 +0900 Subject: [PATCH 039/352] refac --- .../components/common/RichTextInput.svelte | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 99fa025055..ba6c63dbf1 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -302,6 +302,8 @@ let bubbleMenuElement: Element | null = null; let element: Element | null = null; + let pendingUpdate = null; + const options = { throwOnError: false }; @@ -866,10 +868,19 @@ content: collaboration ? undefined : content, autofocus: messageInput ? true : false, onTransaction: () => { - // force re-render so `editor.isActive` works as expected - editor = editor; if (!editor) return; + // Defer Svelte reactivity trigger to rAF so we don't interleave + // DOM reads/writes with ProseMirror's updateStateInner. + if (!pendingUpdate) { + pendingUpdate = requestAnimationFrame(() => { + pendingUpdate = null; + if (editor && !editor.isDestroyed) { + editor = editor; + } + }); + } + htmlValue = editor.getHTML(); jsonValue = editor.getJSON(); @@ -1234,6 +1245,10 @@ }); onDestroy(() => { + if (pendingUpdate) { + cancelAnimationFrame(pendingUpdate); + } + if (provider) { provider.destroy(); } From 1d892ce2c513c4d933c902de5e5d76c317a06dd2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:33:26 +0900 Subject: [PATCH 040/352] refac --- backend/open_webui/models/calendar.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index 47f0a6f722..48b28d8e9a 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -395,14 +395,16 @@ class CalendarTable: # Delete events await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id)) - # Delete access grants - await AccessGrants.revoke_all_access('calendar', id, db=db) - # Delete calendar await db.execute(delete(Calendar).filter(Calendar.id == id)) await db.commit() - return True - except Exception: + + # Revoke access grants in a separate transaction to avoid + # write-lock contention on SQLite when session sharing is off. + await AccessGrants.revoke_all_access('calendar', id) + return True + except Exception as e: + log.exception(f'Failed to delete calendar {id}: {e}') return False From 1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:34:03 +0900 Subject: [PATCH 041/352] refac --- .../calendar/CalendarSidebar.svelte | 19 +++++++++++++++++++ src/lib/components/layout/Navbar/Menu.svelte | 4 ++-- .../components/layout/Sidebar/ChatMenu.svelte | 4 ++-- src/routes/(app)/calendar/+page.svelte | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/lib/components/calendar/CalendarSidebar.svelte b/src/lib/components/calendar/CalendarSidebar.svelte index d3ea51a472..992bcc6854 100644 --- a/src/lib/components/calendar/CalendarSidebar.svelte +++ b/src/lib/components/calendar/CalendarSidebar.svelte @@ -179,6 +179,25 @@
{$i18n.t('Calendars')}
+
{#each calendars as cal (cal.id)} diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index 70ae564de8..43bdc2b745 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -105,8 +105,8 @@ el.style.contentVisibility = 'visible'; }); - // Wait for DOM update/layout - await new Promise((r) => setTimeout(r, 100)); + // Let the browser compute layout for the cloned element + await new Promise((r) => requestAnimationFrame(r)); // Render entire content once const canvas = await html2canvas(clonedElement, { diff --git a/src/lib/components/layout/Sidebar/ChatMenu.svelte b/src/lib/components/layout/Sidebar/ChatMenu.svelte index 156449b7b8..1bc932e430 100644 --- a/src/lib/components/layout/Sidebar/ChatMenu.svelte +++ b/src/lib/components/layout/Sidebar/ChatMenu.svelte @@ -117,8 +117,8 @@ el.style.contentVisibility = 'visible'; }); - // Wait for DOM update/layout - await new Promise((r) => setTimeout(r, 100)); + // Let the browser compute layout for the cloned element + await new Promise((r) => requestAnimationFrame(r)); // Render entire content once const canvas = await html2canvas(clonedElement, { diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte index ec61a27eb9..9e4006bd19 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -13,6 +13,7 @@ import CalendarView from '$lib/components/calendar/CalendarView.svelte'; import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte'; import CalendarEventModal from '$lib/components/calendar/CalendarEventModal.svelte'; + import CreateCalendarModal from '$lib/components/calendar/CreateCalendarModal.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import Plus from '$lib/components/icons/Plus.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; @@ -34,6 +35,7 @@ let showEventModal = false; let editEvent: CalendarEventModel | null = null; let defaultStartAt: number | null = null; + let showCreateCalendarModal = false; const MONTH_NAMES = [ 'January', @@ -159,6 +161,15 @@ refresh(); } + function handleCreateCalendar() { + showCreateCalendarModal = true; + } + + async function handleCalendarCreated() { + await loadCalendars(); + await refresh(); + } + function handleNewEvent() { editEvent = null; defaultStartAt = null; @@ -209,6 +220,11 @@ on:delete={() => refresh()} /> + + - {:else if webConfig.WEB_SEARCH_ENGINE === 'kagi'} + {:else if webConfig.WEB_SEARCH_ENGINE === 'brave_llm_context'} +
+
+
+ {$i18n.t('Brave Search API Key')} +
+ + +
+
+
+ {$i18n.t('Context Tokens')} +
+ +
+
+ +
+
+
+
+ {:else if webConfig.WEB_SEARCH_ENGINE === 'kagi'}
From bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:41:42 +0900 Subject: [PATCH 043/352] refac --- backend/open_webui/config.py | 2 +- backend/open_webui/retrieval/web/duckduckgo.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 90e88f1437..661db353af 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -3287,7 +3287,7 @@ ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( WEB_SEARCH_TRUST_ENV = PersistentConfig( 'WEB_SEARCH_TRUST_ENV', 'rag.web.search.trust_env', - os.getenv('WEB_SEARCH_TRUST_ENV', 'False').lower() == 'true', + os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'true', ) diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index 272a4bf514..a98f43625c 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -1,4 +1,5 @@ import logging +import urllib.request from typing import Optional from open_webui.retrieval.web.main import SearchResult, get_filtered_results @@ -25,9 +26,12 @@ def search_duckduckgo( Returns: list[SearchResult]: A list of search results """ - # Use the DDGS context manager to create a DDGS object + # The ddgs library (primp-based) does not auto-detect proxy env vars. + # Resolve via stdlib getproxies() — same pattern as the other loaders. + env_proxies = urllib.request.getproxies() + proxy = env_proxies.get('https') or env_proxies.get('http') search_results = [] - with DDGS() as ddgs: + with DDGS(proxy=proxy) as ddgs: if concurrent_requests: ddgs.threads = concurrent_requests From 29f6c72e879d67f23021938e24a21914cc9fb120 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:44:42 +0900 Subject: [PATCH 044/352] refac --- src/lib/components/chat/Messages/Markdown.svelte | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 3cbef944e4..bfea80b80b 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -71,11 +71,17 @@ }; const updateHandler = (content) => { - if (content && !pendingUpdate) { - pendingUpdate = requestAnimationFrame(() => { + if (content) { + if (done) { + cancelAnimationFrame(pendingUpdate); pendingUpdate = null; parseTokens(); - }); + } else if (!pendingUpdate) { + pendingUpdate = requestAnimationFrame(() => { + pendingUpdate = null; + parseTokens(); + }); + } } }; From 02f9fe78907c2ecf6f1d93646cbfa2173409bbe8 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:49:41 +0900 Subject: [PATCH 045/352] refac --- backend/open_webui/utils/middleware.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index b55e3c07c9..154dc9e8a6 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2423,6 +2423,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data['files'] = files variables = form_data.pop('variables', None) + payload_tools = form_data.get('tools', None) # snapshot before filters # Process the form_data through the pipeline try: @@ -2513,9 +2514,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): files = form_data.pop('files', None) form_data.pop('folder_id', None) - # Caller-provided OpenAI-style tools take precedence over server-side - # tool resolution (tool_ids, MCP servers, builtin tools). - payload_tools = form_data.get('tools', None) + # If the original caller provided tools, use them as-is (skip resolution). + # Otherwise, save any tools that filter inlets added for merging later. + inlet_filter_tools = None if payload_tools else form_data.get('tools', None) # Skills — extract IDs from message content (<$skillId|label> tags) so # persisted chats work without relying on the frontend to send skill_ids. @@ -2824,6 +2825,8 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data['tools'] = [ {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() ] + if inlet_filter_tools: + form_data['tools'].extend(inlet_filter_tools) else: # If the function calling is not native, then call the tools function calling handler try: From 2ba6b423aa0c9c800bd96cb638c6ade867cac0f6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:50:11 +0900 Subject: [PATCH 046/352] refac --- backend/open_webui/utils/tools.py | 4 ++++ src/lib/apis/index.ts | 6 ++++-- src/lib/utils/index.ts | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 3b449984f9..f7333d85be 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -814,6 +814,8 @@ def convert_openapi_to_tool_payload(openapi_spec): for path, methods in openapi_spec.get('paths', {}).items(): for method, operation in methods.items(): + if not isinstance(operation, dict): + continue if operation.get('operationId'): tool = { 'name': operation.get('operationId'), @@ -1324,6 +1326,8 @@ async def execute_tool_server( method_entry = None for http_method, operation in methods.items(): + if not isinstance(operation, dict): + continue if operation.get('operationId') == name: method_entry = (http_method.lower(), operation) break diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 833979ada0..058c7f2c82 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -530,7 +530,9 @@ export const executeToolServer = async ( try { // Find the matching operationId in the OpenAPI spec const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) => - Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name) + Object.entries(methods as any).some( + ([__, operation]: any) => operation && typeof operation === 'object' && operation.operationId === name + ) ); if (!matchingRoute) { @@ -540,7 +542,7 @@ export const executeToolServer = async ( const [routePath, methods] = matchingRoute; const methodEntry = Object.entries(methods as any).find( - ([_, operation]: any) => operation.operationId === name + ([_, operation]: any) => operation && typeof operation === 'object' && operation.operationId === name ); if (!methodEntry) { diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 82ca9caaba..504ee8bc82 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1395,7 +1395,8 @@ export const convertOpenApiToToolPayload = (openApiSpec) => { for (const [path, methods] of Object.entries(openApiSpec.paths)) { for (const [method, operation] of Object.entries(methods)) { - if (operation?.operationId) { + if (!operation || typeof operation !== 'object') continue; + if ((operation as any)?.operationId) { const tool = { name: operation.operationId, description: operation.description || operation.summary || 'No description available.', From 5b80932e5951786bb348b91589e8d87753f18905 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 06:56:22 +0900 Subject: [PATCH 047/352] refac --- backend/open_webui/utils/tools.py | 77 ++++++++++++++++++++++++------- src/lib/apis/index.ts | 58 +++++++++++++++++------ src/lib/utils/index.ts | 72 ++++++++++++++++++++--------- 3 files changed, 154 insertions(+), 53 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index f7333d85be..6518d2607f 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,4 +1,5 @@ import base64 +import copy import inspect import logging import re @@ -7,6 +8,7 @@ import aiohttp import asyncio import yaml import json +from urllib.parse import quote, urlencode from pydantic import BaseModel from pydantic.fields import FieldInfo @@ -100,7 +102,6 @@ from open_webui.tools.builtin import ( delete_calendar_event, ) -import copy from open_webui.utils.access_control import has_permission log = logging.getLogger(__name__) @@ -728,7 +729,6 @@ def clean_properties(schema: dict): def clean_openai_tool_schema(spec: dict) -> dict: - import copy cleaned_spec = copy.deepcopy(spec) @@ -761,6 +761,11 @@ def get_tool_specs(tool_module: object) -> list[dict]: return specs +# Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +# and non-operation path-item fields (summary, description, servers, parameters). +OPENAPI_HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'} + + def resolve_schema(schema, components, resolved_schemas=None): """ Recursively resolves a JSON schema using OpenAPI components. @@ -813,7 +818,18 @@ def convert_openapi_to_tool_payload(openapi_spec): tool_payload = [] for path, methods in openapi_spec.get('paths', {}).items(): + if not isinstance(methods, dict): + continue + + # Path-level parameters apply to all operations under this path + # unless overridden at the operation level (matched by name + in). + path_level_params = methods.get('parameters', []) + if not isinstance(path_level_params, list): + path_level_params = [] + for method, operation in methods.items(): + if method not in OPENAPI_HTTP_METHODS: + continue if not isinstance(operation, dict): continue if operation.get('operationId'): @@ -826,7 +842,21 @@ def convert_openapi_to_tool_payload(openapi_spec): 'parameters': {'type': 'object', 'properties': {}, 'required': []}, } - for param in operation.get('parameters', []): + # Merge path-level and operation-level parameters. + # Operation-level params override path-level params with the + # same (name, in) pair per the OpenAPI spec. + op_params = operation.get('parameters', []) + if not isinstance(op_params, list): + op_params = [] + merged_params = {} + for param in path_level_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + for param in op_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + + for param in merged_params.values(): param_name = param.get('name') if not param_name: continue @@ -1169,22 +1199,17 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A error_body = await response.json() raise Exception(error_body) - text_content = None + text_content = await response.text() # Check if URL ends with .yaml or .yml to determine format if url.lower().endswith(('.yaml', '.yml')): - text_content = await response.text() res = yaml.safe_load(text_content) else: - text_content = await response.text() - - try: - res = json.loads(text_content) - except json.JSONDecodeError: try: + res = json.loads(text_content) + except json.JSONDecodeError: + # Fall back to YAML for non-.yml URLs that aren't valid JSON res = yaml.safe_load(text_content) - except Exception as e: - raise e except Exception as err: log.exception(f'Could not fetch tool server spec from {url}') @@ -1312,7 +1337,11 @@ async def execute_tool_server( matching_route = None for route_path, methods in paths.items(): + if not isinstance(methods, dict): + continue for http_method, operation in methods.items(): + if http_method not in OPENAPI_HTTP_METHODS: + continue if isinstance(operation, dict) and operation.get('operationId') == name: matching_route = (route_path, methods) break @@ -1326,6 +1355,8 @@ async def execute_tool_server( method_entry = None for http_method, operation in methods.items(): + if http_method not in OPENAPI_HTTP_METHODS: + continue if not isinstance(operation, dict): continue if operation.get('operationId') == name: @@ -1341,7 +1372,22 @@ async def execute_tool_server( query_params = {} body_params = {} - for param in operation.get('parameters', []): + # Merge path-level and operation-level parameters for execution. + path_level_params = methods.get('parameters', []) + if not isinstance(path_level_params, list): + path_level_params = [] + op_params = operation.get('parameters', []) + if not isinstance(op_params, list): + op_params = [] + merged_params = {} + for param in path_level_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + for param in op_params: + if isinstance(param, dict) and param.get('name'): + merged_params[(param['name'], param.get('in', ''))] = param + + for param in merged_params.values(): param_name = param.get('name') if not param_name: continue @@ -1359,11 +1405,10 @@ async def execute_tool_server( final_url = f'{url.rstrip("/")}{route_path}' for key, value in path_params.items(): - final_url = final_url.replace(f'{{{key}}}', str(value)) + final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe='')) if query_params: - query_string = '&'.join(f'{k}={v}' for k, v in query_params.items()) - final_url = f'{final_url}?{query_string}' + final_url = f'{final_url}?{urlencode(query_params)}' if operation.get('requestBody', {}).get('content'): if params: diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 058c7f2c82..6378b6f78d 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -4,6 +4,12 @@ import { getOpenAIModelsDirect } from './openai'; const TOOL_SERVER_FETCH_TIMEOUT = 10000; +// Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +// and non-operation path-item fields (summary, description, servers, parameters). +const OPENAPI_HTTP_METHODS = new Set([ + 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace' +]); + // Every request sent from here is a petition. May it reach // the one for whom it was intended, and return answered. export const getModels = async ( @@ -528,10 +534,14 @@ export const executeToolServer = async ( let error = null; try { - // Find the matching operationId in the OpenAPI spec + // Find the matching operationId in the OpenAPI spec (only valid HTTP methods) const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) => Object.entries(methods as any).some( - ([__, operation]: any) => operation && typeof operation === 'object' && operation.operationId === name + ([method, operation]: any) => + OPENAPI_HTTP_METHODS.has(method) && + operation && + typeof operation === 'object' && + operation.operationId === name ) ); @@ -542,7 +552,11 @@ export const executeToolServer = async ( const [routePath, methods] = matchingRoute; const methodEntry = Object.entries(methods as any).find( - ([_, operation]: any) => operation && typeof operation === 'object' && operation.operationId === name + ([method, operation]: any) => + OPENAPI_HTTP_METHODS.has(method) && + operation && + typeof operation === 'object' && + operation.operationId === name ); if (!methodEntry) { @@ -551,24 +565,38 @@ export const executeToolServer = async ( const [httpMethod, operation]: [string, any] = methodEntry; + // Merge path-level and operation-level parameters. + // Operation-level params override path-level params with the same (name, in). + const pathLevelParams: any[] = Array.isArray((methods as any).parameters) + ? (methods as any).parameters + : []; + const opParams: any[] = Array.isArray(operation.parameters) + ? operation.parameters + : []; + const mergedParams = new Map(); + for (const param of pathLevelParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } + for (const param of opParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } + // Split parameters by type const pathParams: Record = {}; const queryParams: Record = {}; let bodyParams: any = {}; - if (operation.parameters) { - operation.parameters.forEach((param: any) => { - const paramName = param?.name; - if (!paramName) return; - const paramIn = param?.in; - if (params.hasOwnProperty(paramName)) { - if (paramIn === 'path') { - pathParams[paramName] = params[paramName]; - } else if (paramIn === 'query') { - queryParams[paramName] = params[paramName]; - } + for (const param of mergedParams.values()) { + const paramName = param?.name; + if (!paramName) continue; + const paramIn = param?.in; + if (params.hasOwnProperty(paramName)) { + if (paramIn === 'path') { + pathParams[paramName] = params[paramName]; + } else if (paramIn === 'query') { + queryParams[paramName] = params[paramName]; } - }); + } } let finalUrl = `${url}${routePath}`; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 504ee8bc82..81a10cebfd 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1384,6 +1384,10 @@ function resolveSchema(schemaRef, components, resolvedSchemas = new Set()) { return {}; } +// Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*) +// and non-operation path-item fields (summary, description, servers, parameters). +const OPENAPI_HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); + // Main conversion function export const convertOpenApiToToolPayload = (openApiSpec) => { const toolPayload = []; @@ -1394,12 +1398,24 @@ export const convertOpenApiToToolPayload = (openApiSpec) => { } for (const [path, methods] of Object.entries(openApiSpec.paths)) { + if (!methods || typeof methods !== 'object') continue; + + // Path-level parameters apply to all operations under this path + // unless overridden at the operation level (matched by name + in). + const pathLevelParams: any[] = Array.isArray((methods as any).parameters) + ? (methods as any).parameters + : []; + for (const [method, operation] of Object.entries(methods)) { + if (!OPENAPI_HTTP_METHODS.has(method)) continue; if (!operation || typeof operation !== 'object') continue; if ((operation as any)?.operationId) { const tool = { - name: operation.operationId, - description: operation.description || operation.summary || 'No description available.', + name: (operation as any).operationId, + description: + (operation as any).description || + (operation as any).summary || + 'No description available.', parameters: { type: 'object', properties: {}, @@ -1407,30 +1423,42 @@ export const convertOpenApiToToolPayload = (openApiSpec) => { } }; - // Extract path and query parameters - if (operation.parameters) { - operation.parameters.forEach((param) => { - const paramName = param?.name; - if (!paramName) return; - const paramSchema = param?.schema ?? {}; - let description = paramSchema.description || param.description || ''; - if (paramSchema.enum && Array.isArray(paramSchema.enum)) { - description += `. Possible values: ${paramSchema.enum.join(', ')}`; - } - tool.parameters.properties[paramName] = { - type: paramSchema.type, - description: description - }; + // Merge path-level and operation-level parameters. + // Operation-level params override path-level params with the + // same (name, in) pair per the OpenAPI spec. + const opParams: any[] = Array.isArray((operation as any).parameters) + ? (operation as any).parameters + : []; + const mergedParams = new Map(); + for (const param of pathLevelParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } + for (const param of opParams) { + if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param); + } - if (param.required) { - tool.parameters.required.push(paramName); - } - }); + // Extract path and query parameters + for (const param of mergedParams.values()) { + const paramName = param?.name; + if (!paramName) continue; + const paramSchema = param?.schema ?? {}; + let description = paramSchema.description || param.description || ''; + if (paramSchema.enum && Array.isArray(paramSchema.enum)) { + description += `. Possible values: ${paramSchema.enum.join(', ')}`; + } + tool.parameters.properties[paramName] = { + type: paramSchema.type, + description: description + }; + + if (param.required) { + tool.parameters.required.push(paramName); + } } // Extract and recursively resolve requestBody if available - if (operation.requestBody) { - const content = operation.requestBody.content; + if ((operation as any).requestBody) { + const content = (operation as any).requestBody.content; if (content && content['application/json']) { const requestSchema = content['application/json'].schema; const resolvedRequestSchema = resolveSchema(requestSchema, openApiSpec.components); From a938c8ae2e45a00d2f06151fdaeaee94e54a8095 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 07:11:17 +0900 Subject: [PATCH 048/352] refac --- src/lib/components/chat/Chat.svelte | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index d469c58729..be5feab935 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -184,6 +184,12 @@ navigateHandler(); } + let saveControlsTimer; + $: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) { + clearTimeout(saveControlsTimer); + saveControlsTimer = setTimeout(saveControls, 400); + } + const navigateHandler = async () => { // Mark the outgoing chat as read before loading the new one. // $chatId still holds the previous chat here — loadChat() updates it. @@ -191,6 +197,8 @@ updateLastReadAt($chatId); } + clearTimeout(saveControlsTimer); + await saveControls(); loading = true; prompt = ''; @@ -811,6 +819,8 @@ return () => { try { + clearTimeout(saveControlsTimer); + saveControls(); if (chatIdProp && !$temporaryChatEnabled) { updateLastReadAt(chatIdProp); } @@ -2758,6 +2768,13 @@ } }; + const saveControls = async () => { + if (!$chatId || $temporaryChatEnabled) return; + await updateChatById(localStorage.token, $chatId, { params, files: chatFiles }).catch( + (err) => console.error('[controls autosave]', err) + ); + }; + const MAX_DRAFT_LENGTH = 5000; let saveDraftTimeout: ReturnType | null = null; From e1dce9914745de9b4d2c67b1deddde3472ce4dfa Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 07:13:36 +0900 Subject: [PATCH 049/352] refac --- src/lib/components/channel/Messages/Message.svelte | 2 ++ src/lib/components/chat/Messages/Markdown.svelte | 2 ++ .../chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte | 3 +++ .../components/chat/Messages/Markdown/MarkdownTokens.svelte | 2 ++ 4 files changed, 9 insertions(+) diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 6168eea3fd..a853a0e773 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -356,6 +356,7 @@
@@ -527,6 +528,7 @@ id={message.id} content={message.content} paragraphTag="span" + allowEmbeds={!!message?.meta?.model_id} />{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}({$i18n.t('edited')}){/if} diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index bfea80b80b..a37b3c6166 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -24,6 +24,7 @@ export let paragraphTag = 'p'; export let editCodeBlock = true; export let topPadding = false; + export let allowEmbeds = true; export let sourceIds = []; @@ -104,6 +105,7 @@ {editCodeBlock} {sourceIds} {topPadding} + {allowEmbeds} {onTaskClick} {onSourceClick} {onSave} diff --git a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte index 598511eb25..990740d133 100644 --- a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte +++ b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte @@ -30,6 +30,7 @@ }> = []; export let messageDone = true; + export let allowEmbeds = true; let open = $settings?.expandDetails ?? false; @@ -51,6 +52,8 @@ // Collect all embeds from tool_calls tokens $: allEmbeds = (() => { + if (!allowEmbeds) return []; + const result: Array<{ name: string; embed: string; args: string }> = []; for (const t of tokens) { if (t?.attributes?.type !== 'tool_calls') continue; diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index da4deaaa12..bb0df104f3 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte @@ -41,6 +41,7 @@ export let editCodeBlock = true; export let topPadding = false; + export let allowEmbeds = true; export let onSave: Function = () => {}; export let onUpdate: Function = () => {}; @@ -371,6 +372,7 @@ id={`${id}-${tokenIdx}-detail-group`} tokens={token.items} messageDone={done} + {allowEmbeds} >
{#each token.items as detailToken, detailIdx} From adda20509c3ec513d60661c80b7a81a681ab5996 Mon Sep 17 00:00:00 2001 From: looselyhuman Date: Fri, 8 May 2026 16:15:24 -0600 Subject: [PATCH 050/352] fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105) asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which violate anyio cancel-scope task-ownership rules. The MCPClient's exit_stack contains anyio resources (streamable_http transport) that use anyio cancel scopes. When exited from a different task, anyio raises 'Attempted to exit a cancel scope that isn't the current task's current cancel scope' as a BaseException. This BaseException propagates through the finally block, discards the completed response return value, and surfaces as a 500 Internal Server Error / 'No response returned.' - silently swallowing successful MCP tool calls and blocking the chat endpoint. Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect() already catches BaseException internally (see prior commit), so no wrapper is needed. Signed-off-by: Adam Tao Co-authored-by: Tim Baek Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com> Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Co-authored-by: Kylapaallikko Co-authored-by: Teay Co-authored-by: tcx4c70 Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com> Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com> Co-authored-by: RomualdYT Co-authored-by: Lucas Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com> Co-authored-by: Constantine Co-authored-by: Circe (Claude Code Sonnet 4.6) Co-authored-by: Claude --- backend/open_webui/main.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index c53382a758..aaa98ec7d4 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2040,25 +2040,28 @@ async def chat_completion( detail=error_detail, ) finally: - # MCP cleanup — MUST run in the SAME asyncio task as - # connect() because the MCP SDK's streamablehttp_client - # uses anyio task groups whose cancel scopes enforce - # same-task exit. Do NOT wrap in asyncio.shield() or - # asyncio.wait_for() — both create a new task. + # Clean up MCP clients. Each client is isolated so one + # failure doesn't skip the rest. + # + # NOTE: asyncio.wait_for() / asyncio.shield() must NOT be used + # here — they create new asyncio Tasks, which violate anyio + # cancel-scope task-ownership rules when the MCPClient's + # exit_stack contains anyio transport resources (streamable_http). + # Exiting those cancel scopes from the wrong task raises + # "Attempted to exit a cancel scope that isn't the current + # task's current cancel scope", which propagates as a + # BaseException through the finally block, discards the response + # return value, and surfaces as a 500 "No response returned." + # MCPClient.disconnect() already catches BaseException internally. try: if mcp_clients := metadata.get('mcp_clients'): for client in reversed(list(mcp_clients.values())): try: await client.disconnect() - except Exception as e: + except BaseException as e: log.debug(f'Error disconnecting MCP client: {e}') - except asyncio.CancelledError: - # Let the client close asynchronously by GC - pass - except Exception as e: + except BaseException as e: log.debug(f'Error cleaning up MCP clients: {e}') - except asyncio.CancelledError: - pass try: if metadata.get('chat_id'): From cfd2888545cd8cd45df1eb1dad36a7cc232bee3f Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sat, 9 May 2026 00:33:31 +0200 Subject: [PATCH 051/352] fix:image url validation and signout post (#24420) * refac(routers): reject external URLs in profile/model image handlers * refac(ui): centralize image URL validation in safeImageUrl helper * refac(auths): make signout POST-only * refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING Restore the 302 redirect for external http(s) profile image URLs in the user and model profile-image endpoints, but gate it behind a new ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True). Existing deployments that rely on external profile image forwarding continue to work unchanged. Operators who want to suppress the redirect (to prevent client-side IP/UA/Referer leaks) can set the flag to False. --- backend/open_webui/env.py | 13 ++++++++ backend/open_webui/routers/auths.py | 2 +- backend/open_webui/routers/models.py | 15 ++++++--- backend/open_webui/routers/users.py | 15 +++++---- src/lib/apis/auths/index.ts | 2 +- .../chat/Messages/ProfileImage.svelte | 10 ++---- src/lib/components/common/Image.svelte | 3 +- .../common/RichTextInput/Image/image.ts | 7 ++-- src/lib/utils/safeImageUrl.ts | 33 +++++++++++++++++++ 9 files changed, 75 insertions(+), 25 deletions(-) create mode 100644 src/lib/utils/safeImageUrl.ts diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index deca6193dd..d6cf24a83c 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -249,6 +249,19 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE = os.environ.get('ENABLE_STAR_SESSIONS_MIDDLEWAR ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true' +#################################### +# ENABLE_PROFILE_IMAGE_URL_FORWARDING +#################################### + +# When True (default), the user and model profile-image endpoints +# honour external http(s) URLs stored in profile_image_url by issuing a +# 302 redirect to the original origin. Set to False to suppress the +# redirect (prevents client-side IP/UA/Referer leaks to attacker- +# controlled origins) and fall through to the default image instead. +ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get( + 'ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True' +).lower() == 'true' + #################################### # WEBUI_BUILD_HASH #################################### diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index af0e455146..5c2be8f22d 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -785,7 +785,7 @@ async def signup( raise HTTPException(500, detail='An internal error occurred during signup.') -@router.get('/signout') +@router.post('/signout') async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)): # get auth token from headers or cookies token = None diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index a5f39c768f..1ced11b358 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -28,8 +28,8 @@ from fastapi import ( Depends, HTTPException, Request, - status, Response, + status, ) from fastapi.responses import RedirectResponse, StreamingResponse @@ -37,6 +37,7 @@ from fastapi.responses import RedirectResponse, StreamingResponse from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING from open_webui.internal.db import get_async_session from sqlalchemy.ext.asyncio import AsyncSession @@ -484,10 +485,14 @@ async def get_model_profile_image( if profile_image_url: if profile_image_url.startswith('http'): - return Response( - status_code=status.HTTP_302_FOUND, - headers={'Location': profile_image_url}, - ) + if ENABLE_PROFILE_IMAGE_URL_FORWARDING: + return Response( + status_code=status.HTTP_302_FOUND, + headers={'Location': profile_image_url}, + ) + # When forwarding is disabled, fall through to the + # default image to prevent client-side IP/UA/Referer + # leaks via 302 redirect to external origins. elif profile_image_url.startswith('data:image'): try: header, base64_data = profile_image_url.split(',', 1) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 2d204ac18f..bcf11936e2 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -29,7 +29,7 @@ from open_webui.models.users import ( ) from open_webui.constants import ERROR_MESSAGES -from open_webui.env import STATIC_DIR +from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, STATIC_DIR from open_webui.internal.db import get_async_session @@ -478,12 +478,15 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u user = await Users.get_user_by_id(user_id) if user: if user.profile_image_url: - # check if it's url or base64 if user.profile_image_url.startswith('http'): - return Response( - status_code=status.HTTP_302_FOUND, - headers={'Location': user.profile_image_url}, - ) + if ENABLE_PROFILE_IMAGE_URL_FORWARDING: + return Response( + status_code=status.HTTP_302_FOUND, + headers={'Location': user.profile_image_url}, + ) + # When forwarding is disabled, fall through to the + # default image to prevent client-side IP/UA/Referer + # leaks via 302 redirect to external origins. elif user.profile_image_url.startswith('data:image'): try: header, base64_data = user.profile_image_url.split(',', 1) diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index b8494ceedf..8f82f18b1b 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -328,7 +328,7 @@ export const userSignOut = async () => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, { - method: 'GET', + method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/lib/components/chat/Messages/ProfileImage.svelte b/src/lib/components/chat/Messages/ProfileImage.svelte index d837ab05ab..b4d9056442 100644 --- a/src/lib/components/chat/Messages/ProfileImage.svelte +++ b/src/lib/components/chat/Messages/ProfileImage.svelte @@ -1,5 +1,6 @@ diff --git a/src/lib/components/common/RichTextInput/Image/image.ts b/src/lib/components/common/RichTextInput/Image/image.ts index fbabbb83da..08e27fc088 100644 --- a/src/lib/components/common/RichTextInput/Image/image.ts +++ b/src/lib/components/common/RichTextInput/Image/image.ts @@ -1,4 +1,5 @@ import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core'; +import { safeImageUrl } from '$lib/utils/safeImageUrl'; export interface ImageOptions { /** @@ -137,12 +138,12 @@ export const Image = Node.create({ if (editorFiles && node.attrs.src.startsWith('data://')) { const file = editorFiles.find((f) => f.id === fileId); if (file) { - img.setAttribute('src', file.url || ''); + img.setAttribute('src', safeImageUrl(file.url || '')); } else { img.setAttribute('src', '/image-placeholder.png'); } } else { - img.setAttribute('src', node.attrs.src || ''); + img.setAttribute('src', safeImageUrl(node.attrs.src || '')); } img.setAttribute('alt', node.attrs.alt || ''); @@ -153,7 +154,7 @@ export const Image = Node.create({ if (files && node.attrs.src.startsWith('data://')) { const file = editorFiles.find((f) => f.id === fileId); if (file) { - img.setAttribute('src', file.url || ''); + img.setAttribute('src', safeImageUrl(file.url || '')); } else { img.setAttribute('src', '/image-placeholder.png'); } diff --git a/src/lib/utils/safeImageUrl.ts b/src/lib/utils/safeImageUrl.ts new file mode 100644 index 0000000000..82a834ca07 --- /dev/null +++ b/src/lib/utils/safeImageUrl.ts @@ -0,0 +1,33 @@ +import { WEBUI_BASE_URL } from '$lib/constants'; + +const PLACEHOLDER_IMAGE = '/favicon.png'; + +/** + * Validates an image URL against an allowlist of safe patterns and returns + * the URL if trusted, or a placeholder otherwise. + * + * Allowed patterns: + * - Relative paths (starting with '/') + * - data:image/* URIs + * - Same-origin URLs (starting with WEBUI_BASE_URL) + * - Gravatar URLs (https://www.gravatar.com/avatar/) + * + * All other URLs (including arbitrary http(s):// origins) are rejected to + * prevent client-side IP/UA/Referer leaks to attacker-controlled servers. + */ +export function safeImageUrl(url: string): string { + if (!url || url === '') { + return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`; + } + + if ( + url.startsWith(WEBUI_BASE_URL) || + url.startsWith('https://www.gravatar.com/avatar/') || + url.startsWith('data:') || + url.startsWith('/') + ) { + return url; + } + + return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`; +} From 11e076817ae5db34621ce03136353248f7377d97 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 07:34:46 +0900 Subject: [PATCH 052/352] refac --- backend/open_webui/models/knowledge.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 2750ef6058..e08e626981 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -4,7 +4,7 @@ import time from typing import Optional import uuid -from sqlalchemy import select, delete, update, or_, func +from sqlalchemy import select, delete, update, or_, func, cast from sqlalchemy.ext.asyncio import AsyncSession from open_webui.internal.db import Base, JSONField, get_async_db_context @@ -313,11 +313,16 @@ class KnowledgeTable: permission='read', ) - # Apply filename search + # Apply filename / content search if filter: q = filter.get('query') if q: - stmt = stmt.filter(File.filename.ilike(f'%{q}%')) + stmt = stmt.filter( + or_( + File.filename.ilike(f'%{q}%'), + cast(File.data['content'], Text).ilike(f'%{q}%'), + ) + ) # Order by file changes stmt = stmt.order_by(File.updated_at.desc(), File.id.asc()) @@ -467,7 +472,12 @@ class KnowledgeTable: if filter: query_key = filter.get('query') if query_key: - stmt = stmt.filter(or_(File.filename.ilike(f'%{query_key}%'))) + stmt = stmt.filter( + or_( + File.filename.ilike(f'%{query_key}%'), + cast(File.data['content'], Text).ilike(f'%{query_key}%'), + ) + ) view_option = filter.get('view_option') if view_option == 'created': From 85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 07:37:53 +0900 Subject: [PATCH 053/352] refac --- backend/open_webui/utils/tools.py | 7 +++++++ src/lib/utils/index.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 6518d2607f..20e8ce365c 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -802,6 +802,13 @@ def resolve_schema(schema, components, resolved_schemas=None): if 'items' in resolved_schema: resolved_schema['items'] = resolve_schema(resolved_schema['items'], components) + # Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref + for keyword in ('oneOf', 'anyOf', 'allOf'): + if keyword in resolved_schema and isinstance(resolved_schema[keyword], list): + resolved_schema[keyword] = [ + resolve_schema(inner, components, resolved_schemas) for inner in resolved_schema[keyword] + ] + return resolved_schema diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 81a10cebfd..93c94fefc9 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1377,9 +1377,35 @@ function resolveSchema(schemaRef, components, resolvedSchemas = new Set()) { // for primitive types (string, integer, etc.), just use as is break; } + + // Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref + for (const keyword of ['oneOf', 'anyOf', 'allOf']) { + if (Array.isArray(schemaRef[keyword])) { + schemaObj[keyword] = schemaRef[keyword].map((inner) => + resolveSchema(inner, components, resolvedSchemas) + ); + } + } + return schemaObj; } + // Handle schemas that only have composition keywords without an explicit type + const compositionObj: Record = {}; + let hasComposition = false; + for (const keyword of ['oneOf', 'anyOf', 'allOf']) { + if (Array.isArray(schemaRef[keyword])) { + compositionObj[keyword] = schemaRef[keyword].map((inner) => + resolveSchema(inner, components, resolvedSchemas) + ); + hasComposition = true; + } + } + if (hasComposition) { + if (schemaRef.description) compositionObj.description = schemaRef.description; + return compositionObj; + } + // fallback for schemas without explicit type return {}; } From 485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 07:52:15 +0900 Subject: [PATCH 054/352] refac --- backend/open_webui/models/chat_messages.py | 75 ++++++++++++++++++++++ backend/open_webui/models/chats.py | 12 ++++ 2 files changed, 87 insertions(+) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index b1768d0ff2..44349c1584 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -241,6 +241,81 @@ class ChatMessageTable: messages = result.scalars().all() return [ChatMessageModel.model_validate(message) for message in messages] + # DB column names that differ from the JSON message keys. + DB_TO_JSON_KEY_MAP = { + 'parent_id': 'parentId', + 'model_id': 'model', + 'status_history': 'statusHistory', + 'created_at': 'timestamp', + } + # DB-internal columns excluded from the reconstructed message dict. + EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'}) + + async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]: + """Build a {message_id: message_dict} map from chat_message rows. + + Returns the same shape as chat.history.messages so callers + (get_message_list, middleware) work unchanged. Returns None if + no rows exist for the chat (caller should fall back to the + embedded JSON blob for legacy chats). + """ + async with get_async_db_context(db) as db: + result = await db.execute( + select(ChatMessage).filter_by(chat_id=chat_id) + ) + rows = result.scalars().all() + + if not rows: + return None + + # Strip the composite-id prefix ("{chat_id}-") to recover the + # original message_id used as map key. + prefix = f'{chat_id}-' + prefix_len = len(prefix) + col_keys = [c.key for c in ChatMessage.__table__.columns] + + messages_map: dict[str, dict] = {} + for row in rows: + msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id + + msg: dict = {'id': msg_id} + for key in col_keys: + if key in self.EXCLUDED_COLUMNS: + continue + val = getattr(row, key) + if val is None: + continue + json_key = self.DB_TO_JSON_KEY_MAP.get(key, key) + msg[json_key] = val + + # Ensure content always has a value + msg.setdefault('content', '') + + # Mirror usage into info.usage for callers that read it there + if 'usage' in msg: + msg['info'] = {'usage': msg['usage']} + + messages_map[msg_id] = msg + + # Reconstruct childrenIds from parentId links so that the map + # is fully navigable (callers like the frontend rely on this). + for msg_id, msg in messages_map.items(): + parent_id = msg.get('parentId') + if parent_id and parent_id in messages_map: + parent = messages_map[parent_id] + children = parent.get('childrenIds') + if children is None: + parent['childrenIds'] = [msg_id] + elif msg_id not in children: + children.append(msg_id) + + # Ensure every message has a childrenIds list (leaf nodes get []) + for msg in messages_map.values(): + if 'childrenIds' not in msg: + msg['childrenIds'] = [] + + return messages_map + async def get_messages_by_user_id( self, user_id: str, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 4dc00e9f84..af999f21cd 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -460,6 +460,18 @@ class ChatTable: return row[0] or 'New Chat' async def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: + """Message map for walking history (see ``get_message_list``). + + Prefer ``chat_message`` rows to avoid loading the large ``chat`` + JSON blob; fall back to embedded history when no rows exist + (legacy chats). + """ + # Fast path: build from normalized chat_message rows. + messages_map = await ChatMessages.get_messages_map_by_chat_id(id) + if messages_map is not None: + return messages_map + + # No rows — fall back to the embedded JSON blob for legacy chats. chat = await self.get_chat_by_id(id) if chat is None: return None From 04bd0425ead28185bcd124e77892e31209a6e15b Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 07:56:58 +0900 Subject: [PATCH 055/352] refac --- backend/open_webui/main.py | 32 +++++++++++++++------------- src/lib/components/chat/Chat.svelte | 33 +++++++++++++++-------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index aaa98ec7d4..8706f439c8 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -583,6 +583,8 @@ from open_webui.utils.redis import get_redis_connection from open_webui.tasks import ( redis_task_command_listener, list_task_ids_by_item_id, + has_active_tasks, + cleanup_task, create_task, stop_task, stop_item_tasks, @@ -2063,22 +2065,21 @@ async def chat_completion( except BaseException as e: log.debug(f'Error cleaning up MCP clients: {e}') + # Deregister this task, then emit chat:active=false if no others remain try: - if metadata.get('chat_id'): - - async def emit_inactive_event(): - try: - event_emitter = await get_event_emitter(metadata, update_db=False) - if event_emitter: - await event_emitter({'type': 'chat:active', 'data': {'active': False}}) - except Exception: - pass - - try: - # Shield the event emission so it finishes even if the main task is cancelled - await asyncio.shield(emit_inactive_event()) - except asyncio.CancelledError: - pass + chat_id = metadata.get('chat_id') + task_id = metadata.get('task_id') + if chat_id and task_id: + await cleanup_task(request.app.state.redis, task_id, chat_id) + if not await has_active_tasks(request.app.state.redis, chat_id): + event_emitter = await get_event_emitter(metadata, update_db=False) + if event_emitter: + try: + await asyncio.shield( + event_emitter({'type': 'chat:active', 'data': {'active': False}}) + ) + except asyncio.CancelledError: + pass except Exception: pass @@ -2128,6 +2129,7 @@ async def chat_completion( ), id=chat_id, ) + per_model_metadata['task_id'] = task_id task_ids.append(task_id) # Emit chat:active=true diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index be5feab935..d444ca450d 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1398,6 +1398,7 @@ autoScroll = true; await tick(); + // Mark all non-current assistant messages as done if (history.currentId) { for (const message of Object.values(history.messages)) { if ( @@ -1411,23 +1412,23 @@ } } - const taskRes = await getTaskIdsByChatId(localStorage.token, $chatId).catch((error) => { - return null; - }); - - if (taskRes) { - taskIds = taskRes.task_ids; - } - - // If no active tasks and current message is incomplete, generation was interrupted + // Reconcile active tasks with message state: + // If the response is already done, remaining tasks are just background + // work (follow-ups, title gen) that shouldn't block the input. + const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId) + .then((res) => res?.task_ids ?? []) + .catch(() => []); const currentMessage = history.currentId ? history.messages[history.currentId] : null; - if ( - currentMessage && - currentMessage.role === 'assistant' && - !currentMessage.done && - (!taskIds || taskIds.length === 0) - ) { - currentMessage.done = true; + const responseComplete = currentMessage?.role === 'assistant' && currentMessage?.done; + + if (pendingTaskIds.length > 0 && !responseComplete) { + taskIds = pendingTaskIds; + } else { + taskIds = null; + // No active tasks and message incomplete → generation was interrupted + if (currentMessage?.role === 'assistant' && !currentMessage.done) { + currentMessage.done = true; + } } await tick(); From 3fcad2f627b96976bdf4498e9f1be6ea3fbca902 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 08:28:29 +0900 Subject: [PATCH 056/352] refac --- backend/open_webui/routers/chats.py | 9 + src/lib/components/chat/Messages.svelte | 20 +- .../chat/Messages/OutputEditView.svelte | 367 ++++++++++++++++++ .../chat/Messages/ResponseMessage.svelte | 115 ++++-- 4 files changed, 473 insertions(+), 38 deletions(-) create mode 100644 src/lib/components/chat/Messages/OutputEditView.svelte diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 9862b1edf5..6c1e2119a0 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -8,6 +8,7 @@ from fastapi.responses import StreamingResponse from open_webui.utils.misc import get_message_list +from open_webui.utils.middleware import serialize_output from open_webui.socket.main import get_event_emitter from open_webui.models.chats import ( ChatForm, @@ -967,6 +968,14 @@ async def update_chat_by_id( chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: updated_chat = {**chat.chat, **form_data.chat} + + # Re-derive content from output for assistant messages so that + # frontend edits to output items are always reflected in content. + # serialize_output() is the single source of truth for this conversion. + for msg in updated_chat.get('history', {}).get('messages', {}).values(): + if msg.get('role') == 'assistant' and msg.get('output'): + msg['content'] = serialize_output(msg['output']) + chat = await Chats.update_chat_by_id(id, updated_chat, db=db) return ChatResponse(**chat.model_dump()) else: diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index cf512dccfb..ea6c89034d 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -159,11 +159,21 @@ if (!$temporaryChatEnabled) { history = history; await tick(); - await updateChatById(localStorage.token, chatId, { + const res = await updateChatById(localStorage.token, chatId, { history: history, messages: messages }); + // Refresh local message content from backend (e.g. re-derived via serialize_output) + if (res?.chat?.history?.messages) { + for (const [id, msg] of Object.entries(res.chat.history.messages)) { + if (history.messages[id] && (msg as any).content) { + history.messages[id].content = (msg as any).content; + } + } + history = history; + } + currentChatPage.set(1); await chats.set(await getChatList(localStorage.token, $currentChatPage)); } @@ -317,7 +327,7 @@ await updateChat(); }; - const editMessage = async (messageId, { content, files }, submit = true) => { + const editMessage = async (messageId, { content, files, output = undefined }, submit = true) => { if ((selectedModels ?? []).filter((id) => id).length === 0) { toast.error($i18n.t('Model not selected')); return; @@ -361,7 +371,7 @@ } } else { if (submit) { - // New response message + // New response message (Save As Copy) const responseMessageId = uuidv4(); const message = history.messages[messageId]; const parentId = message.parentId; @@ -373,6 +383,7 @@ childrenIds: [], files: undefined, content: content, + output: output ?? undefined, timestamp: Math.floor(Date.now() / 1000) // Unix epoch }; @@ -392,6 +403,9 @@ // Edit response message history.messages[messageId].originalContent = history.messages[messageId].content; history.messages[messageId].content = content; + if (output !== undefined) { + history.messages[messageId].output = output; + } await updateChat(); } } diff --git a/src/lib/components/chat/Messages/OutputEditView.svelte b/src/lib/components/chat/Messages/OutputEditView.svelte new file mode 100644 index 0000000000..8a58feb220 --- /dev/null +++ b/src/lib/components/chat/Messages/OutputEditView.svelte @@ -0,0 +1,367 @@ + + +
+ +
+ + + +
+ + {#if viewMode === 'json'} +
+ {#if jsonError} +
{jsonError}
+ {/if} + {:else} + +
+ {#each displayItems as di, idx} +
+ +
+
+ {getItemLabel(di)} +
+
+ + +
+ {#if di.type === 'message'} +