From 5bc80b145f87a6e0fe5309fd45ae12d280a1d93c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 4 May 2026 23:57:20 +0900 Subject: [PATCH 001/219] refac --- static/pyodide/pyodide-lock.json | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/static/pyodide/pyodide-lock.json b/static/pyodide/pyodide-lock.json index 440679ecbf..61d7ebcc9f 100644 --- a/static/pyodide/pyodide-lock.json +++ b/static/pyodide/pyodide-lock.json @@ -4987,10 +4987,10 @@ }, "pathspec": { "name": "pathspec", - "version": "1.1.0", - "file_name": "pathspec-1.1.0-py3-none-any.whl", + "version": "1.1.1", + "file_name": "pathspec-1.1.1-py3-none-any.whl", "install_dir": "site", - "sha256": "574b128f7456bd899045ccd142dd446af7e6cfd0072d63ad73fbc55fbb4aaa42", + "sha256": "a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", "package_type": "package", "imports": [ "pathspec" @@ -5008,6 +5008,18 @@ "mypy_extensions" ], "depends": [] + }, + "pytokens": { + "name": "pytokens", + "version": "0.4.1", + "file_name": "pytokens-0.4.1-py3-none-any.whl", + "install_dir": "site", + "sha256": "26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", + "package_type": "package", + "imports": [ + "pytokens" + ], + "depends": [] } } } \ No newline at end of file From 86df8bf27e1b84abbe2eeedcc8650df59c7d23d6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 5 May 2026 02:41:22 +0900 Subject: [PATCH 002/219] refac --- src/lib/components/chat/Chat.svelte | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 03af994a68..1468d6b349 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2416,6 +2416,22 @@ window.history.replaceState(history.state, '', `/c/${res.chat_id}`); currentChatPage.set(1); 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 + ) { + await updateChatById(localStorage.token, res.chat_id, { + params: params, + files: chatFiles + }); + } } } } From cde21b9f6dc11575a668484f42440824ec5a4fae Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 5 May 2026 03:33:47 +0900 Subject: [PATCH 003/219] refac --- backend/open_webui/routers/chats.py | 43 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 7cf125f7c7..15a2876b16 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -829,29 +829,31 @@ async def get_shared_chat_by_id( if user.role == 'pending': raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: + chat = await Chats.get_chat_by_share_id(share_id, db=db) + + # Fallback: admins can also access any chat directly by chat ID + if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(share_id, db=db) - else: - chat = await Chats.get_chat_by_share_id(share_id, db=db) if not chat: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - # Look up the original chat_id to check access grants - shared = await SharedChats.get_by_id(share_id, db=db) - if shared: - has_grant = await AccessGrants.has_access( - user_id=user.id, - resource_type='shared_chat', - resource_id=shared.chat_id, - permission='read', - db=db, - ) - if not has_grant: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + # Look up the original chat_id to check access grants (admins bypass) + if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS: + shared = await SharedChats.get_by_id(share_id, db=db) + if shared: + has_grant = await AccessGrants.has_access( + user_id=user.id, + resource_type='shared_chat', + resource_id=shared.chat_id, + permission='read', + db=db, ) + if not has_grant: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) return ChatResponse(**chat.model_dump()) @@ -1183,10 +1185,11 @@ async def clone_chat_by_id( async def clone_shared_chat_by_id( id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) ): - if user.role == 'admin': + chat = await Chats.get_chat_by_share_id(id, db=db) + + # Fallback: admins can also access any chat directly by chat ID + if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = await Chats.get_chat_by_id(id, db=db) - else: - chat = await Chats.get_chat_by_share_id(id, db=db) if not chat: raise HTTPException( From 4e6a7baab7595f230a371f71a90f6e9512bf589e Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 4 May 2026 20:45:57 +0200 Subject: [PATCH 004/219] Merge pull request #24356 from Classic298/patch-1 doc/chore: Update SECURITY.md --- docs/SECURITY.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 1e310a9b79..dc3c640b98 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -16,13 +16,26 @@ Based on a precedent of an unacceptable degree of spamming and unsolicited commu Any reports or solicitations arriving from sources other than our designated GitHub repository will be dismissed without consideration. We’ve seen how external engagements can dilute and compromise the integrity of community-driven projects, and we’re not here to gamble with the security and privacy of our user community. +## Foreign CNAs and Vendor Disposition + +When a report is filed via GitHub Security Advisories and the maintainers close it as out-of-scope per this policy, that closure is the **vendor's disposition** of the issue. A CVE Numbering Authority (CNA) that mints a CVE for such an issue without reflecting that vendor disposition in the resulting record is acting against vendor disposition. + +We respond to such records by: + +1. Filing a **REJECT** request with the CVE Program (with **DISPUTED** as fallback); +2. Cataloging the record publicly, naming the issuing CNA; +3. Refusing to provide vendor statements, version mappings, fix references, or any other coordination that would lend authority to the record; +4. Escalating repeated patterns from a single CNA to the CVE Program Root. + +**Channel compliance does not entitle a CNA to override vendor disposition.** Reporters who escalate a closed-as-out-of-scope GHSA report to a third-party CNA after vendor disposition has been issued are likewise considered to have acted against vendor disposition, and will be permanently barred from future GHSA submissions. + ## Reporting a Vulnerability Reports not submitted through our designated GitHub repository will be disregarded, and we will categorically reject invitations to collaborate on external platforms. Our aggressive stance on this matter underscores our commitment to a secure, transparent, and open community where all operations are visible and contributors are accountable. We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. Ensure that **submissions are constructive, actionable, reproducible, well documented and adhere to the following guidelines**: -1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**. +1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**. A vulnerability must cross at least one of the security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation). **These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them.** 2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted. @@ -33,7 +46,7 @@ We appreciate the community's interest in identifying potential vulnerabilities. > [!NOTE] > A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show: > -> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation) +> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them) > 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.) > 3. What actions the attacker can now perform > 4. What data/action becomes possible that should not be possible @@ -105,7 +118,14 @@ Your remediation guidance can include, for example: > - wrote comments with conflicting information > - used illogical and conflicting arguments -**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. +12. **Self-Affecting Issues Are Not Vulnerabilities:** A vulnerability requires crossing a security boundary that affects **a party other than the reporter**. Crossing one of the five recognized security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them) only against the reporter's own data, account, session, or environment is **not a vulnerability** - it is a bug, and belongs in the [Issue Tracker](https://github.com/open-webui/open-webui/issues), not in a security report. + +> [!NOTE] +> This rule is about **who is harmed**, not about severity. A user modifying or deleting their own data, impairing their own session, observing their own configuration, or disabling security controls on their own account is out of scope under this rule, regardless of impact. +> +> If the same action also affects another user, the operator, the host system, or shared resources, identify that second party clearly in the PoC, and we want to hear about it. + +**Non-compliant submissions will be closed, and repeat or extreme violators may be banned from submitting reports.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. ## Where to report the vulnerability @@ -114,7 +134,7 @@ If you feel like you are not able to follow ALL outlined requirements for vulner ## Expected Response Timeframe -Due to the volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by a large number of invalid AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities. +Due to the very high volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by an unbelievably high number of AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities. **Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means we have not yet had the capacity to address it. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding. @@ -157,4 +177,4 @@ For any other immediate concerns and questions, please create an issue in our [i --- -_Last updated on **2026-03-20**._ +_Last updated on **2026-05-04**._ From 989d5fd4e2ce285edf4475a1e13f0981a78d3821 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 5 May 2026 04:05:15 +0900 Subject: [PATCH 005/219] refac --- backend/open_webui/utils/middleware.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 5b1da36d37..f3e829f22c 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -3945,6 +3945,12 @@ async def streaming_chat_response_handler(response, ctx): response_id = response_metadata.pop('response_id', None) if response_id: last_response_id = response_id + + # Normalize and capture usage for DB persistence + if response_metadata.get('usage'): + response_metadata['usage'] = normalize_usage(response_metadata['usage']) + usage = response_metadata['usage'] + processed_data.update(response_metadata) processed_data.pop('done', None) From a32d26e61d24d9f63650faed5cb8909ed90af661 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 5 May 2026 04:21:23 +0900 Subject: [PATCH 006/219] refac --- backend/open_webui/models/chat_messages.py | 49 ++++++++++------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 0758e0354d..b1768d0ff2 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -48,6 +48,25 @@ def get_usage(data: dict) -> Optional[dict]: return normalize_usage(usage) if usage else None +def _token_columns(dialect: str): + """Return (input_tokens, output_tokens) SQL column expressions. + + Falls back to OpenAI-style keys (prompt_tokens / completion_tokens) + when the normalized keys are absent. + """ + if dialect == 'sqlite': + extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer) + elif dialect == 'postgresql': + extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer) + else: + raise NotImplementedError(f'Unsupported dialect: {dialect}') + + return ( + func.coalesce(extract('input_tokens'), extract('prompt_tokens')), + func.coalesce(extract('output_tokens'), extract('completion_tokens')), + ) + + #################### # ChatMessage DB Schema #################### @@ -343,20 +362,7 @@ class ChatMessageTable: bind = await db.connection() dialect = bind.dialect.name - if dialect == 'sqlite': - input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer) - output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer) - elif dialect == 'postgresql': - input_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'input_tokens'), - Integer, - ) - output_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'output_tokens'), - Integer, - ) - else: - raise NotImplementedError(f'Unsupported dialect: {dialect}') + input_tokens, output_tokens = _token_columns(dialect) stmt = select( ChatMessage.model_id, @@ -404,20 +410,7 @@ class ChatMessageTable: bind = await db.connection() dialect = bind.dialect.name - if dialect == 'sqlite': - input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer) - output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer) - elif dialect == 'postgresql': - input_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'input_tokens'), - Integer, - ) - output_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, 'output_tokens'), - Integer, - ) - else: - raise NotImplementedError(f'Unsupported dialect: {dialect}') + input_tokens, output_tokens = _token_columns(dialect) stmt = select( ChatMessage.user_id, From 4fe2de78643c2213652190d2820f4e8d9f4f89cc Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:13:16 +0900 Subject: [PATCH 007/219] refac --- backend/open_webui/main.py | 100 ++++++++++++++++++ backend/open_webui/routers/openai.py | 43 ++++++++ backend/open_webui/utils/models.py | 3 + src/lib/apis/index.ts | 33 ++++++ src/lib/components/AddConnectionModal.svelte | 32 +++--- .../chat/ModelSelector/ModelItem.svelte | 41 +++---- .../chat/ModelSelector/Selector.svelte | 3 +- src/routes/+layout.svelte | 3 +- 8 files changed, 222 insertions(+), 36 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index af570af0af..ba503b40a9 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1524,6 +1524,106 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)): return {'data': models} +class ModelUnloadForm(BaseModel): + model: str + + +@app.post('/api/models/unload') +async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)): + """ + Unified model unload endpoint. + Resolves the provider that owns the model and calls its native unload mechanism. + Supports: Ollama (keep_alive=0) and llama.cpp (/models/unload). + """ + model_id = form_data.model + + # --- Ollama provider --- + ollama_models = getattr(request.app.state, 'OLLAMA_MODELS', None) or {} + if model_id in ollama_models: + url_indices = ollama_models[model_id].get('urls', []) + errors = [] + for idx in url_indices: + url = request.app.state.config.OLLAMA_BASE_URLS[idx] + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(idx), + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), + ) + key = api_config.get('key', None) + + prefix_id = api_config.get('prefix_id', None) + actual_model = model_id + if prefix_id and actual_model.startswith(f'{prefix_id}.'): + actual_model = actual_model[len(f'{prefix_id}.'):] + + payload = json.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''}) + + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + 'Content-Type': 'application/json', + **({"Authorization": f"Bearer {key}"} if key else {}), + } + async with session.post( + f'{url}/api/generate', + data=payload, + headers=headers, + ) as r: + if not r.ok: + errors.append({'url_idx': idx, 'error': await r.text()}) + except Exception as e: + log.exception(f'Failed to unload model on Ollama node {idx}: {e}') + errors.append({'url_idx': idx, 'error': str(e)}) + + if errors: + raise HTTPException( + status_code=500, + detail=f'Failed to unload model on {len(errors)} node(s): {errors}', + ) + return {'status': True} + + # --- OpenAI-compatible providers --- + openai_models = getattr(request.app.state, 'OPENAI_MODELS', None) or {} + if model_id in openai_models: + model_info = openai_models[model_id] + idx = model_info.get('urlIdx') + api_config = request.app.state.config.OPENAI_API_CONFIGS.get(str(idx), {}) + provider = api_config.get('provider', '') + base_url = request.app.state.config.OPENAI_API_BASE_URLS[idx] + key = request.app.state.config.OPENAI_API_KEYS[idx] if idx < len(request.app.state.config.OPENAI_API_KEYS) else '' + + if provider == 'llama.cpp': + root_url = base_url.rstrip('/').removesuffix('/v1') + try: + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: + headers = { + 'Content-Type': 'application/json', + **({"Authorization": f"Bearer {key}"} if key else {}), + } + async with session.post( + f'{root_url}/models/unload', + json={'model': model_id}, + headers=headers, + ) as r: + if not r.ok: + detail = await r.text() + raise HTTPException(status_code=r.status, detail=detail) + return await r.json() + except HTTPException: + raise + except Exception as e: + log.exception(f'Failed to unload model via llama.cpp: {e}') + raise HTTPException(status_code=500, detail=str(e)) + else: + raise HTTPException( + status_code=400, + detail=f'Provider "{provider or "default"}" does not support model unloading', + ) + + raise HTTPException(status_code=404, detail=f'Model "{model_id}" not found') + + ################################## # Embeddings ################################## diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index 6f8c0f81bf..ab2eec9527 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -439,6 +439,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: connection_type = api_config.get('connection_type', 'external') prefix_id = api_config.get('prefix_id', None) tags = api_config.get('tags', []) + provider = api_config.get('provider', '') model_list = response if isinstance(response, list) else response.get('data', []) if not isinstance(model_list, list): @@ -459,6 +460,9 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: if connection_type: model['connection_type'] = connection_type + if provider: + model['provider'] = provider + log.debug(f'get_all_models:responses() {responses}') return responses @@ -488,6 +492,41 @@ async def get_filtered_models(models, user, db=None): return filtered_models +async def get_openai_loaded_models(request: Request, models: dict, api_base_urls: list): + """ + Fetch loaded-model state from providers that expose it and annotate + each model dict with a ``loaded`` boolean. + + Currently supports: + - **llama.cpp** – queries ``GET /slots`` and matches slot model IDs. + """ + api_configs = request.app.state.config.OPENAI_API_CONFIGS + api_keys = request.app.state.config.OPENAI_API_KEYS + + for idx, url in enumerate(api_base_urls): + api_config = api_configs.get( + str(idx), + api_configs.get(url, {}), + ) + provider = api_config.get('provider', '') + + if provider == 'llama.cpp': + try: + root_url = url.rstrip('/').removesuffix('/v1') + key = api_keys[idx] if idx < len(api_keys) else None + slots = await send_get_request(url=f'{root_url}/slots', key=key) + loaded_model_ids = ( + {s.get('model') for s in slots if s.get('model')} + if isinstance(slots, list) + else set() + ) + for model_id, model in models.items(): + if model.get('urlIdx') == idx: + model['loaded'] = model_id in loaded_model_ids + except Exception as e: + log.debug(f'Failed to fetch llama.cpp slots for idx {idx}: {e}') + + @cached( ttl=MODELS_CACHE_TTL, key=lambda _, user: f'openai_all_models_{user.id}' if user else 'openai_all_models', @@ -548,6 +587,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: 'owned_by': 'openai', 'openai': model, 'connection_type': model.get('connection_type', 'external'), + 'provider': model.get('provider', ''), 'urlIdx': idx, } @@ -556,6 +596,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: models = get_merged_models(map(extract_data, responses)) log.debug(f'models: {models}') + # Fetch loaded state for providers that support it (e.g. llama.cpp /slots) + await get_openai_loaded_models(request, models, api_base_urls) + request.app.state.OPENAI_MODELS = models return {'data': list(models.values())} diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index cc8c5fad3a..e9201bb621 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -47,6 +47,7 @@ async def fetch_ollama_models(request: Request, user: UserModel = None): 'created': int(time.time()), 'owned_by': 'ollama', 'ollama': model, + 'loaded': 'expires_at' in model, 'connection_type': model.get('connection_type', 'local'), 'tags': model.get('tags', []), } @@ -199,6 +200,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) 'connection_type': connection_type, 'preset': True, **({'pipe': pipe} if pipe is not None else {}), + **({'provider': base_model.get('provider')} if base_model and base_model.get('provider') else {}), + **({'loaded': base_model.get('loaded')} if base_model and base_model.get('loaded') is not None else {}), } info = custom_model.model_dump() diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 5faf56d4d4..833979ada0 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -159,6 +159,39 @@ export const getModels = async ( return models; }; +export const unloadModel = async (token: string, model: string) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/models/unload`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify({ model }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + type ChatCompletedForm = { model: string; messages: Record[]; diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index ae1d353642..d1f04f3e0a 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -36,9 +36,10 @@ let auth_type = 'bearer'; let connectionType = 'external'; - let azure = false; + let provider = ''; $: azure = - (url.includes('azure.') || url.includes('cognitive.microsoft.com')) && !direct ? true : false; + provider === 'azure' || + ((url.includes('azure.') || url.includes('cognitive.microsoft.com')) && !direct && provider === ''); let prefixId = ''; let enable = true; @@ -98,7 +99,7 @@ key, config: { auth_type, - azure: azure, + ...(provider ? { provider } : azure ? { azure: true } : {}), api_version: apiVersion, ...(_headers ? { headers: _headers } : {}) } @@ -186,7 +187,8 @@ connection_type: connectionType, auth_type, headers: headers ? JSON.parse(headers) : undefined, - ...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}), + ...(provider ? { provider } : !ollama && azure ? { azure: true } : {}), + ...(azure ? { api_version: apiVersion } : {}), ...(apiType ? { api_type: apiType } : {}) } }; @@ -223,7 +225,7 @@ connectionType = connection.config?.connection_type ?? 'local'; } else { connectionType = connection.config?.connection_type ?? 'external'; - azure = connection.config?.azure ?? false; + provider = connection.config?.provider ?? (connection.config?.azure ? 'azure' : ''); apiVersion = connection.config?.api_version ?? ''; apiType = connection.config?.api_type ?? ''; } @@ -491,22 +493,22 @@ {#if !ollama && !direct}
{$i18n.t('Provider')}
-
{/if} diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index cd5fe453c3..8f46619868 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -120,25 +120,28 @@ {/if} - {#if item.model.ollama?.expires_at && new Date(item.model.ollama?.expires_at * 1000) > new Date()} -
- + new Date() + ? `${$i18n.t('Unloads {{FROM_NOW}}', { FROM_NOW: dayjs(item.model.ollama?.expires_at * 1000).fromNow() - })}`} - className="self-end" - > -
- - - - -
-
-
- {/if} + })}` + : `${$i18n.t('Loaded')}`} + className="self-end" + > +
+ + + + +
+ + {/if} @@ -233,7 +236,7 @@
- {#if $user?.role === 'admin' && item.model.owned_by === 'ollama' && item.model.ollama?.expires_at && new Date(item.model.ollama?.expires_at * 1000) > new Date()} + {#if $user?.role === 'admin' && item.model.loaded} Date: Sat, 9 May 2026 01:17:33 +0900 Subject: [PATCH 008/219] refac --- src/lib/components/chat/MessageInput/CallOverlay.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/components/chat/MessageInput/CallOverlay.svelte b/src/lib/components/chat/MessageInput/CallOverlay.svelte index d5977c0eb0..58ac2a6663 100644 --- a/src/lib/components/chat/MessageInput/CallOverlay.svelte +++ b/src/lib/components/chat/MessageInput/CallOverlay.svelte @@ -63,7 +63,12 @@ console.log(videoInputDevices); if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) { - selectedVideoInputDeviceId = videoInputDevices[0].deviceId; + const savedDeviceId = localStorage.getItem('selectedVideoInputDeviceId'); + if (savedDeviceId && videoInputDevices.some((d) => d.deviceId === savedDeviceId)) { + selectedVideoInputDeviceId = savedDeviceId; + } else { + selectedVideoInputDeviceId = videoInputDevices[0].deviceId; + } } }; @@ -890,6 +895,7 @@ on:change={async (e) => { console.log(e.detail); selectedVideoInputDeviceId = e.detail; + localStorage.setItem('selectedVideoInputDeviceId', e.detail); await stopVideoStream(); await startVideoStream(); }} From 4754ece4a2de5bba85a1d53af2dc8d24fdfb58be Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:17:57 +0900 Subject: [PATCH 009/219] refac --- backend/open_webui/config.py | 13 +++++++++++++ backend/open_webui/main.py | 10 +++++++++- backend/open_webui/routers/audio.py | 13 ++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 06178d385c..1699129f2a 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -3947,6 +3947,19 @@ AUDIO_STT_SUPPORTED_CONTENT_TYPES = PersistentConfig( ], ) +AUDIO_STT_ALLOWED_EXTENSIONS = PersistentConfig( + 'AUDIO_STT_ALLOWED_EXTENSIONS', + 'audio.stt.allowed_extensions', + [ + ext.strip() + for ext in os.environ.get( + 'AUDIO_STT_ALLOWED_EXTENSIONS', + 'mp3,wav,m4a,webm,ogg,flac,mp4,mpga,mpeg', + ).split(',') + if ext.strip() + ], +) + AUDIO_STT_AZURE_API_KEY = PersistentConfig( 'AUDIO_STT_AZURE_API_KEY', 'audio.stt.azure.api_key', diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index ba503b40a9..e4ee823b4f 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -199,6 +199,7 @@ from open_webui.config import ( AUDIO_STT_ENGINE, AUDIO_STT_MODEL, AUDIO_STT_SUPPORTED_CONTENT_TYPES, + AUDIO_STT_ALLOWED_EXTENSIONS, AUDIO_STT_OPENAI_API_BASE_URL, AUDIO_STT_OPENAI_API_KEY, AUDIO_STT_AZURE_API_KEY, @@ -1289,6 +1290,7 @@ app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = IMAGES_EDIT_COMFYUI_WORKFL app.state.config.STT_ENGINE = AUDIO_STT_ENGINE app.state.config.STT_MODEL = AUDIO_STT_MODEL app.state.config.STT_SUPPORTED_CONTENT_TYPES = AUDIO_STT_SUPPORTED_CONTENT_TYPES +app.state.config.STT_ALLOWED_EXTENSIONS = AUDIO_STT_ALLOWED_EXTENSIONS app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY @@ -2876,7 +2878,13 @@ async def serve_cache_file( raise HTTPException(status_code=404, detail='File not found') if not os.path.isfile(file_path): raise HTTPException(status_code=404, detail='File not found') - return FileResponse(file_path) + + mime, _ = mimetypes.guess_type(file_path) + inline_safe = mime and mime.split('/', 1)[0] in {'image', 'audio', 'video'} + headers = {'X-Content-Type-Options': 'nosniff'} + if not inline_safe: + headers['Content-Disposition'] = f'attachment; filename="{os.path.basename(file_path)}"' + return FileResponse(file_path, headers=headers) def swagger_ui_html(*args, **kwargs): diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index c69be124e5..c653a370e0 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -178,6 +178,7 @@ class STTConfigForm(BaseModel): ENGINE: str MODEL: str SUPPORTED_CONTENT_TYPES: list[str] = [] + ALLOWED_EXTENSIONS: list[str] = [] WHISPER_MODEL: str DEEPGRAM_API_KEY: str AZURE_API_KEY: str @@ -219,6 +220,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)): 'ENGINE': request.app.state.config.STT_ENGINE, 'MODEL': request.app.state.config.STT_MODEL, 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, + 'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS, 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, @@ -254,6 +256,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm request.app.state.config.STT_ENGINE = form_data.stt.ENGINE request.app.state.config.STT_MODEL = form_data.stt.MODEL request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES + request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY @@ -295,6 +298,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm 'ENGINE': request.app.state.config.STT_ENGINE, 'MODEL': request.app.state.config.STT_MODEL, 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, + 'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS, 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, @@ -1242,7 +1246,14 @@ async def transcription( try: safe_name = os.path.basename(file.filename) if file.filename else '' - ext = safe_name.rsplit('.', 1)[-1] if '.' in safe_name else '' + ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else '' + + allowed_extensions = getattr(request.app.state.config, 'STT_ALLOWED_EXTENSIONS', []) + if allowed_extensions and ext not in allowed_extensions: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid audio file extension', + ) id = uuid.uuid4() From 1f977d072e23b6524c65ebdf9388e82806f6f2b0 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 8 May 2026 18:19:30 +0200 Subject: [PATCH 010/219] chore: Update SECURITY.md (#24363) * Update SECURITY.md * Update SECURITY.md --- docs/SECURITY.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index dc3c640b98..66ce0ee100 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -138,6 +138,20 @@ Due to the very high volume of incoming vulnerability reports, issues, discussio **Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means we have not yet had the capacity to address it. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding. +## Report Handling + +If you report a valid vulnerability that somebody else reported before you, we will close your report as a duplicate. The earliest filing is the one we will handle going forward, and we will not publish multiple advisories for the same vulnerability. + +When multiple independent reporters describe the same vulnerability class but each demonstrates a **distinct and separate exploitation vector** — for example, the same missing authorization check reached through different endpoints — we will consolidate them into the earliest filing and credit every reporter who demonstrated a distinct path. Only one CVE will be issued for the consolidated advisory. + +### Why duplicate reports don't receive credit + +We credit only the earliest filer of a given vulnerability: + +1. **The first report did the work.** By the time a later report arrives, triage and fix are already in motion. Later reports don't change the outcome or timeline; crediting them would misrepresent what moved the fix. +2. **Credit-for-duplicates incentivizes flooding.** If similar-but-later filings earn credit, the rational play is to skim open advisories and file variations. We already see this pressure — the first-filer rule is what limits it. +3. **Co-discovery is different from duplication.** Multiple reporters **are credited** on one advisory **when each contributes a *distinct* finding** — different vector, different affected component, different sub-path the earlier filing does not cover. That is the consolidation rule above. Filing a duplicate of an existing report is not co-discovery. + ## Confidential Disclosure Vulnerability reports submitted through GitHub Security Advisories are **private and confidential**. Public disclosure of **ANY** details related to a submitted vulnerability report is **STRICTLY PROHIBITED** until the advisory has been **fully published** — not merely when a CVE ID has been assigned, but when the advisory itself is publicly visible. From 2a18dc98ac69eb200a17f53ddd3eaab5ce8d4f44 Mon Sep 17 00:00:00 2001 From: Jacob Leksan <63938553+jmleksan@users.noreply.github.com> Date: Fri, 8 May 2026 12:20:11 -0400 Subject: [PATCH 011/219] Implement asynchronous database ping for health checks (#24380) --- backend/open_webui/main.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index e4ee823b4f..19227b6b9e 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -2812,6 +2812,14 @@ async def get_opensearch_xml(): return Response(content=xml_content, media_type='application/xml') +def _sync_db_ping() -> None: + ScopedSession.execute(text('SELECT 1;')).all() + + +async def async_db_ping() -> None: + await asyncio.to_thread(_sync_db_ping) + + @app.get('/health') async def healthcheck(): return {'status': True} @@ -2833,7 +2841,7 @@ async def readiness_check(): # Check database connectivity try: - ScopedSession.execute(text('SELECT 1;')).all() + await async_db_ping() except Exception as e: log.warning(f'Readiness check DB ping failed: {e!r}') raise HTTPException( @@ -2860,7 +2868,7 @@ async def readiness_check(): @app.get('/health/db') async def healthcheck_with_db(): - ScopedSession.execute(text('SELECT 1;')).all() + await async_db_ping() return {'status': True} From 1dee67b64d0b34e70bac949682b216c0aaec8152 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:21:17 +0900 Subject: [PATCH 012/219] refac --- backend/open_webui/routers/models.py | 78 +++++++++++++++++----------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 510a0d3d29..a5f39c768f 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -456,47 +456,63 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes @router.get('/model/profile/image') async def get_model_profile_image( + request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session), ): - model_meta = await Models.get_model_meta_by_id(id, db=db) + profile_image_url = None + updated_at = None + # First, check the database for regular models + model_meta = await Models.get_model_meta_by_id(id, db=db) if model_meta: meta, updated_at = model_meta profile_image_url = (meta or {}).get('profile_image_url') - if profile_image_url: - if profile_image_url.startswith('http'): - return Response( - status_code=status.HTTP_302_FOUND, - headers={'Location': profile_image_url}, + # Fallback: check arena models stored in config (not in the DB) + if not profile_image_url: + arena_models = getattr( + getattr(request.app.state, 'config', None), + 'EVALUATION_ARENA_MODELS', + [], + ) + for arena_model in arena_models: + if arena_model.get('id') == id: + profile_image_url = arena_model.get('meta', {}).get('profile_image_url') + break + + if profile_image_url: + if profile_image_url.startswith('http'): + return Response( + status_code=status.HTTP_302_FOUND, + headers={'Location': profile_image_url}, + ) + elif profile_image_url.startswith('data:image'): + try: + header, base64_data = profile_image_url.split(',', 1) + image_data = base64.b64decode(base64_data) + image_buffer = io.BytesIO(image_data) + media_type = header.split(';')[0].lstrip('data:') + + headers = {'Content-Disposition': 'inline'} + if updated_at: + headers['ETag'] = f'"{updated_at}"' + + return StreamingResponse( + image_buffer, + media_type=media_type, + headers=headers, + ) + except Exception: + pass + else: + safe_static = _safe_static_redirect_path(profile_image_url) + if safe_static: + return RedirectResponse( + url=safe_static, + status_code=status.HTTP_302_FOUND, ) - elif profile_image_url.startswith('data:image'): - try: - header, base64_data = profile_image_url.split(',', 1) - image_data = base64.b64decode(base64_data) - image_buffer = io.BytesIO(image_data) - media_type = header.split(';')[0].lstrip('data:') - - headers = {'Content-Disposition': 'inline'} - if updated_at: - headers['ETag'] = f'"{updated_at}"' - - return StreamingResponse( - image_buffer, - media_type=media_type, - headers=headers, - ) - except Exception: - pass - else: - safe_static = _safe_static_redirect_path(profile_image_url) - if safe_static: - return RedirectResponse( - url=safe_static, - status_code=status.HTTP_302_FOUND, - ) return RedirectResponse( url='/static/favicon.png', From f39f4a86aedc2769d8268670a020b1f3c16776dd Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:22:25 +0900 Subject: [PATCH 013/219] refac --- backend/open_webui/tools/builtin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 18b888bbd7..f191cad758 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2678,7 +2678,7 @@ async def update_automation( is_active=automation.is_active, ) - updated = await Automations.update(automation_id, form, next_run_ns(new_rrule, tz=tz)) + updated = await Automations.update_by_id(automation_id, form, next_run_ns(new_rrule, tz=tz)) return json.dumps( { From 2977910ffd9d2369dfa504aa6ab12745b3dbd19a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:25:01 +0900 Subject: [PATCH 014/219] refac --- backend/open_webui/tools/builtin.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f191cad758..dad212c90d 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -3177,8 +3177,10 @@ async def update_calendar_event( return json.dumps({'error': 'Event not found'}) # Check write access to the event's calendar - cal = await Calendars.get_calendar_by_id(event.calendar_id) - if cal and cal.user_id != user_id and __user__.get('role') != 'admin': + if event.user_id != user_id and __user__.get('role') != 'admin': + cal = await Calendars.get_calendar_by_id(event.calendar_id) + if not cal: + return json.dumps({'error': 'Access denied'}) user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] if not await AccessGrants.has_access( user_id=user_id, @@ -3278,8 +3280,10 @@ async def delete_calendar_event( return json.dumps({'error': 'Event not found'}) # Check write access - cal = await Calendars.get_calendar_by_id(event.calendar_id) - if cal and cal.user_id != user_id and __user__.get('role') != 'admin': + if event.user_id != user_id and __user__.get('role') != 'admin': + cal = await Calendars.get_calendar_by_id(event.calendar_id) + if not cal: + return json.dumps({'error': 'Access denied'}) user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)] if not await AccessGrants.has_access( user_id=user_id, From b72019db393a658ca0ceecdcc59b70f6cc5dcd40 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:31:04 +0900 Subject: [PATCH 015/219] refac --- backend/open_webui/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 1699129f2a..d71bd389b2 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2627,13 +2627,13 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig( ) -ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' -ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' - ONEDRIVE_CLIENT_ID = os.environ.get('ONEDRIVE_CLIENT_ID', '') ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get('ONEDRIVE_CLIENT_ID_PERSONAL', ONEDRIVE_CLIENT_ID) ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get('ONEDRIVE_CLIENT_ID_BUSINESS', ONEDRIVE_CLIENT_ID) +ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' and bool(ONEDRIVE_CLIENT_ID_PERSONAL) +ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' and bool(ONEDRIVE_CLIENT_ID_BUSINESS) + ONEDRIVE_SHAREPOINT_URL = PersistentConfig( 'ONEDRIVE_SHAREPOINT_URL', 'onedrive.sharepoint_url', From 6bdc2ffa79d72daf78981209c9c5292c697cbfe5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:31:42 +0900 Subject: [PATCH 016/219] refac --- src/lib/components/chat/FileNav.svelte | 38 ++++++++++++-- .../chat/FileNav/FileEntryRow.svelte | 16 ++++++ .../chat/FileNav/FileNavToolbar.svelte | 49 +++++++++++++++++++ src/lib/i18n/locales/en-US/translation.json | 3 ++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 2e81a94294..1bd204398c 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -92,6 +92,35 @@ let loading = false; let error: string | null = null; + // ── Sort state ────────────────────────────────────────────────────── + type SortMode = 'name' | 'date'; + let sortBy: SortMode = 'name'; + let sortAsc = true; + + const sortEntries = (items: FileEntry[]): FileEntry[] => { + return [...items].sort((a, b) => { + // Directories always first + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + if (sortBy === 'date') { + const aTime = a.modified ?? 0; + const bTime = b.modified ?? 0; + return sortAsc ? aTime - bTime : bTime - aTime; + } + const cmp = a.name.localeCompare(b.name); + return sortAsc ? cmp : -cmp; + }); + }; + + const toggleSort = (mode: SortMode) => { + if (sortBy === mode) { + sortAsc = !sortAsc; + } else { + sortBy = mode; + sortAsc = mode === 'name'; // name defaults asc, date defaults asc (oldest first) + } + entries = sortEntries(entries); + }; + // ── Navigation history ────────────────────────────────────────────── type NavEntry = { path: string; file: string | null }; let navHistory: NavEntry[] = []; @@ -341,10 +370,7 @@ 'Failed to load directory. Check your Terminal connection in Settings → Integrations.'; entries = []; } else { - entries = result.sort((a, b) => { - if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.localeCompare(b.name); - }); + entries = sortEntries(result); } }; @@ -918,6 +944,8 @@ {loading} {canGoBack} {canGoForward} + {sortBy} + {sortAsc} onGoBack={goBack} onGoForward={goForward} onNavigate={loadDir} @@ -934,6 +962,7 @@ onUploadFiles={handleUploadFiles} onDownloadDir={() => downloadFile(currentPath)} onMove={handleMove} + onSort={toggleSort} > {#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)} @@ -1355,6 +1384,7 @@ onRename={handleRename} onSelect={handleSelect} onLongPress={enterSelectionMode} + showDate={sortBy === 'date'} /> {/each} diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index ef56c2c6b5..cd4c90f8a1 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -30,6 +30,17 @@ export let selectedPaths: Set = new Set(); export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {}; export let onLongPress: () => void = () => {}; + export let showDate: boolean = false; + + const formatRelativeTime = (epoch: number): string => { + const diff = Math.floor(Date.now() / 1000) - epoch; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`; + if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`; + return `${Math.floor(diff / 31536000)}y ago`; + }; let dragOverFolder = false; @@ -271,7 +282,12 @@ {/if} {#if entry.type === 'file' && entry.size !== undefined && !renaming} + {#if showDate && entry.modified} + {formatRelativeTime(entry.modified)} + {/if} {formatFileSize(entry.size)} + {:else if entry.type === 'directory' && showDate && entry.modified && !renaming} + {formatRelativeTime(entry.modified)} {/if} diff --git a/src/lib/components/chat/FileNav/FileNavToolbar.svelte b/src/lib/components/chat/FileNav/FileNavToolbar.svelte index 6b705a98d6..0983995676 100644 --- a/src/lib/components/chat/FileNav/FileNavToolbar.svelte +++ b/src/lib/components/chat/FileNav/FileNavToolbar.svelte @@ -6,6 +6,7 @@ import FilePlusAlt from '../../icons/FilePlusAlt.svelte'; import Spinner from '../../common/Spinner.svelte'; import Tooltip from '../../common/Tooltip.svelte'; + import Dropdown from '$lib/components/common/Dropdown.svelte'; const i18n = getContext('i18n'); @@ -21,6 +22,11 @@ export let onDownloadDir: () => void = () => {}; export let onMove: (source: string, destFolder: string) => void = () => {}; + // Sort controls + export let sortBy: 'name' | 'date' = 'name'; + export let sortAsc: boolean = true; + export let onSort: (mode: 'name' | 'date') => void = () => {}; + // Back / forward navigation export let canGoBack = false; export let canGoForward = false; @@ -161,6 +167,49 @@ {#if !selectedFile} + + + + + +
+
+ + +
+
+
{/if} + + {#if permissions.chat.share} +
+
+
+ {$i18n.t('Chats Public Sharing')} +
+ +
+ {#if defaultPermissions?.sharing?.public_chats && !permissions.sharing.public_chats} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+ {/if}
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 1468d6b349..eae8d7d233 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -70,6 +70,7 @@ import { archiveChatById, createNewChat, + deleteChatById, getAllTags, getChatById, getChatList, @@ -101,6 +102,7 @@ import Navbar from '$lib/components/chat/Navbar.svelte'; import ChatControls from './ChatControls.svelte'; import EventConfirmDialog from '../common/ConfirmDialog.svelte'; + import DeleteConfirmDialog from '../common/ConfirmDialog.svelte'; import Placeholder from './Placeholder.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import NotificationToast from '../NotificationToast.svelte'; @@ -2793,6 +2795,33 @@ toast.error($i18n.t('Failed to archive chat.')); } }; + + let showDeleteConfirm = false; + + const deleteChatHandler = async (id: string) => { + showDeleteConfirm = true; + }; + + const confirmDeleteChat = async () => { + const id = $chatId; + if (!id) return; + + try { + const res = await deleteChatById(localStorage.token, id); + if (res) { + currentChatPage.set(1); + initNewChat(); + await goto('/'); + chats.set(await getChatList(localStorage.token, $currentChatPage)); + pinnedChats.set(await getPinnedChatList(localStorage.token)); + allTags.set(await getAllTags(localStorage.token)); + toast.success($i18n.t('Chat deleted.')); + } + } catch (error) { + console.error('Error deleting chat:', error); + toast.error(`${error}`); + } + }; @@ -2805,6 +2834,18 @@ + { + confirmDeleteChat(); + }} +> +
+ {$i18n.t('This will delete')} {$chatTitle}. +
+
+ { try { diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte index 5c4e1364b4..e2a49f7621 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -53,6 +53,7 @@ export let onSaveTempChat: () => {}; export let archiveChatHandler: (id: string) => void; + export let deleteChatHandler: (id: string) => void; export let moveChatHandler: (id: string, folderId: string) => void; let closedBannerIds = []; @@ -199,6 +200,9 @@ archiveChatHandler={() => { archiveChatHandler(chat.id); }} + deleteChatHandler={() => { + deleteChatHandler(chat.id); + }} {moveChatHandler} > + +
diff --git a/src/lib/constants/permissions.ts b/src/lib/constants/permissions.ts index 69a8a3a664..c740696ee1 100644 --- a/src/lib/constants/permissions.ts +++ b/src/lib/constants/permissions.ts @@ -24,7 +24,8 @@ export const DEFAULT_PERMISSIONS = { skills: false, public_skills: false, notes: false, - public_notes: false + public_notes: false, + public_chats: false }, access_grants: { allow_users: true diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index a84604c76a..ca04c11f0c 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/src/lib/i18n/locales/en-US/translation.json @@ -309,6 +309,7 @@ "Chart new frontiers": "", "Chat": "", "Chat archived.": "", + "Chat deleted.": "", "Chat Background Image": "", "Chat Bubble UI": "", "Chat Completions": "", From fd3368c0bff168417e3c49ffd73491c344702339 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 01:55:51 +0900 Subject: [PATCH 018/219] refac --- backend/open_webui/routers/auths.py | 12 ++++++++++++ backend/open_webui/routers/users.py | 1 + 2 files changed, 13 insertions(+) diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 6d2349f89f..af0e455146 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -522,6 +522,18 @@ async def ldap_auth( db=db, ) + if request.app.state.config.WEBHOOK_URL: + await post_webhook( + request.app.state.WEBUI_NAME, + request.app.state.config.WEBHOOK_URL, + WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + { + 'action': 'signup', + 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + 'user': user.model_dump_json(exclude_none=True), + }, + ) + except HTTPException: raise except Exception as err: diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 04be89c92f..2d204ac18f 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -193,6 +193,7 @@ class SharingPermissions(BaseModel): public_skills: bool = False notes: bool = False public_notes: bool = True + public_chats: bool = False class AccessGrantsPermissions(BaseModel): From 9adc0c442a57eaa88a5f30c2b2cb393623154e20 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:02:02 +0900 Subject: [PATCH 019/219] refac --- backend/open_webui/retrieval/web/duckduckgo.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index da1c3f77ec..272a4bf514 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -33,9 +33,14 @@ def search_duckduckgo( # Use the ddgs.text() method to perform the search try: - search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend) + kwargs = {"safesearch": "moderate", "max_results": count} + if backend and backend != "auto": + kwargs["backend"] = backend + results = ddgs.text(query, **kwargs) + search_results = results if results is not None else [] except RatelimitException as e: log.error(f'RatelimitException: {e}') + search_results = [] if filter_list: search_results = get_filtered_results(search_results, filter_list) From 55a572cd398c9b4e6118728f8f129941437aa225 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:04:26 +0900 Subject: [PATCH 020/219] refac --- backend/open_webui/config.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 52014b1edd..dfa3808b0c 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -172,6 +172,7 @@ def get_config_value(config_path: str): PERSISTENT_CONFIG_REGISTRY = [] + def save_config(config): """Sync save — used ONLY at startup/import time.""" global CONFIG_DATA @@ -328,6 +329,17 @@ class AppConfig: except Exception as e: log.error(f'Failed to async-persist config key {key}: {e}') + def _sync_to_redis(self): + """Push all in-memory config values to Redis, e.g. after a bulk import.""" + if not self._redis or not ENABLE_PERSISTENT_CONFIG: + return + for key, pc in self._state.items(): + redis_key = f'{self._redis_key_prefix}:config:{key}' + try: + self._redis.set(redis_key, json.dumps(pc.value)) + except Exception as e: + log.error(f'Failed to sync config key {key} to Redis: {e}') + def __getattr__(self, key): if key not in self._state: raise AttributeError(f"Config key '{key}' not found") From 1c1c8b18e5cc90ca3c6961a4c193a4363febbc83 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:04:36 +0900 Subject: [PATCH 021/219] refac --- backend/open_webui/routers/configs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index 02b16d8e5b..21721a4a8b 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -49,8 +49,9 @@ class ImportConfigForm(BaseModel): @router.post('/import', response_model=dict) -async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)): +async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)): await async_save_config(form_data.config) + request.app.state.config._sync_to_redis() return get_config() From 7e275c1daa47d3963cefc469cc26c2aeae21c56d Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 8 May 2026 19:05:28 +0200 Subject: [PATCH 022/219] fix: prevent STT from blocking the uvicorn event loop (#24338) The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing. - Add asyncio import - Use async UploadFile.read() instead of synchronous file.file.read() - Offload the blocking transcribe() call via asyncio.to_thread() Closes #24169 --- backend/open_webui/routers/audio.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index c653a370e0..f0a1e54fc9 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import json import logging @@ -1258,7 +1259,7 @@ async def transcription( id = uuid.uuid4() filename = f'{id}.{ext}' - contents = file.file.read() + contents = await file.read() file_dir = os.path.join(CACHE_DIR, 'audio', 'transcriptions') os.makedirs(file_dir, exist_ok=True) @@ -1277,7 +1278,7 @@ async def transcription( if language: metadata = {'language': language} - result = transcribe(request, file_path, metadata, user) + result = await asyncio.to_thread(transcribe, request, file_path, metadata, user) return { **result, From 6dff85b9d205cfc4bc2845dac40909b8d859910c Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:08:08 +0900 Subject: [PATCH 023/219] refac --- backend/open_webui/retrieval/web/searxng.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/open_webui/retrieval/web/searxng.py b/backend/open_webui/retrieval/web/searxng.py index 0335bea9a3..2b7bd04895 100644 --- a/backend/open_webui/retrieval/web/searxng.py +++ b/backend/open_webui/retrieval/web/searxng.py @@ -38,7 +38,7 @@ def search_searxng( """ # Default values for optional parameters are provided as empty strings or None when not specified. - language = kwargs.get('language', 'all') + language = kwargs.get('language', 'all').strip().rstrip(',') safesearch = kwargs.get('safesearch', '1') time_range = kwargs.get('time_range', '') categories = ''.join(kwargs.get('categories', [])) From e451f8f63b88dc46ab51de5f43319028248ee49f Mon Sep 17 00:00:00 2001 From: Athanasios Oikonomou Date: Fri, 8 May 2026 20:09:35 +0300 Subject: [PATCH 024/219] fix: open file content in new window when clicking file name in FileItemModal (#24125) Previously, clicking the file name link did not open the file content because the condition checked `!isPDF && item.url`, which failed for `type === 'file'` items that use an ID-based URL path. Update the condition to trigger on `item.type === 'file' || item.url`, and resolve the correct URL by extracting `fileId` from `item.id` or `item.tempId` instead of using `item.url` directly as the file identifier. --- src/lib/components/common/FileItemModal.svelte | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/components/common/FileItemModal.svelte b/src/lib/components/common/FileItemModal.svelte index 4767cf92cb..2fb6a16bad 100644 --- a/src/lib/components/common/FileItemModal.svelte +++ b/src/lib/components/common/FileItemModal.svelte @@ -266,12 +266,13 @@ href="#" class="hover:underline line-clamp-1" on:click|preventDefault={() => { - if (!isPDF && item.url) { + if (item.type === 'file' || item.url) { + let fileId = item?.id ?? item?.tempId; window.open( item.type === 'file' ? item?.url?.startsWith('http') ? item.url - : `${WEBUI_API_BASE_URL}/files/${item.url}/content` + : `${WEBUI_API_BASE_URL}/files/${fileId}/content` : item.url, '_blank' ); From 3309f5d9f11f521c0ee97b64c59a83e3cf390bde Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:25:26 +0900 Subject: [PATCH 025/219] refac --- backend/open_webui/utils/tools.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 9f3ab0bce4..0cd182dc84 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -189,10 +189,11 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr log.warning(f'Access denied to tool {tool_id} for user {user.id}') continue - module = request.app.state.TOOLS.get(tool_id, None) - if module is None: - module, _ = await load_tool_module_by_id(tool_id) + module = request.app.state.TOOLS.get(tool_id) + if module is None or request.app.state.TOOL_CONTENTS.get(tool_id) != tool.content: + module, _ = await load_tool_module_by_id(tool_id, content=tool.content) request.app.state.TOOLS[tool_id] = module + request.app.state.TOOL_CONTENTS[tool_id] = tool.content __user__ = { **extra_params['__user__'], From 0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:31:30 +0900 Subject: [PATCH 026/219] refac --- backend/open_webui/config.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index dfa3808b0c..3cfb4ce21d 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1311,10 +1311,16 @@ MODEL_ORDER_LIST = PersistentConfig( [], ) +try: + default_model_metadata = json.loads(os.environ.get('DEFAULT_MODEL_METADATA', '{}')) +except Exception as e: + log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}') + default_model_metadata = {} + DEFAULT_MODEL_METADATA = PersistentConfig( 'DEFAULT_MODEL_METADATA', 'models.default_metadata', - {}, + default_model_metadata, ) try: From 7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:38:08 +0900 Subject: [PATCH 027/219] refac --- backend/open_webui/__init__.py | 7 +++++++ backend/open_webui/internal/db.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index be25227d36..92cadefe7c 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -1,6 +1,7 @@ import base64 import os import random +import sys from pathlib import Path from typing import Annotated @@ -68,12 +69,18 @@ def serve( import open_webui.main # noqa: F401 from open_webui.env import UVICORN_WORKERS # Import the workers setting + # On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop, + # which is incompatible with psycopg v3 async. Setting loop='none' lets + # asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py. + loop = 'none' if sys.platform == 'win32' else 'auto' + uvicorn.run( 'open_webui.main:app', host=host, port=port, forwarded_allow_ips='*', workers=UVICORN_WORKERS, + loop=loop, ) diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index c9e4f318e1..1bb9db5bb4 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -1,4 +1,5 @@ import os +import sys import json import logging from contextlib import asynccontextmanager, contextmanager @@ -332,6 +333,14 @@ get_db = contextmanager(get_session) # all work without any stripping or translation. ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL) +# psycopg v3 cannot run in async mode under Windows' default +# ProactorEventLoop — switch to SelectorEventLoop before creating +# the async engine. This runs at import time, which is early enough +# to cover every entry point (workers, reload, direct invocations). +if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL): + import asyncio + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL: # Generous default — async coroutines + no session sharing = high connection demand. _sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512 From ff791b4814fc1453df2235ea78016d7015aa6806 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:43:07 +0900 Subject: [PATCH 028/219] refac --- backend/open_webui/routers/audio.py | 58 ++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index f0a1e54fc9..e4b89299da 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -1,4 +1,5 @@ import asyncio +import io import hashlib import json import logging @@ -128,6 +129,55 @@ def convert_audio_to_mp3(file_path): return None +def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_path: str) -> bool: + """ + Transcode audio bytes to MP3 if the Content-Type indicates a non-MP3 format. + + Handles raw PCM audio (e.g. Gemini-TTS via OpenRouter/LiteLLM) by parsing + optional rate/channels from the Content-Type params, defaulting to 24kHz, + 16-bit, mono. For other non-MP3 formats, uses pydub auto-detection. + + Returns True if transcoding was performed, False if the data is already MP3. + Respects BYPASS_PYDUB_PREPROCESSING — when set, writes raw bytes and logs a warning. + """ + mime_type = content_type_header.split(';')[0].strip().lower() + + if mime_type in ('audio/mpeg', 'audio/mp3'): + return False + + if BYPASS_PYDUB_PREPROCESSING: + log.warning( + f'TTS returned {mime_type} but BYPASS_PYDUB_PREPROCESSING is set; ' + f'writing raw audio without transcoding' + ) + return False + + if mime_type in ('audio/pcm', 'audio/l16', 'audio/raw'): + # Parse optional rate/channels from Content-Type params, + # default: 24kHz, 16-bit, mono (standard for Gemini TTS). + ct_params = {} + for part in content_type_header.split(';')[1:]: + key_val = part.strip().split('=') + if len(key_val) == 2: + ct_params[key_val[0].strip().lower()] = key_val[1].strip() + + sample_rate = int(ct_params.get('rate', 24000)) + channels = int(ct_params.get('channels', 1)) + + audio_segment = AudioSegment.from_raw( + io.BytesIO(audio_data), + sample_width=2, + frame_rate=sample_rate, + channels=channels, + ) + else: + audio_segment = AudioSegment.from_file(io.BytesIO(audio_data)) + + audio_segment.export(str(output_path), format='mp3') + log.info(f'Transcoded {mime_type} audio to MP3: {output_path}') + return True + + def set_faster_whisper_model(model: str, auto_update: bool = False): whisper_model = None if model: @@ -392,8 +442,12 @@ async def speech(request: Request, user=Depends(get_verified_user)): r.raise_for_status() - async with aiofiles.open(file_path, 'wb') as f: - await f.write(await r.read()) + audio_data = await r.read() + content_type_header = r.headers.get('Content-Type', 'audio/mpeg') + + if not transcode_audio_to_mp3(audio_data, content_type_header, file_path): + async with aiofiles.open(file_path, 'wb') as f: + await f.write(audio_data) async with aiofiles.open(file_body_path, 'w') as f: await f.write(json.dumps(payload)) From cde72dab71671645e119564ca9747ce25dd590ad Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:54:09 +0900 Subject: [PATCH 029/219] refac --- backend/open_webui/config.py | 6 ++++++ backend/open_webui/utils/oauth.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 3cfb4ce21d..3cba80a9d4 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1209,6 +1209,12 @@ TOOL_SERVER_CONNECTIONS = PersistentConfig( tool_server_connections, ) +OAUTH_CLIENT_TIMEOUT = PersistentConfig( + 'OAUTH_CLIENT_TIMEOUT', + 'oauth.client.timeout', + os.environ.get('OAUTH_CLIENT_TIMEOUT', ''), +) + #################################### # TERMINAL_SERVER #################################### diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 4a7d79d87c..ddc5bed74c 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -37,6 +37,7 @@ from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupF from open_webui.config import ( DEFAULT_USER_ROLE, ENABLE_OAUTH_SIGNUP, + OAUTH_CLIENT_TIMEOUT, OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE, OAUTH_MERGE_ACCOUNTS_BY_EMAIL, OAUTH_PROVIDERS, @@ -597,6 +598,7 @@ class OAuthClientManager: 'client_secret': oauth_client_info.client_secret, 'client_kwargs': { 'follow_redirects': True, + **({'timeout': int(OAUTH_CLIENT_TIMEOUT.value)} if OAUTH_CLIENT_TIMEOUT.value else {}), **({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}), **( {'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method} From bc4d6eef33dcb92719b07483cdb1d63ebf250721 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:56:15 +0900 Subject: [PATCH 030/219] refac --- src/lib/components/common/PDFViewer.svelte | 190 +++++++++++++++++++-- 1 file changed, 180 insertions(+), 10 deletions(-) diff --git a/src/lib/components/common/PDFViewer.svelte b/src/lib/components/common/PDFViewer.svelte index 73a5d7a2f2..4912908b2b 100644 --- a/src/lib/components/common/PDFViewer.svelte +++ b/src/lib/components/common/PDFViewer.svelte @@ -18,6 +18,9 @@ let rerenderTimer: ReturnType | null = null; let lastRenderedZoom = 1; + // Keep a reference to TextLayer instances so we can update/cancel them + let textLayerInstances: any[] = []; + const initPanzoom = () => { if (pzInstance) { pzInstance.dispose(); @@ -84,19 +87,33 @@ // Re-render existing canvases at a new zoom level (preserves panzoom transform) const rerenderPages = async (forZoom: number) => { if (!pdfDoc || !sceneElement) return; + const pdfjs = await import('pdfjs-dist'); const dpr = window.devicePixelRatio || 1; const containerWidth = outerContainer?.clientWidth || 800; - const canvases = sceneElement.querySelectorAll('canvas'); + const pageWrappers = sceneElement.querySelectorAll('.pdf-page-wrapper'); - for (let i = 0; i < canvases.length; i++) { + // Cancel old text layers + for (const tl of textLayerInstances) { + try { + tl.cancel(); + } catch (_) {} + } + textLayerInstances = []; + + for (let i = 0; i < pageWrappers.length; i++) { const page = await pdfDoc.getPage(i + 1); const viewport = page.getViewport({ scale: 1 }); const cssScale = containerWidth / viewport.width; const renderScale = cssScale * forZoom * dpr; const scaledViewport = page.getViewport({ scale: renderScale }); + const cssViewport = page.getViewport({ scale: cssScale }); - const canvas = canvases[i]; + const wrapper = pageWrappers[i] as HTMLElement; + // Update the CSS custom property so textLayer dimensions resolve correctly + wrapper.style.setProperty('--scale-factor', String(cssViewport.scale)); + + const canvas = wrapper.querySelector('canvas')!; canvas.width = scaledViewport.width; canvas.height = scaledViewport.height; @@ -104,6 +121,21 @@ if (ctx) { await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise; } + + // Rebuild text layer + const textLayerDiv = wrapper.querySelector('.textLayer') as HTMLElement; + if (textLayerDiv) { + textLayerDiv.innerHTML = ''; + + const textContent = await page.getTextContent(); + const textLayer = new pdfjs.TextLayer({ + textContentSource: textContent, + container: textLayerDiv, + viewport: cssViewport + }); + await textLayer.render(); + textLayerInstances.push(textLayer); + } } lastRenderedZoom = forZoom; }; @@ -111,9 +143,18 @@ const renderAllPages = async () => { if (!pdfDoc || !sceneElement) return; - // Clear previous canvases + // Clear previous content sceneElement.innerHTML = ''; + // Cancel old text layers + for (const tl of textLayerInstances) { + try { + tl.cancel(); + } catch (_) {} + } + textLayerInstances = []; + + const pdfjs = await import('pdfjs-dist'); const dpr = window.devicePixelRatio || 1; for (let i = 1; i <= pdfDoc.numPages; i++) { @@ -125,7 +166,24 @@ const cssScale = containerWidth / viewport.width; const renderScale = cssScale * dpr; const scaledViewport = page.getViewport({ scale: renderScale }); + const cssViewport = page.getViewport({ scale: cssScale }); + // Create page wrapper (positioned container for canvas + text layer) + const wrapper = document.createElement('div'); + wrapper.className = 'pdf-page-wrapper'; + wrapper.style.position = 'relative'; + wrapper.style.width = `${Math.round(cssScale * viewport.width)}px`; + wrapper.style.height = `${Math.round(cssScale * viewport.height)}px`; + wrapper.style.display = 'block'; + // pdfjs TextLayer uses --total-scale-factor (= --scale-factor * --user-unit) + // to position/size text spans. We must set --scale-factor so the calc resolves. + wrapper.style.setProperty('--scale-factor', String(cssViewport.scale)); + + if (i > 1) { + wrapper.style.marginTop = '4px'; + } + + // Create canvas const canvas = document.createElement('canvas'); canvas.width = scaledViewport.width; canvas.height = scaledViewport.height; @@ -133,18 +191,29 @@ canvas.style.width = `${Math.round(cssScale * viewport.width)}px`; canvas.style.height = `${Math.round(cssScale * viewport.height)}px`; canvas.style.display = 'block'; - - if (i > 1) { - canvas.style.marginTop = '4px'; - } - - sceneElement.appendChild(canvas); + wrapper.appendChild(canvas); const ctx = canvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport: scaledViewport }).promise; + + // Create text layer overlay — pdfjs setLayerDimensions handles its sizing + const textLayerDiv = document.createElement('div'); + textLayerDiv.className = 'textLayer'; + wrapper.appendChild(textLayerDiv); + + const textContent = await page.getTextContent(); + const textLayer = new pdfjs.TextLayer({ + textContentSource: textContent, + container: textLayerDiv, + viewport: cssViewport + }); + await textLayer.render(); + textLayerInstances.push(textLayer); + + sceneElement.appendChild(wrapper); } lastRenderedZoom = 1; @@ -187,6 +256,12 @@ onDestroy(() => { if (rerenderTimer) clearTimeout(rerenderTimer); pzInstance?.dispose(); + for (const tl of textLayerInstances) { + try { + tl.cancel(); + } catch (_) {} + } + textLayerInstances = []; if (pdfDoc) { pdfDoc.destroy(); pdfDoc = null; @@ -257,3 +332,98 @@
{/if} + + From 60ea4214aa42f1ad22142f1a43535007a2293d16 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 02:58:17 +0900 Subject: [PATCH 031/219] refac --- src/lib/components/common/SensitiveInput.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/components/common/SensitiveInput.svelte b/src/lib/components/common/SensitiveInput.svelte index 57f3e681c8..c43a87ae33 100644 --- a/src/lib/components/common/SensitiveInput.svelte +++ b/src/lib/components/common/SensitiveInput.svelte @@ -13,6 +13,7 @@ export let showButtonClassName = 'pl-1.5 transition bg-transparent'; export let screenReader = true; export let autocomplete = 'off'; + export let name: string | undefined = undefined; let show = false; @@ -27,6 +28,7 @@ {placeholder} type={type === 'password' && !show ? 'password' : 'text'} bind:value + {name} required={required && !readOnly} disabled={readOnly} {autocomplete} From f152ad36b32c2bcb5fc140a2513ce923a3c6bd29 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 03:06:19 +0900 Subject: [PATCH 032/219] refac --- backend/open_webui/main.py | 36 +++++++++++++++------------ backend/open_webui/utils/tools.py | 41 ++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 19227b6b9e..c437365312 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -707,30 +707,34 @@ async def lifespan(app: FastAPI): # Pre-fetch tool server specs so the first request doesn't pay the latency cost if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: + mock_request = Request( + { + 'type': 'http', + 'asgi.version': '3.0', + 'asgi.spec_version': '2.0', + 'method': 'GET', + 'path': '/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, + } + ) + log.info('Initializing tool servers...') try: - mock_request = Request( - { - 'type': 'http', - 'asgi.version': '3.0', - 'asgi.spec_version': '2.0', - 'method': 'GET', - 'path': '/internal', - 'query_string': b'', - 'headers': Headers({}).raw, - 'client': ('127.0.0.1', 12345), - 'server': ('127.0.0.1', 80), - 'scheme': 'http', - 'app': app, - } - ) await set_tool_servers(mock_request) log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)') + except Exception as e: + log.warning(f'Failed to initialize tool servers at startup: {e}') + try: await set_terminal_servers(mock_request) log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)') except Exception as e: - log.warning(f'Failed to initialize tool/terminal servers at startup: {e}') + log.warning(f'Failed to initialize terminal servers at startup: {e}') # Mark application as ready to accept traffic from a startup perspective. app.state.startup_complete = True diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 0cd182dc84..f1b4fb1d1f 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -872,29 +872,40 @@ def convert_openapi_to_tool_payload(openapi_spec): async def set_tool_servers(request: Request): - request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS) + try: + request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS) + except Exception as e: + log.error(f'Error fetching tool server data: {e}') + request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or [] - if request.app.state.redis is not None: - await request.app.state.redis.set( - f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS) - ) + try: + if request.app.state.redis is not None: + await request.app.state.redis.set( + f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS) + ) + except Exception as e: + log.error(f'Error caching tool_servers to Redis: {e}') return request.app.state.TOOL_SERVERS async def get_tool_servers(request: Request): - tool_servers = [] - if request.app.state.redis is not None: - try: - tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')) - request.app.state.TOOL_SERVERS = tool_servers - except Exception as e: - log.error(f'Error fetching tool_servers from Redis: {e}') + try: + tool_servers = [] + if request.app.state.redis is not None: + try: + tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')) + request.app.state.TOOL_SERVERS = tool_servers + except Exception as e: + log.error(f'Error fetching tool_servers from Redis: {e}') - if not tool_servers: - tool_servers = await set_tool_servers(request) + if not tool_servers: + tool_servers = await set_tool_servers(request) - return tool_servers + return tool_servers + except Exception as e: + log.error(f'Failed to load tool servers, skipping: {e}') + return getattr(request.app.state, 'TOOL_SERVERS', None) or [] async def get_terminal_cwd( From 552bbcecfae5ae273ab98e2ce3e540d0771aa964 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 03:15:53 +0900 Subject: [PATCH 033/219] refac --- backend/open_webui/socket/main.py | 32 ++++++--- backend/open_webui/tools/builtin.py | 12 +++- src/routes/+layout.svelte | 104 +++++++++++++++------------- 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index e224408742..bed04c549b 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -948,17 +948,27 @@ async def get_event_emitter(request_info, update_db=True): async def get_event_call(request_info): async def __event_caller__(event_data): - response = await sio.call( - 'events', - { - 'chat_id': request_info.get('chat_id', None), - 'message_id': request_info.get('message_id', None), - 'data': event_data, - }, - to=request_info['session_id'], - timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, - ) - return response + session_id = request_info['session_id'] + + # Fast-fail if the client has disconnected. + if session_id not in SESSION_POOL: + log.warning(f'Event caller: session {session_id} no longer connected') + return {'error': 'Client session disconnected.'} + + try: + return await sio.call( + 'events', + { + 'chat_id': request_info.get('chat_id', None), + 'message_id': request_info.get('message_id', None), + 'data': event_data, + }, + to=session_id, + timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, + ) + except TimeoutError: + log.warning(f'Event caller timed out for session {session_id}') + return {'error': 'Event call timed out. The browser tab may be inactive or closed.'} if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: return __event_caller__ diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index dad212c90d..736402d0c8 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -471,9 +471,15 @@ async def execute_code( # Parse the output - pyodide returns dict with stdout, stderr, result if isinstance(output, dict): - stdout = output.get('stdout', '') - stderr = output.get('stderr', '') - result = output.get('result', '') + # Handle error responses from event_caller (e.g. session disconnected, timeout) + if output.get('error') and not output.get('stdout') and not output.get('result'): + stderr = output['error'] + stdout = '' + result = '' + else: + stdout = output.get('stdout', '') + stderr = output.get('stderr', '') + result = output.get('result', '') else: stdout = '' stderr = '' diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b41abda179..5efbd43b1c 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -483,59 +483,18 @@ return; } - if ((event.chat_id !== $chatId && !$temporaryChatEnabled) || isInBackground) { - if (type === 'chat:completion') { - const { done, content, title } = data; - const displayTitle = title || $i18n.t('New Chat'); - - if (done) { - if ( - ($settings?.notificationSound ?? true) && - ($settings?.notificationSoundAlways ?? false) - ) { - playingNotificationSound.set(true); - - const audio = new Audio(`/audio/notification.mp3`); - audio.play().finally(() => { - // Ensure the global state is reset after the sound finishes - playingNotificationSound.set(false); - }); - } - - if ($isLastActiveTab) { - if ($settings?.notificationEnabled ?? false) { - new Notification(`${displayTitle} • Open WebUI`, { - body: content, - icon: `${WEBUI_BASE_URL}/static/favicon.png` - }); - } - } - - toast.custom(NotificationToast, { - componentProps: { - onClick: () => { - goto(`/c/${event.chat_id}`); - }, - content: content, - title: displayTitle - }, - duration: 15000, - unstyled: true - }); - } - } else if (type === 'chat:title') { - currentChatPage.set(1); - await chats.set(await getChatList(localStorage.token, $currentChatPage)); - } else if (type === 'chat:tags') { - tags.set(await getAllTags(localStorage.token)); - } - } else if (data?.session_id === $socket.id) { + // Session-targeted RPC calls (code execution, tool calls, direct completion) + // must ALWAYS be processed regardless of active chat or tab visibility, + // because the backend's sio.call blocks waiting for our callback response. + if (data?.session_id === $socket.id) { if (type === 'execute:python') { console.log('execute:python', data); executePythonAsWorker(data.id, data.code, cb, data.files || []); + return; } else if (type === 'execute:tool') { console.log('execute:tool', data); executeTool(data, cb, event.chat_id); + return; } else if (type === 'request:chat:completion') { console.log(data, $socket.id); const { session_id, channel, form_data, model } = data; @@ -621,8 +580,55 @@ done: true }); } - } else { - console.log('chatEventHandler', event); + return; + } + } + + if ((event.chat_id !== $chatId && !$temporaryChatEnabled) || isInBackground) { + if (type === 'chat:completion') { + const { done, content, title } = data; + const displayTitle = title || $i18n.t('New Chat'); + + if (done) { + if ( + ($settings?.notificationSound ?? true) && + ($settings?.notificationSoundAlways ?? false) + ) { + playingNotificationSound.set(true); + + const audio = new Audio(`/audio/notification.mp3`); + audio.play().finally(() => { + // Ensure the global state is reset after the sound finishes + playingNotificationSound.set(false); + }); + } + + if ($isLastActiveTab) { + if ($settings?.notificationEnabled ?? false) { + new Notification(`${displayTitle} • Open WebUI`, { + body: content, + icon: `${WEBUI_BASE_URL}/static/favicon.png` + }); + } + } + + toast.custom(NotificationToast, { + componentProps: { + onClick: () => { + goto(`/c/${event.chat_id}`); + }, + content: content, + title: displayTitle + }, + duration: 15000, + unstyled: true + }); + } + } else if (type === 'chat:title') { + currentChatPage.set(1); + await chats.set(await getChatList(localStorage.token, $currentChatPage)); + } else if (type === 'chat:tags') { + tags.set(await getAllTags(localStorage.token)); } } }; From 8b78821ba4c65529d71fe080c0f354f0ab814347 Mon Sep 17 00:00:00 2001 From: Jacob Leksan <63938553+jmleksan@users.noreply.github.com> Date: Fri, 8 May 2026 14:17:47 -0400 Subject: [PATCH 034/219] Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379) --- backend/open_webui/routers/files.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 7ca1c2e73f..86beeec188 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -125,7 +125,13 @@ async def process_uploaded_file( if strict_match_mime_type(stt_supported_content_types, content_type): file_path_processed = await asyncio.to_thread(Storage.get_file, file_path) - result = transcribe(request, file_path_processed, file_metadata, user) + result = await asyncio.to_thread( + transcribe, + request, + file_path_processed, + file_metadata, + user, + ) await process_file( request, From d06e6d6ddc520f6e91244264a38457da87f73247 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Fri, 8 May 2026 20:19:25 +0200 Subject: [PATCH 035/219] Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370) --- backend/open_webui/models/channels.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 942c06d6b3..adeaeaf9da 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -4,6 +4,8 @@ import time import uuid from typing import Optional +from open_webui.utils.validate import validate_profile_image_url + from sqlalchemy import select, delete, update, func, case, or_, and_ from sqlalchemy.ext.asyncio import AsyncSession from open_webui.internal.db import Base, JSONField, get_async_db_context @@ -13,7 +15,7 @@ from open_webui.models.access_grants import ( AccessGrants, ) -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy.dialects.postgresql import JSONB @@ -244,6 +246,13 @@ class ChannelWebhookForm(BaseModel): name: str profile_image_url: Optional[str] = None + @field_validator('profile_image_url', mode='before') + @classmethod + def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + return validate_profile_image_url(v) + class ChannelTable: async def _get_access_grants(self, channel_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]: From 4d766a3edfa116abcefe7168f1d1284683b860b2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 03:19:48 +0900 Subject: [PATCH 036/219] refac --- backend/open_webui/models/chats.py | 16 ++++++++-------- src/lib/utils/index.ts | 28 +++++++++++++++++++++++----- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index bcf6951e49..4dc00e9f84 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -366,20 +366,20 @@ class ChatTable: await db.commit() # Dual-write messages to chat_message table - try: - for form_data, chat_obj in zip(chat_import_forms, chats): - history = form_data.chat.get('history', {}) - messages = history.get('messages', {}) - for message_id, message in messages.items(): - if isinstance(message, dict) and message.get('role'): + for form_data, chat_obj in zip(chat_import_forms, chats): + history = form_data.chat.get('history', {}) + messages = history.get('messages', {}) + for message_id, message in messages.items(): + if isinstance(message, dict) and message.get('role'): + try: await ChatMessages.upsert_message( message_id=message_id, chat_id=chat_obj.id, user_id=user_id, data=message, ) - except Exception as e: - log.warning(f'Failed to write imported messages to chat_message table: {e}') + except Exception as e: + log.warning(f'Failed to write imported message {message_id} for chat {chat_obj.id}: {e}') return [ChatModel.model_validate(chat) for chat in chats] diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1820e70481..5432c3b211 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -729,6 +729,7 @@ const convertOpenAIMessages = (convo) => { const messages = []; let currentId = ''; let lastId = null; + const uniqueModels = new Set(); for (const message_id in mapping) { const message = mapping[message_id]; @@ -749,16 +750,28 @@ const convertOpenAIMessages = (convo) => { continue; } - const new_chat = { + const model = + message['message']?.['metadata']?.['model_slug'] || 'gpt-3.5-turbo'; + const timestamp = message['message']?.['create_time'] + ? Math.floor(message['message']['create_time']) + : undefined; + + const new_chat: Record = { id: message_id, parentId: lastId, childrenIds: message['children'] || [], role: role !== 'user' ? 'assistant' : 'user', content: extractOpenAIMessageContent(message['message']), - model: 'gpt-3.5-turbo', + model, done: true, - context: null + context: null, + ...(timestamp !== undefined && { timestamp }) }; + + if (role !== 'user') { + uniqueModels.add(model); + } + messages.push(new_chat); lastId = currentId; } @@ -781,7 +794,7 @@ const convertOpenAIMessages = (convo) => { currentId: currentId, messages: history // Need to convert this to not a list and instead a json object }, - models: ['gpt-3.5-turbo'], + models: uniqueModels.size > 0 ? [...uniqueModels] : ['gpt-3.5-turbo'], messages: messages, options: {}, timestamp: convo['create_time'], @@ -828,12 +841,17 @@ export const convertOpenAIChats = (_chats) => { const chat = convertOpenAIMessages(convo); if (validateChat(chat)) { + // Use created_at/updated_at keys so importChatsHandler passes them + // to the backend correctly (previously used 'timestamp' which was ignored) + const createdAt = convo['create_time'] ? Math.floor(convo['create_time']) : null; + const updatedAt = convo['update_time'] ? Math.floor(convo['update_time']) : createdAt; chats.push({ id: convo['id'], user_id: '', title: convo['title'], chat: chat, - timestamp: convo['create_time'] + created_at: createdAt, + updated_at: updatedAt }); } else { failed++; From cdfcbc4af6e9aec835b88dc1806a2a46711e6947 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 9 May 2026 03:40:23 +0900 Subject: [PATCH 037/219] refac --- backend/open_webui/utils/middleware.py | 11 +++++++ src/lib/components/chat/Chat.svelte | 31 +++++++++++++------- src/lib/components/chat/Messages.svelte | 12 ++++++++ src/lib/components/chat/Navbar.svelte | 2 ++ src/lib/components/layout/Navbar/Menu.svelte | 30 +++++++++++++++++++ 5 files changed, 76 insertions(+), 10 deletions(-) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index f3e829f22c..ff0edd166e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2263,6 +2263,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data = apply_params_to_form_data(form_data, model) log.debug(f'form_data: {form_data}') + # Guided regeneration: extract before it reaches the LLM provider + regeneration_prompt = form_data.pop('regeneration_prompt', None) + # Load messages from DB when available — DB preserves structured 'output' items # which the frontend strips, causing tool calls to be merged into content. chat_id = metadata.get('chat_id') @@ -2298,6 +2301,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Strip files field — it's been incorporated into content message.pop('files', None) + if regeneration_prompt: + form_data['messages'].append({'role': 'user', 'content': regeneration_prompt}) + # Process messages with OR-aligned output items for clean LLM messages form_data['messages'] = process_messages_with_output(form_data.get('messages', [])) @@ -4876,6 +4882,11 @@ async def streaming_chat_response_handler(response, ctx): log.debug(f'Code interpreter output: {ci_output}') + # Handle error responses from event_caller + # (e.g. session disconnected, timeout) + if isinstance(ci_output, dict) and ci_output.get('error'): + ci_output = {'stderr': ci_output['error']} + if isinstance(ci_output, dict): stdout = ci_output.get('stdout', '') diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index eae8d7d233..e7cf14a567 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -121,8 +121,10 @@ let controlPaneComponent: ChatControls | undefined; let messageInput: MessageInput | undefined; + let messagesRef: Messages | undefined; let autoScroll = true; + let isNearTop = true; let processing = ''; let messagesContainerElement: HTMLDivElement; @@ -1413,6 +1415,10 @@ } }; + const scrollToTop = async () => { + await messagesRef?.scrollToTop(); + }; + let scrollRAF = null; let contentsRAF = null; const scheduleScrollToBottom = () => { @@ -1969,11 +1975,13 @@ { messages = null, modelId = null, - modelIdx = null + modelIdx = null, + regenerationPrompt = null }: { messages?: any[] | null; modelId?: string | null; modelIdx?: number | null; + regenerationPrompt?: string | null; } = {} ) => { if (autoScroll) { @@ -2085,7 +2093,8 @@ _history, primaryResponseMessageId, _chatId, - selectedModelIds.length > 1 ? messageIdsMap : undefined + selectedModelIds.length > 1 ? messageIdsMap : undefined, + regenerationPrompt ); if (chatEventEmitter) clearInterval(chatEventEmitter); @@ -2150,7 +2159,8 @@ _history, responseMessageId, _chatId, - messageIdsMap?: Record + messageIdsMap?: Record, + regenerationPrompt?: string | null ) => { const responseMessage = _history.messages[responseMessageId]; const userMessage = _history.messages[responseMessage.parentId]; @@ -2206,6 +2216,8 @@ ? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` } : undefined ].filter(Boolean); + + if ($temporaryChatEnabled) { messages = [ ...messages, @@ -2349,6 +2361,7 @@ ...(messageIdsMap ? { message_ids: messageIdsMap } : {}), parent_id: userMessage?.parentId ?? null, user_message: userMessage, + ...(regenerationPrompt ? { regeneration_prompt: regenerationPrompt } : {}), background_tasks: { ...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null @@ -2580,13 +2593,8 @@ await sendMessage(history, userMessage.id, { ...(suggestionPrompt ? { - messages: [ - ...createMessagesList(history, message.id), - { - role: 'user', - content: suggestionPrompt - } - ] + messages: createMessagesList(history, message.id), + regenerationPrompt: suggestionPrompt } : {}), ...((userMessage?.models ?? [...selectedModels]).length > 1 @@ -2916,6 +2924,7 @@ bind:selectedModels shareEnabled={!!history.currentId} {initNewChat} + scrollToTop={!isNearTop ? scrollToTop : null} {archiveChatHandler} {deleteChatHandler} {moveChatHandler} @@ -2968,10 +2977,12 @@ autoScroll = messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <= messagesContainerElement.clientHeight + 5; + isNearTop = messagesContainerElement.scrollTop <= 100; }} >
{ + messagesCount = null; + buildMessages(); + await tick(); + if (messages.length > 0) { + const firstMessageEl = document.getElementById(`message-${messages[0].id}`); + if (firstMessageEl) { + firstMessageEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }; + const updateChat = async () => { if (!$temporaryChatEnabled) { history = history; diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte index e2a49f7621..43439a7fc6 100644 --- a/src/lib/components/chat/Navbar.svelte +++ b/src/lib/components/chat/Navbar.svelte @@ -45,6 +45,7 @@ export let initNewChat: Function; export let shareEnabled: boolean = false; export let scrollTop = 0; + export let scrollToTop: (() => void) | null = null; export let chat; export let history; @@ -194,6 +195,7 @@ { showShareChatModal = !showShareChatModal; }} diff --git a/src/lib/components/layout/Navbar/Menu.svelte b/src/lib/components/layout/Navbar/Menu.svelte index b8f3b0cf80..f9ea09104e 100644 --- a/src/lib/components/layout/Navbar/Menu.svelte +++ b/src/lib/components/layout/Navbar/Menu.svelte @@ -50,6 +50,7 @@ export let chat; export let onClose: Function = () => {}; + export let scrollToTop: (() => void) | null = null; let showFullMessages = false; @@ -310,6 +311,35 @@
{$i18n.t('Settings')}
--> + + + {#if scrollToTop} + + +
+ {/if} {#if ($artifactContents ?? []).length > 0} +
+
+ +
+ + +
@@ -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 067/219] 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 068/219] 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 069/219] 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 070/219] 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 071/219] 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 072/219] 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 073/219] 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 074/219] 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 075/219] 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 076/219] 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 077/219] 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 078/219] 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 080/219] 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 081/219] 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 082/219] 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 083/219] 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 084/219] 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 085/219] 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 086/219] 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 087/219] 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 088/219] 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 089/219] 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 090/219] 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 091/219] 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 092/219] 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 093/219] 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'} +