Compare commits

...
971 Commits
Author SHA1 Message Date
Tim Baek b8112d72b9 Merge pull request #21507 from open-webui/dev
refac
2026-02-17 01:25:39 -06:00
Timothy Jaeryang Baek 71ccedd2bf refac 2026-02-17 01:25:18 -06:00
Tim Baek 4225791313 Merge pull request #21458 from open-webui/dev
0.8.3
2026-02-17 01:19:39 -06:00
Timothy Jaeryang Baek e5cd1b479b refac 2026-02-17 01:12:57 -06:00
Timothy Jaeryang Baek 61d44aa773 refac 2026-02-17 01:12:33 -06:00
Timothy Jaeryang Baek ef036529b5 chore: format 2026-02-17 01:11:56 -06:00
Timothy Jaeryang Baek e0bdef85ab chore: format 2026-02-17 01:11:48 -06:00
Timothy Jaeryang Baek 2ce935bdb1 refac 2026-02-17 01:10:19 -06:00
Timothy Jaeryang Baek 23b1e2cca4 refac 2026-02-17 01:00:19 -06:00
Timothy Jaeryang Baek 05b8768fb9 refac 2026-02-17 00:48:49 -06:00
Timothy Jaeryang Baek 173d5631ca refac 2026-02-17 00:31:34 -06:00
Timothy Jaeryang Baek 34cd3d79e8 refac 2026-02-16 23:52:32 -06:00
Classic298 aede1b7a08 chore: Changelog updates (#21497)
* changelog: add model edit shortcut entry

* changelog: add Chinese translation updates for 0.8.3

* changelog: fix USER_EMAIL template variable

* changelog: add fix for missing function error handling in models endpoint

* changelog: startup error handling, translation updates

* changelog: PostgreSQL group query fix

* changelog: images, attachments, vision

* changelog: model edit, image handling, user_email, groups, startup

* changelog: image-edit, model-shortcut, general-improvements

* changelog: image edit API background support

* changelog: image API support, function error handling
2026-02-16 18:33:58 -06:00
Timothy Jaeryang Baek 15b893e651 refac 2026-02-16 15:32:28 -06:00
Classic298 4896d30281 chore: changelog (#21474)
* changelog: add model edit shortcut entry

* changelog: add Chinese translation updates for 0.8.3

* changelog: fix USER_EMAIL template variable

* changelog: add fix for missing function error handling in models endpoint

* changelog: startup error handling, translation updates

* changelog: PostgreSQL group query fix
2026-02-16 14:19:33 -06:00
Timothy Jaeryang Baek 9be45f49e4 refac 2026-02-16 14:13:49 -06:00
Timothy Jaeryang Baek f1053d94c7 refac 2026-02-16 14:08:35 -06:00
Timothy Jaeryang Baek 10cfddccd7 refac: styling 2026-02-16 13:39:41 -06:00
Timothy Jaeryang Baek e5e39be90f refac 2026-02-16 13:14:40 -06:00
Timothy Jaeryang Baek 337109e99c refac 2026-02-16 13:10:01 -06:00
joaoback 8adf2b33b4 i18n: (pt-BR): add translations for newly added UI items + consistency pass (#21472)
i18n: (pt-BR): add translations for newly added UI items + consistency pass
2026-02-16 13:08:38 -06:00
Classic298 1984ce42aa Update Chat.svelte (#21479) 2026-02-16 13:03:22 -06:00
Classic298andClaude 656de56a3e fix: gracefully handle missing functions when loading models (#21476)
When models reference functions (via filterIds/actionIds) that no longer
exist in the database, the /api/models endpoint crashes with a 500 error,
preventing the UI from loading chats entirely. This can happen after
upgrades when built-in functions are removed or when user-created
functions are deleted while still referenced by models.

Instead of raising an exception, log at INFO level and skip the missing
function so the rest of the models load successfully.

Fixes #21464

https://claude.ai/code/session_015JRM7m2bNeZPBBmV2Gv4Mj

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-16 13:02:54 -06:00
Shirasawa 0b2abe6cb8 I18n: improve Chinese translation (#21460)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-16 02:17:52 -06:00
Timothy Jaeryang Baek f364b2d205 refac 2026-02-16 02:05:19 -06:00
Timothy Jaeryang Baek 519ff40cb6 refac 2026-02-16 01:56:43 -06:00
Tim Baek 7c7fe44328 Merge pull request #21443 from open-webui/dev
0.8.2
2026-02-16 01:37:13 -06:00
Algorithm5838 15b5f97f89 fix: prevent scroll jump when editing large messages (#21402)
Save and restore scroll positions around textarea auto-resize to avoid
layout shifts. Use preventScroll on focus.
2026-02-16 01:33:51 -06:00
Shamil ca0983f76b i18n: Add missing Russian (ru-RU) translations (#21453)
* i18n: add missing Russian (ru-RU) translations

* i18n: add translations for API Keys and Asc keys
2026-02-16 01:27:23 -06:00
Timothy Jaeryang Baek ef04a704ce chore: format 2026-02-16 01:26:29 -06:00
Timothy Jaeryang Baek 5fda814669 doc: changelog 2026-02-16 01:25:40 -06:00
Timothy Jaeryang Baek c26e8110af enh: renderMarkdownInPreviews 2026-02-16 01:23:56 -06:00
Timothy Jaeryang Baek 9e85055b8b doc: changelog 2026-02-16 00:55:23 -06:00
Timothy Jaeryang Baek 6d17de6c67 chore: bump 2026-02-16 00:53:36 -06:00
Timothy Jaeryang Baek f4e99c80f6 refac: "tool_calls" finish reason support 2026-02-16 00:53:01 -06:00
Timothy Jaeryang Baek 09dc28df1e chore: format 2026-02-16 00:43:32 -06:00
Timothy Jaeryang Baek c748c3ede7 refac 2026-02-16 00:41:36 -06:00
Timothy Jaeryang Baek 33308022f0 refac 2026-02-15 23:57:40 -06:00
Timothy Jaeryang Baek f96e8f04fc refac: styling 2026-02-15 23:50:34 -06:00
Timothy Jaeryang Baek 38ae91ae23 refac 2026-02-15 23:35:40 -06:00
Timothy Jaeryang Baek 88401e91c7 refac 2026-02-15 23:28:47 -06:00
Timothy Jaeryang Baek 8c5cfa530d refac: styling 2026-02-15 22:55:50 -06:00
Timothy Jaeryang Baek d215e46315 refac: styling 2026-02-15 22:49:56 -06:00
Classic298 e10e7d056e chore: changelog (#21424)
* changelog: oauth session error handling

* changelog: task model selector validation fix

* changelog: skill content handling

* changelog: add Spanish and German translations

* changelog: move skill content to Added section

* changelog: tool call, validation fixes

* changelog: tool call message preservation links

* changelog: update per-model tool toggles entry with per-conversation chat toggle feature

* changelog: tool-server, startup, initialization

* changelog: remove empty Changed section

* changelog: fix duplicate Fixed section

* changelog: revert old entry, add new entry to 0.8.2

* changelog: move built-in tool toggles to Added section

* changelog: update date to 2026-02-16

* changelog: resource handle cleanup, file descriptors, leak fix

* changelog: image preview, file modal, modal enhancement
2026-02-15 19:33:20 -06:00
Timothy Jaeryang Baek 7a7d902238 refac 2026-02-15 19:32:22 -06:00
Varun Chawla 24179cde2f fix: preserve trailing slash in MCP server URLs (#21212)
Stop trimming trailing slashes from MCP server URLs on save. Some MCP
servers (e.g. Bitrix24) require a trailing slash; removing it triggers a
301 redirect that drops Authorization headers, resulting in 400 errors.

The trailing-slash trim is now skipped when the connection type is 'mcp',
while OpenAPI connections continue to have it stripped as before.

Fixes open-webui#21179
2026-02-15 19:09:41 -06:00
Timothy Jaeryang Baek 3ae4c618e1 refac 2026-02-15 19:07:53 -06:00
Timothy Jaeryang Baek 4a0d893995 refac 2026-02-15 19:03:08 -06:00
Timothy Jaeryang Baek b780d5c556 refac 2026-02-15 18:41:16 -06:00
Timothy Jaeryang BaekandDario Ruellan 911eecac85 fix: disabled mcp display issue
Co-Authored-By: Dario Ruellan <6965667+druellan@users.noreply.github.com>
2026-02-15 18:10:18 -06:00
Timothy Jaeryang Baek 319d3e8856 refac 2026-02-15 17:55:59 -06:00
Timothy Jaeryang Baek e1b3e7252c enh: preview image in file modal 2026-02-15 17:53:20 -06:00
Timothy Jaeryang BaekandSpootyMcSpoot f20cc6d7e6 refac
Co-Authored-By: SpootyMcSpoot <6732450+spootymcspoot@users.noreply.github.com>
2026-02-15 17:29:55 -06:00
Timothy Jaeryang Baek f1a1e64d2e refac: explicit toggle builtin tools 2026-02-15 17:20:49 -06:00
Classic298 58e923fe00 Update translation.json (#21441) 2026-02-15 16:43:50 -06:00
Timothy Jaeryang Baek f2aca781c8 refac: tool message handling 2026-02-15 16:14:47 -06:00
_00_ ce51c481b8 upd:i18n: Spanish Translation Update v.0.8.0 (#21427)
### Spanish Translation Update v.0.8.0

- Added new strings
2026-02-15 16:05:45 -06:00
Timothy Jaeryang Baek 9a2595f070 fix: task models issue 2026-02-14 22:26:03 -06:00
Timothy Jaeryang Baek 393c0071dc refac: manual skill invocation 2026-02-14 19:22:17 -06:00
Timothy Jaeryang Baek 7e224e4a53 refac 2026-02-13 18:26:03 -06:00
Tim Baek 883f1dda0f Merge pull request #21346 from open-webui/dev
0.8.1
2026-02-13 18:04:11 -06:00
Classic298 12bad452fa chore: Changelog updates (#21382)
* Add v0.8.1 release section

* changelog: knowledge menu layout fix

* changelog: knowledge tooltip z-index fix

* changelog: sync modal community sharing fix

* changelog: postgresql distinct ordering fix

* changelog: security fix public sharing bypass

* changelog: add issue ref to postgresql fix

* changelog: fix postgresql skills json compatibility

* changelog: apply new format style to 0.8.1 entries

* changelog: web search result count fix

* changelog: metadata, document, crash fix

* changelog: add channel user active status performance entry

* changelog: add model and prompt list optimization entry

* changelog: batch access control queries, channel status, model list optimization

* changelog: user list, performance, deferred loading

* Update CHANGELOG.md for 0.8.1

* Add emoji variation to Added section

* Remove empty Changed section and finalize 0.8.1 changelog

* changelog: arm, torch compatibility fix

* changelog: update database migration warning format

* changelog: ollama cloud, model naming fix

* Add SCIM externalId entry and database migration warning to 0.8.1

* Fix: move web search to Added, restore 0.8.0 headers

* Fix: SCIM above translations, 0.8.0 restored

* Remove 0.8.1 migration warning, keep 0.8.0 original

* changelog: direct model access control fix

* changelog: add commit link to direct model access control fix

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* changelog: sqlite, cascade delete, database fix

* changelog: responses, api, model-routing

* Add PR and issue links to SCIM externalId changelog entry

* Add commit link to Responses API entry, remove PR link from translation entry

* changelog: reasoning traces, performance, browser

* changelog: password, validation, regex

* Update CHANGELOG.md
2026-02-13 17:46:52 -06:00
Timothy Jaeryang Baek 5de60dc922 refac 2026-02-13 17:44:52 -06:00
Taylor Wilsdon a30b106ea3 fix issues/21399 (#21400) 2026-02-13 17:42:34 -06:00
Timothy Jaeryang Baek d33ad462aa refac 2026-02-13 17:38:57 -06:00
Timothy Jaeryang Baek 3b61562c82 refac 2026-02-13 17:26:54 -06:00
Timothy Jaeryang Baek e5d88be4f3 doc: changelog 2026-02-13 15:08:20 -06:00
Timothy Jaeryang Baek b36f8d9314 chore: format 2026-02-13 15:00:47 -06:00
Timothy Jaeryang Baek 626d236d13 chore: format 2026-02-13 15:00:39 -06:00
Timothy Jaeryang Baek 79ecbfc757 refac 2026-02-13 14:59:20 -06:00
Timothy Jaeryang Baek a9b8677cc0 refac 2026-02-13 14:59:05 -06:00
Classic298 0f3f68b0c4 enh (#21362) 2026-02-13 14:56:53 -06:00
Timothy Jaeryang BaekandJuan Calderon-Perez abc9b63093 refac
Co-Authored-By: Juan Calderon-Perez <835733+gaby@users.noreply.github.com>
2026-02-13 14:55:13 -06:00
Timothy Jaeryang Baek 64fa26bd28 refac 2026-02-13 14:48:10 -06:00
Timothy Jaeryang Baek 2487c84f1f refac: styling 2026-02-13 14:47:00 -06:00
Timothy Jaeryang Baek 163211a367 refac: styling 2026-02-13 14:43:06 -06:00
Timothy Jaeryang Baek f027a01ab2 fix: direct model access control 2026-02-13 14:39:01 -06:00
Classic298 370a677a38 fix: pin torch to prevent startup errors on ARM devices (#21385)
* fix: rpi

* Update requirements-min.txt

* Update requirements.txt

* Update pyproject.toml
2026-02-13 14:28:23 -06:00
Timothy Jaeryang Baek d1d1efe212 refac: scim 2026-02-13 14:27:11 -06:00
Timothy Jaeryang Baek b7549d2f6c refac: defer profile 2026-02-13 14:08:07 -06:00
Timothy Jaeryang Baek 589c4e64c1 refac 2026-02-13 13:56:29 -06:00
Timothy Jaeryang Baek 20de5a87da refac 2026-02-13 13:43:43 -06:00
Timothy Jaeryang Baek ca6b18ab5c refac: is_user_active 2026-02-13 13:40:59 -06:00
Classic298 97a3b1528d Update utils.py (#21105) 2026-02-13 13:37:12 -06:00
Classic298 d01b1d4880 enh: apply admin default to builtin web search (#21373) 2026-02-13 13:32:48 -06:00
Timothy Jaeryang Baek df6e38039f refac 2026-02-13 13:29:22 -06:00
Timothy Jaeryang Baek b4c3f54f96 fix: skills postgres issue 2026-02-13 11:24:08 -06:00
Classic298 73776d54b8 fix: enforce public sharing permission checks across all resource types (#21358)
The sharePublic prop in editor components (Knowledge, Tools, Skills,
Prompts, Models) incorrectly included an "|| edit" / "|| write_access"
condition, allowing users with write access to see and use the "Public"
sharing option regardless of their actual public sharing permission.
Additionally, all backend access/update endpoints only verified write
authorization but did not check the corresponding sharing.public_*
permission, allowing direct API calls to bypass frontend restrictions
entirely.
Frontend: removed the edit/write_access bypass from sharePublic in all
five editor components so visibility is gated solely by the user's
sharing.public_* permission or admin role.
Backend: added has_public_read_access_grant checks to the access/update
endpoints in knowledge.py, tools.py, prompts.py, skills.py, models.py,
and notes.py. Public grants are silently stripped when the user lacks
the corresponding permission.
Fixes #21356
2026-02-13 11:22:32 -06:00
Timothy Jaeryang BaekandEntropyYue 7bda6bf767 fix: PostgreSQL cannot use get_chat_ids_by_model_id
Co-Authored-By: EntropyYue <164553692+EntropyYue@users.noreply.github.com>
2026-02-13 11:20:26 -06:00
G30 ddcec9842f fix: ensure sync modal only triggers if community sharing is enabled (#21376) 2026-02-13 11:18:58 -06:00
G30 4d5b7b3014 fix: resolve knowledge tooltip z-index issue in model edit page (#21375) 2026-02-13 11:18:35 -06:00
G30 9886ebb97f fix: resolve knowledge collection indentation/truncation issue by correcting flex layout (#21374) 2026-02-13 11:18:14 -06:00
Timothy Jaeryang Baek 0b05b2fc7e refac 2026-02-13 00:44:01 -06:00
joaoback 49e7eade15 Merge pull request #21345 from joaoback/patch-25
i18n: Update translation.json (pt-BR)
2026-02-12 19:42:52 -06:00
Tim Baek 7a7a25766c Merge pull request #21314 from open-webui/dev
0.8.0
2026-02-12 17:42:25 -06:00
Timothy Jaeryang Baek 9fc1658085 refac 2026-02-12 17:42:15 -06:00
Timothy Jaeryang Baek 5297dceb2a chore: format 2026-02-12 17:36:19 -06:00
Timothy Jaeryang Baek 59afbd6f92 refac 2026-02-12 17:35:22 -06:00
Timothy Jaeryang Baek f7af3f010e refac 2026-02-12 17:27:38 -06:00
Timothy Jaeryang Baek bb40724d45 refac 2026-02-12 17:21:53 -06:00
Timothy Jaeryang Baek 0e64b31adb refac 2026-02-12 17:17:45 -06:00
Timothy Jaeryang Baek 850a864b02 refac 2026-02-12 16:54:32 -06:00
Timothy Jaeryang Baek 9468d92553 refac 2026-02-12 16:44:08 -06:00
Timothy Jaeryang Baek c3dc5d5984 refac 2026-02-12 16:41:57 -06:00
Timothy Jaeryang Baek 5291b3dca2 refac 2026-02-12 16:36:00 -06:00
Classic298andClassic298 87d0c112fa Update CHANGELOG.md (#20594)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* docs(changelog): update CHANGELOG.md for file upload settings fix

* docs: add RTL markdown table display fix to CHANGELOG

* changelog: add GPU usage fix for online status indicator

- Fixed high GPU usage (35-40% to near-zero) caused by animate-ping CSS
  animation on the user online status indicator

Refs: #21062, 938d1b0

* docs(changelog): add ANSI color code fix for code execution

- Fixed code execution reliability when LLMs include ANSI terminal
  color codes in their output
- Prevents random syntax errors during code execution
- Related to issue #21091

* fix: restore tool call expand/collapse functionality

A refactor moved tool call display logic into a separate component but inadvertently disabled the collapsible behavior because tool calls store content in attributes rather than body text.

Fixes #21205

* changelog: fix malformed Authorization header crash in Fixed section

- Add entry for malformed Authorization header fix (issue #20938)

The APIKeyRestrictionMiddleware now validates Authorization headers before
processing them, preventing ValueError and HTTP 500 errors.

* Add notification toast close button entry to CHANGELOG

* Update changelog date to 2026-02-07

* fix: respect ENABLE_OLLAMA_API and ENABLE_OPENAI_API flags when url_idx is provided

* Update CHANGELOG.md

* Add Latvian (lv-LV) language translation

* changelog: add relative time indicators for chat updates

* Update CHANGELOG.md

* changelog: add active chat tasks sidebar feature

* changelog: Update date to 2026-02-10 and fix tool call result decoding issue (#20600)

* changelog: Consolidate Open Responses protocol commits

* changelog: Add Irish translations

* changelog: icons, dark-mode, display

* changelog: Fix dark mode icon display issue (#21272)

* changelog: duplicate, icons, dark-mode

* changelog: access-control-ui, sharing-users, api-groups-users

* changelog: reorder access control entries for visibility

* changelog: move api entry down

* changelog: batch-file-ops, knowledge-base, performance

* changelog: add Gboard Android clipboard fix entry

* changelog: opensearch, knowledge-base, typefix

* changelog: add Gboard Android fix entry

* Update CHANGELOG.md

* changelog: expand user sharing entry

* changelog: remove emphasis word from user sharing

* changelog: reformulate user sharing entry per guidelines

* changelog: notifications, new-chat-fallback, device-scoped

* changelog: fix embeddings endpoint batch input for Ollama

* changelog: replace backticks with single quotes per style guidelines

* changelog: ssl-embeddings, aiohttp-session-ssl

* changelog: add Ollama auto-port fallback entry

* changelog: add independent access control endpoints and permission fixes

* changelog: consolidate access control entries and improve permission fix description

* changelog: model selector, virtual scrolling, performance

* changelog: update date to February 11, 2026

* changelog: pagination, model settings, admin

* changelog: add Skill standard feature entry

* changelog: add French to translations list

* changelog: action-html, iframe, rich-content

* changelog: tool call, html entities, multi-turn

* changelog: redis model cache, performance

* changelog: inline-images, context-window, fix-20916

* changelog: fix inline images context window exhaustion

* changelog: audio, timeout, api

* changelog: cache optimization, performance, ttft

* changelog: ldap, authentication, freeze

* changelog: async pipelines, event loop

* changelog: streaming, connection, cleanup

* changelog: add commits for Action Rich UI refactor and group performance

* changelog: model access control, batch queries, database optimization

* changelog: trusted-header, authentication, user-registration

* changelog: add pinned read-only models entry

* changelog: update date to February 12, 2026

* Update CHANGELOG.md

* Update CHANGELOG.md

* changelog: add issue #17058 to streaming response cleanup entry

* changelog: reorder Fixed section by importance priority

* Update CHANGELOG.md

* changelog: add warning about chat message table migration duration

* changelog: openai-api-router, model-cache-lookup, bugfix

* changelog: ollama-model-name-fix, provider-compatibility

* changelog: fix Ollama-compatible provider model name issue #21331

* changelog: OpenAI API endpoint detection fix

* changelog: model list and import performance optimization

* changelog: external tool event emitter message id support #21214

* changelog: fix large document upload worker timeout #21158

* changelog: fix RedisCluster task stopping

* changelog: add discussion link to #21158

* changelog: add commit c653e4ec to #21158 entry

* changelog: functions, chat_id, internal_tasks

* changelog: channel permissions security fix

---------

Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com@>
2026-02-12 16:15:05 -06:00
Timothy Jaeryang Baek 2a11175f22 chore: format 2026-02-12 16:13:48 -06:00
Timothy Jaeryang Baek 3238d94a0e refac 2026-02-12 15:57:27 -06:00
Timothy Jaeryang Baek 8919d8a82a refac 2026-02-12 15:52:50 -06:00
Classic298andTim Baek ea4ef28da5 init (#20883)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-02-12 15:50:13 -06:00
Timothy Jaeryang Baek 2ffd8d9277 refac 2026-02-12 15:40:13 -06:00
Classic298 e8499ccdd1 fix: pass chat_id to internal task calls for consistent function context (#20585)
Ensure chat_id is reliably passed to function pipelines/manifolds during internal task invocations (web search query generation, RAG query generation, image prompt generation).

This allows stateful functions to maintain per-chat state without fragmentation, as they will now receive a consistent chat_id for all chat-scoped invocations including internal tasks.

Backend changes:
- Pass chat_id in generate_queries call for web search
- Pass chat_id in generate_queries call for RAG/retrieval
- Pass chat_id in generate_image_prompt call

Frontend changes:
- Add optional chat_id parameter to generateQueries API function
- Add optional chat_id parameter to generateAutoCompletion API function

Fixes #20563
2026-02-12 15:36:42 -06:00
Timothy Jaeryang Baek 0dcbd05e24 refac 2026-02-12 15:29:34 -06:00
Timothy Jaeryang Baek c653e4ec54 refac 2026-02-12 15:25:24 -06:00
Classic298andClaude 8cf32ae2a7 fix: prevent worker death during document upload by using run_coroutine_threadsafe (#21158)
* fix: prevent worker death during document upload by using run_coroutine_threadsafe

Replace asyncio.run() with asyncio.run_coroutine_threadsafe() in
save_docs_to_vector_db() to prevent uvicorn worker health check failures.

The issue: asyncio.run() creates a new event loop and blocks the thread
completely, preventing the worker from responding to health checks during
long-running embedding operations (>5 seconds default timeout).

The fix: Schedule the async embedding work on the main event loop using
run_coroutine_threadsafe(). This keeps the main loop responsive to health
check pings while the sync caller waits for the result.

Changes:
- main.py: Store main event loop reference in app.state.main_loop at startup
- retrieval.py: Use run_coroutine_threadsafe() instead of asyncio.run()

https://claude.ai/code/session_01UQSYvSTkXb57sFb7M85Kcw

* add env var

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-12 15:22:57 -06:00
Classic298 c6af296b60 add message id (#21214) 2026-02-12 15:04:27 -06:00
Timothy Jaeryang Baek 531ac70ce5 refac 2026-02-12 12:07:45 -06:00
Timothy Jaeryang Baek 423d8b1817 refac 2026-02-12 11:04:34 -06:00
EntropyYue 633505460a i18n: improve zh-CN translation (#21319) 2026-02-12 11:02:41 -06:00
Aleix Dorca c8bf390680 i18n: Update catalan translation (#21320)
* Update catalan translation.json type

* Update catalan translation.json
2026-02-12 11:02:25 -06:00
Classic298 da46c1bbd2 Update translation.json (#21324) 2026-02-12 11:02:10 -06:00
Timothy Jaeryang Baek 05ae44b98d refac 2026-02-12 11:01:46 -06:00
Timothy Jaeryang Baek a40808579f refac 2026-02-12 10:59:41 -06:00
Timothy Jaeryang Baek 4d024c91d6 refac 2026-02-11 18:33:35 -06:00
Timothy Jaeryang Baek ccb71a7322 refac 2026-02-11 18:32:14 -06:00
Classic298andMichael efe5416f83 fix: reduce TTFT by caching model lookups in chat completion (#20886)
fix: reduce TTFT by caching model lookups in chat completion

Skip expensive get_all_models() calls when models are already cached
in app.state. This significantly reduces Time To First Token (TTFT)
for chat completions and embeddings requests.

Previously, every request called get_all_models() which fetches model
lists from all configured backends. Now we check the cache first and
only call get_all_models() on cache miss.

Affected endpoints:
- openai: generate_chat_completion, embeddings
- ollama: embed, embeddings

Fixes #20069

Co-authored-by: Michael <42099345+mickeytheseal@users.noreply.github.com>
2026-02-11 18:29:10 -06:00
Timothy Jaeryang Baek a4281f6a7f refac: ldap 2026-02-11 18:25:37 -06:00
Timothy Jaeryang Baek 2372b70031 refac: async pipelines requests 2026-02-11 18:24:30 -06:00
Classic298andClaude d02e826c9d Fix idle in transaction leaks in Open WebUI (#20868)
* fix: add ScopedSession.remove() to prevent idle transaction leaks

The HTTP middleware was calling ScopedSession.commit() but not
ScopedSession.remove(), causing database connections to remain
"checked out" from the pool indefinitely. This resulted in
"idle in transaction" connections in PostgreSQL that could persist
for 30-50+ minutes.

With SQLAlchemy's scoped_session:
- commit() commits but keeps the session active
- remove() is required to return the connection to the pool

This fix adds the missing remove() call, ensuring connections are
properly returned after each HTTP request.

Also includes IDLE_TRANSACTION_ANALYSIS.md documenting the full
root cause analysis and additional recommendations.

* Delete IDLE_TRANSACTION_ANALYSIS.md

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 18:20:03 -06:00
Timothy Jaeryang BaekandClassic298 dddac2b0ca refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-02-11 18:19:01 -06:00
Timothy Jaeryang BaekandClassic298 0da57149ae refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-02-11 18:13:30 -06:00
Classic298 0bebb260bf fix: decode HTML entities in tool call results for multi-turn conversations (#20755) 2026-02-11 18:11:41 -06:00
Thomas Rehn 390d7663b0 fix: don't pollute model text context with encoded images (#20916) 2026-02-11 18:10:22 -06:00
Thomas Rehn ce3a615442 perf: cache OpenAI config reads to avoid redundant Redis lookups in /api/models (#21306)
Each access to request.app.state.config.<KEY> triggers a synchronous
Redis GET. In get_all_models_responses() and get_merged_models(), the
config keys OPENAI_API_BASE_URLS, OPENAI_API_KEYS, and
OPENAI_API_CONFIGS were read on every loop iteration — resulting
in some cases in 200-300 Redis round-trips for OPENAI_API_BASE_URLS alone.

Read each config value once into a local variable at the start of the
function and reuse it throughout.
2026-02-11 17:59:50 -06:00
Timothy Jaeryang BaekandJannik S. 4e0cb88583 refac: audio timeout
Co-Authored-By: Jannik S. <jannik@streidl.dev>
2026-02-11 17:56:49 -06:00
Timothy Jaeryang Baek 9b30e8f689 refac 2026-02-11 17:53:01 -06:00
Timothy Jaeryang Baek 97331bf11d refac 2026-02-11 17:26:25 -06:00
Timothy Jaeryang Baek 96c07f44a8 refac 2026-02-11 16:45:47 -06:00
Timothy Jaeryang Baek 60ada21c15 refac 2026-02-11 16:40:40 -06:00
Classic298andClaude ab20745ee5 Enable Rich UI embed support for action functions (#21294)
Action functions can now return HTMLResponse objects or (html, headers)
tuples with Content-Disposition: inline to render rich UI iframes in
chat, matching the existing tool behavior.

https://claude.ai/code/session_01KCZKQXj1uqgPjqjMd4U2NF

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-11 16:27:00 -06:00
Timothy Jaeryang Baek f376d4f378 chore: format 2026-02-11 16:24:11 -06:00
Florian 89fddcc741 update french translation.json (#21295) 2026-02-11 16:22:55 -06:00
Timothy Jaeryang Baek 773787c74c refac 2026-02-11 16:16:41 -06:00
Tim Baek 5c1c9a4dcb Merge pull request #21312 from open-webui/skills
feat: skills
2026-02-11 16:01:08 -06:00
Timothy Jaeryang Baek 9b925a115a refac 2026-02-11 16:00:19 -06:00
Timothy Jaeryang Baek e5035ea31e refac 2026-02-11 15:55:23 -06:00
Timothy Jaeryang Baek c8cbdc8f7f refac 2026-02-11 15:24:12 -06:00
Timothy Jaeryang Baek 64c37ab968 refac 2026-02-11 15:12:37 -06:00
Timothy Jaeryang Baek f7c5965a70 feat: $ skills mention 2026-02-11 15:08:59 -06:00
Timothy Jaeryang Baek 46aa54b7dc refac 2026-02-11 14:35:45 -06:00
Timothy Jaeryang Baek 27944cf7ca refac 2026-02-11 14:26:56 -06:00
Timothy Jaeryang Baek 1973115678 feat: skills frontend 2026-02-11 14:22:26 -06:00
Timothy Jaeryang Baek a38ad8fc42 refac 2026-02-11 14:09:55 -06:00
Timothy Jaeryang Baek c2207887b3 feat: skills backend 2026-02-11 14:00:34 -06:00
Timothy Jaeryang Baek 3fabc085cc feat: skills migration 2026-02-11 03:58:28 -06:00
Timothy Jaeryang Baek 2f584c9f88 refac 2026-02-11 02:31:17 -06:00
Timothy Jaeryang Baek ba18d6250a refac 2026-02-11 02:26:01 -06:00
Timothy Jaeryang Baek 4331029926 refac 2026-02-11 02:20:07 -06:00
Timothy Jaeryang Baek 3e56261c5e refac 2026-02-11 02:06:43 -06:00
Timothy Jaeryang Baek 30f72672fa refac 2026-02-10 15:57:08 -06:00
Timothy Jaeryang Baek 4aedfdc547 refac 2026-02-10 15:47:21 -06:00
Timothy Jaeryang Baek e3a8257690 refac 2026-02-10 15:41:11 -06:00
Timothy Jaeryang Baek a73cdf4288 refac: dmr fallback for ollama 2026-02-10 15:38:21 -06:00
Timothy Jaeryang Baek c259c87806 refac 2026-02-10 15:30:16 -06:00
Timothy Jaeryang Baek 0044902c08 refac 2026-02-10 15:15:17 -06:00
Timothy Jaeryang Baek cd31b8301b refac 2026-02-10 12:44:31 -06:00
Timothy Jaeryang Baek 8fd5c06e5b refac 2026-02-10 12:23:08 -06:00
Classic298andClaude 97afe3bc58 enh: skip chat notifications on other devices if temporary chat (#21292)
* Merge pull request #20581 from Classic298/fix/db-pool-memory-update (#150)

Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21290

* Update +layout.svelte

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-10 13:21:57 -05:00
Timothy Jaeryang Baek 3567054325 refac 2026-02-09 18:22:28 -06:00
Classic298 f236192fe1 fix: resolve N+1 query in knowledge batch file add (#21006) 2026-02-09 16:17:30 -06:00
Varun Chawla 9b1fd86aa7 fix: use keyword argument for IndicesClient.refresh() for opensearch-py 3.x (#21248)
In opensearch-py >= 3.0.0, IndicesClient.refresh() no longer accepts the
index name as a positional argument. This causes a TypeError when
uploading documents to knowledge bases with OpenSearch backend.

Changes positional arguments to keyword arguments (index=...) in all
three refresh() calls in the OpenSearch vector DB client.

Fixes #20649
2026-02-09 16:16:44 -06:00
Classic298 55169e69c0 chore: Dep bump (#21261)
* Update pyproject.toml

* Update requirements-min.txt

* Update package versions in requirements.txt

Updated various package versions in requirements.txt to latest releases.
2026-02-09 16:15:39 -06:00
Algorithm5838 e3e4e1d9d3 fix: handle Gboard clipboard strip multi-line paste via beforeinput (#21265)
Gboard's clipboard suggestion strip sends multi-line pastes as
'insertText' in beforeinput rather than a standard paste event,
causing ProseMirror to drop the text content and insert only a
newline. Add a beforeinput handler that intercepts multi-line
insertText events on Android and manually inserts with hard breaks
to preserve the formatting.
2026-02-09 16:15:08 -06:00
Timothy Jaeryang Baek c2f5cb542e refac 2026-02-09 14:03:35 -06:00
Timothy Jaeryang Baek 1034b74abd refac 2026-02-09 13:48:48 -06:00
Timothy Jaeryang Baek 0a44d80252 refac 2026-02-09 13:42:27 -06:00
Tim Baek 48a0abb40f Merge pull request #21277 from open-webui/acl
refac: acl
2026-02-09 13:34:36 -06:00
Timothy Jaeryang Baek 68c77295bd refac 2026-02-09 13:31:59 -06:00
Timothy Jaeryang Baek 3c7f9aa6a4 refac 2026-02-09 13:28:21 -06:00
Timothy Jaeryang Baek f7406ff576 refac 2026-02-09 13:28:14 -06:00
65904b867e i18n: Updated Irish translation (#21253)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-02-09 14:25:28 -05:00
Tim Baek e2d09ac361 refac 2026-02-09 09:06:48 +04:00
Timothy Jaeryang Baek 3ae44d11a5 refac 2026-02-08 22:37:13 -06:00
Tim Baek aa8c2959ca refac 2026-02-09 08:07:33 +04:00
Timothy Jaeryang Baek b147616080 refac 2026-02-08 21:32:12 -06:00
Timothy Jaeryang Baek 9747b07ca5 refac 2026-02-08 21:24:38 -06:00
Timothy Jaeryang Baek 0f78451c2b refac 2026-02-08 21:24:20 -06:00
Timothy Jaeryang Baek 42763cbbd8 enh: access grants table 2026-02-08 21:21:25 -06:00
Timothy Jaeryang Baek d193c143a5 refac 2026-02-08 21:18:38 -06:00
Tim Baek 26460917c4 refac 2026-02-08 07:20:28 +04:00
Tim Baek fd97ae9bd7 refac 2026-02-08 06:48:41 +04:00
Tim Baek c3fa0f30fe refac 2026-02-08 06:45:01 +04:00
Tim Baek 4852227158 refac 2026-02-08 06:22:56 +04:00
Timothy Jaeryang Baek 9be85b6d3c refac: access control migration 2026-02-07 19:07:49 -06:00
Tim Baek 59b98ab730 refac 2026-02-08 03:42:57 +04:00
Tim Baek 690686f3c7 refac 2026-02-08 03:40:54 +04:00
Tim Baek 691a04f0dd refac 2026-02-08 03:39:29 +04:00
4fa6123c04 i18n: Add Latvian translation (#21234)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Edgaras0x4E <edgaras217t@gmail.com >
2026-02-08 03:33:34 +04:00
Classic298 494cf8b3ef fix (#21226) 2026-02-08 03:19:26 +04:00
Tim Baek 73bb600034 refac 2026-02-06 23:35:21 +04:00
Tim Baek 9cf4d34832 refac 2026-02-06 23:21:09 +04:00
Tim Baek 284b97bd84 refac 2026-02-06 22:46:56 +04:00
Tim Baek 7e79f8d1c6 refac 2026-02-06 22:39:20 +04:00
Tim Baek 258454276e fix: files settings save issue 2026-02-06 22:33:49 +04:00
Tim Baek 938d1b0743 refac 2026-02-06 22:29:16 +04:00
Tim Baek b1737040a7 refac 2026-02-06 22:25:18 +04:00
Timothy Jaeryang Baek 26286625f4 refac 2026-02-05 23:40:58 -06:00
Tim Baek a214ec40ea fix 2026-02-06 03:34:21 +04:00
Tim Baek 2c37daef86 refac 2026-02-06 03:23:37 +04:00
Tim Baek 8e79b3d0bc refac 2026-02-06 03:03:34 +04:00
Tim Baek 6c0f886cdf refac 2026-02-06 02:48:35 +04:00
Shirasawa 225036863f I18n: improve Chinese translation (#21181)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-06 02:28:37 +04:00
G30 cac5dd12e9 fix: handle null data in model_response_handler (#21112)
Fix `AttributeError` in `model_response_handler` when processing channel messages with `null` data field. The function iterates over thread messages to build conversation history, but some messages may have `data=None` causing a crash when accessing `thread_message.data.get()`. Added null check using `(thread_message.data or {}).get("files", [])` to safely handle messages without data.
2026-02-05 15:15:34 -05:00
Classic298 f751c0b46c Update builtin.py (#21115) 2026-02-05 15:14:58 -05:00
Classic298 9ed8f50d40 fix: bundle NLTK punkt_tab in Docker image for airgapped environments (#21165)
Pre-download NLTK punkt_tab during Docker build instead of at runtime.
This fixes document extraction failures in offline/airgapped environments
where the container cannot download the tokenizer data after restarts.
Fixes #21150
2026-02-05 15:11:00 -05:00
Tim Baek d3f2cf7474 refac 2026-02-05 10:24:12 +04:00
Tim Baek 62750b8980 feat: queue messages 2026-02-05 10:06:37 +04:00
Timothy Jaeryang Baek e62649f940 enh: analytics 2026-02-05 00:00:49 -06:00
Timothy Jaeryang Baek 0e60c757ce refac 2026-02-04 23:47:16 -06:00
Timothy Jaeryang Baek 68a1e87b66 enh: analytics model modal 2026-02-04 23:42:46 -06:00
Timothy Jaeryang Baek e8a36f033b refac 2026-02-04 23:05:08 -06:00
Timothy Jaeryang Baek 2cf2565e80 refac 2026-02-04 22:57:46 -06:00
Timothy Jaeryang Baek 5669d1062c refac 2026-02-04 21:54:45 -06:00
Timothy Jaeryang Baek 3ace75820e refac 2026-02-04 21:46:20 -06:00
Timothy Jaeryang Baek 020cb0d4bf refac 2026-02-04 21:09:05 -06:00
Timothy Jaeryang Baek 8b75d34a8a refac 2026-02-04 21:07:30 -06:00
Timothy Jaeryang Baek 6320a9aaa9 refac 2026-02-04 20:47:21 -06:00
Timothy Jaeryang Baek 6cd35b185d refac 2026-02-04 00:13:05 -06:00
Timothy Jaeryang Baek a1ea854b38 refac 2026-02-04 00:08:46 -06:00
Timothy Jaeryang Baek 405dc26cc6 refac 2026-02-04 00:04:09 -06:00
Timothy Jaeryang Baek fe681abd33 refac: tags 2026-02-03 23:58:11 -06:00
Timothy Jaeryang Baek 43c68468f7 refac 2026-02-03 23:38:03 -06:00
Timothy Jaeryang Baek ecf3fa2feb refac 2026-02-03 23:36:15 -06:00
EntropyYue 4aacaeb9b8 fix: Shortcut i18n (#21138) 2026-02-03 16:52:38 -06:00
Timothy Jaeryang Baek afc56b9746 refac 2026-02-03 16:34:29 -06:00
Timothy Jaeryang Baek 0a61666197 refac 2026-02-03 16:33:12 -06:00
Thomas Rehn a9e0462e57 feat: add keyboard shortcut for opening the model selector (#21130) 2026-02-03 14:09:02 -06:00
Tim Baek a2b9986a75 Merge pull request #21129 from alpha-pet/fix-redis-cluster-otel
fix: OTEL instrumentation for redis in cluster mode
2026-02-03 14:07:51 -06:00
Thomas Rehn 654172d757 fix: redis clustermode instrumentation 2026-02-03 15:25:37 +01:00
Timothy Jaeryang Baek 527d48efa9 refac 2026-02-02 10:04:13 -06:00
Tim Baek cda08aaed4 Merge pull request #21106 from open-webui/chat-message-rebased
feat: analytics
2026-02-02 09:34:18 -06:00
Tim Baek cfd30581d5 Merge branch 'dev' into chat-message-rebased 2026-02-02 09:33:41 -06:00
Timothy Jaeryang Baek 3c0313f41b refac 2026-02-02 09:31:52 -06:00
Timothy Jaeryang Baek d938eb0e76 refac 2026-02-02 09:23:46 -06:00
Classic298 60f2f8c1c4 Update pyproject.toml (#21087) 2026-02-02 09:21:39 -06:00
Timothy Jaeryang Baek b0c5f7b668 refac 2026-02-02 09:02:34 -06:00
Timothy Jaeryang Baek 767343dc5b refac 2026-02-02 08:57:26 -06:00
Timothy Jaeryang Baek c22bb4f853 refac 2026-02-02 08:55:13 -06:00
Timothy Jaeryang Baek 117c091b95 refac 2026-02-01 20:07:11 -06:00
Timothy Jaeryang Baek 6719558150 refac 2026-02-01 20:06:38 -06:00
Timothy Jaeryang Baek 6ffce4bccd refac 2026-02-01 20:00:21 -06:00
Timothy Jaeryang Baek ea9c58ea80 feat: experimental responses api support 2026-02-01 19:39:28 -06:00
Tim Baek b2c2f1bd49 refac 2026-02-01 10:24:04 +04:00
Tim Baek 679e56c494 feat: token analytics 2026-02-01 10:19:59 +04:00
Tim Baek 3da4323ef3 refac 2026-02-01 10:11:13 +04:00
Tim Baek 75e5a485d2 refac 2026-02-01 10:06:52 +04:00
Tim Baek 7bb3a827bb refac 2026-02-01 08:10:25 +04:00
Tim Baek 96f106319e refac 2026-02-01 07:59:02 +04:00
Tim Baek a4ad34841b feat: analytics frontend dashboard
- Add Dashboard with summary stats, model/user tables
- Add ChartLine component with multi-model support
- Interactive hover tooltips and model breakdown
- Hourly granularity for 24h, daily for 7d+
2026-02-01 07:04:23 +04:00
Tim Baek 599cd2eeeb feat: analytics backend API with chat_message table
- Add chat_message table for message-level analytics with usage JSON field
- Add migration to backfill from existing chats
- Add /analytics endpoints: summary, models, users, daily
- Support hourly/daily granularity for time-series data
- Fill missing days/hours in date range
2026-02-01 07:04:13 +04:00
Tim Baek ee5fd1246c refac 2026-02-01 03:26:59 +04:00
Timothy Jaeryang Baek 1441d0d735 refac 2026-01-31 17:03:29 -06:00
Timothy Jaeryang Baek e5dbfc420d refac 2026-01-31 17:03:24 -06:00
Classic298 643c661a6f chore: Updates minor/patch versions for 21 Python backend dependencies (#21059) 2026-01-31 16:51:08 -06:00
Timothy Jaeryang Baek ab5dfbda54 refac 2026-01-30 11:12:54 -05:00
Timothy Jaeryang Baek 94302de49b feat: images playground 2026-01-30 10:54:04 -05:00
Timothy Jaeryang Baek 45d7486485 refac 2026-01-30 10:43:40 -05:00
Classic298 ee27fd8de1 perf: replace double lookup with single JOIN in authenticate_user_by_email (#21010)
* perf: replace double lookup with single JOIN in authenticate_user_by_email

* Update auths.py
2026-01-30 10:22:07 -05:00
Classic298 89f154630f fix 404 (#21047) 2026-01-30 10:15:40 -05:00
Timothy Jaeryang Baek a6ed0ef9f4 refac 2026-01-30 10:13:14 -05:00
Classic298 aac98120c8 perf: batch fetch filter functions to eliminate N+1 queries (#21018) 2026-01-30 00:50:04 +04:00
Timothy Jaeryang Baek 44e36e5b0d refac 2026-01-30 00:46:56 +04:00
Timothy Jaeryang BaekandHsienz f9ab66f51a refac
Co-Authored-By: Hsienz <55347238+hsienz@users.noreply.github.com>
2026-01-30 00:46:42 +04:00
Classic298 ce8ed5b5ec feat(files): add shift+click quick delete to File Manager (#21044)
* feat(files): add shift+click quick delete to File Manager

Add shift+click functionality to FilesModal for rapid file deletion without confirmation dialogs.
Changes:
- Track Shift key state via keyboard event listeners
- When Shift is held, delete button bypasses confirmation and deletes immediately
- Visual feedback: delete icon turns red when Shift is held
- Optimized delete to remove file from local array instead of re-fetching entire list, enabling rapid successive deletions without UI flicker
This matches the quick delete pattern used in other workspace components like Tools, Prompts, and Models.

* Update FilesModal.svelte
2026-01-30 00:18:39 +04:00
Classic298 baef422a28 perf: eliminate redundant query after memory update (#21013)
## Summary
Eliminates redundant database query in update_memory_by_id_and_user_id. Previously, after modifying the memory object, it called get_memory_by_id which opened a new session and queried again.
## Changes
models/memories.py update_memory_by_id_and_user_id:
- Replace self.get_memory_by_id(id) with db.refresh(memory)
- Return the same memory object that was already modified
## Performance Impact
Before: 2 queries (get + get_memory_by_id)
After: 1 query + refresh on same session
2026-01-29 21:45:09 +04:00
Classic298 68e257849d perf: optimize database queries in functions, feedbacks, and groups (#21019) 2026-01-29 21:44:50 +04:00
Classic298 e686554392 fix: resolve N+1 query in SCIM group_to_scim user lookup (#21005) 2026-01-29 21:43:33 +04:00
Timothy Jaeryang Baek 96a9696383 enh: admin models 2026-01-29 21:33:56 +04:00
ShirasawaandTim Baek fa84ff5e12 I18n: improve Chinese translation (#20902)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-01-29 20:58:26 +04:00
7. Sun 66359d5815 fix: clean up all event listeners in +layout.svelte onMount (#20913)
Extract anonymous touch event handlers into named functions and add
proper cleanup for all event listeners (message, touchstart, touchmove,
touchend, visibilitychange) in the onMount return function.

Previously only the resize listener was being cleaned up, causing
memory leaks during navigation and hot-reloads.
2026-01-29 20:50:25 +04:00
Classic298 c111fa0837 fix: add lang="ts" to Svelte script tags using TypeScript syntax (#21001)
fix: add lang="ts" to Svelte script tags using

TypeScript syntax
Two Svelte components were using TypeScript type annotations (ReturnType<typeof setTimeout>)
but their script tags were missing the lang="ts" attribute. This caused Vite/Svelte to parse
them as plain JavaScript, resulting in parse errors.

Fixed files:
- src/lib/components/admin/Users/UserList.svelte
- src/lib/components/channel/ChannelInfoModal/UserList.svelte
2026-01-29 20:49:25 +04:00
Timothy Jaeryang Baek 6e182940e2 refac 2026-01-29 20:30:22 +04:00
Timothy Jaeryang Baek bc90463ea6 refac 2026-01-29 20:15:23 +04:00
Timothy Jaeryang Baek 93ed4ae2cd enh: files data controls 2026-01-29 19:50:06 +04:00
Timothy Jaeryang Baek a10ac774ab enh: manage shared chats 2026-01-29 18:51:02 +04:00
Timothy Jaeryang Baek 26a5d8f75d refac 2026-01-29 18:05:07 +04:00
Classic298 5749f78262 fix: reduce triple query to single fetch in model toggle (#21009) 2026-01-29 17:24:09 +04:00
Classic298 1eaae9d934 perf: eliminate redundant SELECT after UPDATE in users.py (#21011) 2026-01-29 17:22:28 +04:00
Cevat Batuhan Tolon 567b0776cd REDIS_RECONNECT_DELAY as positive float for handle retry delay on timeout/connection errors (#21021) 2026-01-29 17:13:03 +04:00
72f330133a add slovak translations (#21032)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Tomáš Lokša <loksa@kros.sk>
2026-01-29 17:11:35 +04:00
Timothy Jaeryang Baek 665f95eda3 refac 2026-01-28 01:18:39 +04:00
Tim Baek 8e2b0b6fd2 enh: playground export 2026-01-27 16:17:17 -05:00
Timothy Jaeryang Baek ce50d9bac4 refac 2026-01-28 01:14:22 +04:00
Timothy Jaeryang Baek 934bebd8cd refac 2026-01-28 01:02:10 +04:00
Timothy Jaeryang Baek 171940869b refac 2026-01-28 00:49:12 +04:00
7. Sun 33020d826f perf: parallelize image loading in image_edits endpoint (#20911)
Use asyncio.gather() to load multiple images concurrently instead of
sequentially, significantly reducing latency for multi-image edit
operations.
2026-01-28 00:35:25 +04:00
Classic298 2c12278444 perf: Debounce various Database Endpoints for less Database Queries and better Backend performance (#20982)
* Update KnowledgeSelector.svelte

* Update KnowledgeSelector.svelte

* Update Users.svelte

* Update MemberSelector.svelte

* Update MemberSelector.svelte

* Update Knowledge.svelte

* Update Knowledge.svelte

* Update Notes.svelte

* Update Knowledge.svelte

* Update Prompts.svelte

* Update Tools.svelte

* Update Tools.svelte

* Update Prompts.svelte

* Update Prompts.svelte

* Update Prompts.svelte

* Update Functions.svelte

* Update UserList.svelte

* Update Functions.svelte

* Update Prompts.svelte

* Update UserList.svelte
2026-01-28 00:33:23 +04:00
Timothy Jaeryang Baek 57a2024c58 refac 2026-01-27 23:55:49 +04:00
Classic298 d67fe02263 Update translation.json (#20959) 2026-01-27 23:40:17 +04:00
Timothy Jaeryang Baek 57ec2aa088 refac 2026-01-27 23:34:23 +04:00
Timothy Jaeryang Baek fa859de460 refac 2026-01-27 23:21:31 +04:00
Timothy Jaeryang Baek 36766f157d refac: prompts pagination 2026-01-27 23:01:56 +04:00
Timothy Jaeryang Baek 683438b418 refac 2026-01-27 21:37:20 +04:00
Timothy Jaeryang Baek 4a55167759 refac 2026-01-26 18:04:58 +04:00
c5c4aef7b1 Yandex web search (#20922)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-26 07:31:44 -05:00
Peter Dave Hello 533c7b27eb i18n: Improve zh-TW Traditional Chinese translation (#20920) 2026-01-26 07:30:56 -05:00
Tim Baek 82af218790 Merge pull request #20945 from open-webui/prompt_versioning
enh: prompts
2026-01-26 16:27:28 +04:00
Classic298 b272ca5e88 fix: remove invalid expunge call on Pydantic FileModel (#20931)
fix: remove invalid expunge call on Pydantic FileModel
Files.get_file_by_id() returns a Pydantic FileModel, not an SQLAlchemy
ORM object. Calling db.expunge() on a Pydantic model fails with
UnmappedInstanceError since it lacks _sa_instance_state.
The expunge was also unnecessary because subsequent DB updates already
use fresh sessions via get_db() context manager.
Fixes #20925
2026-01-26 07:24:53 -05:00
Timothy Jaeryang Baek 10ba2accf7 refac 2026-01-26 16:18:49 +04:00
Timothy Jaeryang Baek 4c8d4e6dbd enh: prompt tags 2026-01-26 16:11:10 +04:00
Tim Baek 6359628bc3 refac 2026-01-26 07:01:05 -05:00
Classic298 25fd342261 Update retrieval.py (#20930) 2026-01-26 15:29:15 +04:00
Timothy Jaeryang Baek f199c486a2 refac 2026-01-24 04:29:56 +04:00
Timothy Jaeryang Baek 1f205a8441 refac 2026-01-24 04:19:51 +04:00
Timothy Jaeryang Baek b1d5b3b28e refac 2026-01-24 04:17:11 +04:00
Timothy Jaeryang Baek 32810b4152 refac 2026-01-24 04:13:19 +04:00
Timothy Jaeryang Baek b7e9992d78 refac 2026-01-24 03:25:29 +04:00
Timothy Jaeryang Baek 6c76983999 refac 2026-01-24 03:21:47 +04:00
Timothy Jaeryang Baek 8bf46dcc5d refac 2026-01-24 03:16:34 +04:00
Timothy Jaeryang Baek 5510fa178e refac 2026-01-24 03:10:29 +04:00
Timothy Jaeryang Baek 5ad593e465 refac: prompt endpoints 2026-01-24 03:08:48 +04:00
Timothy Jaeryang Baek dff0141160 refac 2026-01-24 02:58:23 +04:00
Timothy Jaeryang Baek 44da9c6523 refac 2026-01-24 02:57:08 +04:00
Timothy Jaeryang Baek 6ab7d54982 refac 2026-01-24 02:51:44 +04:00
Timothy Jaeryang Baek 0c79a566ac feat: prompt history frontend 2026-01-24 02:40:17 +04:00
Timothy Jaeryang Baek 34773e795b wip: prompt history backend integration 2026-01-24 02:39:48 +04:00
Timothy Jaeryang Baek 66daa15722 wip: prompt history models 2026-01-24 02:39:29 +04:00
Timothy Jaeryang Baek db80dd2692 feat: prompt history table 2026-01-24 02:38:57 +04:00
Tim Baek 0dc74a8a2e refac 2026-01-23 08:33:21 -05:00
Tim Baek 90a057f400 refac 2026-01-23 08:25:19 -05:00
Timothy Jaeryang Baek 78f856e204 enh: quick prompt copy 2026-01-23 16:40:11 +04:00
Tim Baek d2c695eb11 feat: add convert_output_to_messages for OR-aligned message building 2026-01-22 19:54:05 -05:00
Timothy Jaeryang Baek 46cf40ec82 refac 2026-01-23 04:31:39 +04:00
Timothy Jaeryang Baek 655420fd25 feat: ENABLE_OAUTH_TOKEN_EXCHANGE 2026-01-23 04:25:39 +04:00
Timothy Jaeryang Baek 52c73390f8 refac 2026-01-23 01:44:48 +04:00
Timothy Jaeryang Baek c46ef3b63b enh: builtin tools model editor 2026-01-23 01:40:18 +04:00
G30 443908b14c fix: enforce Allow Chat Valves permission in integrations menu (#20691)
- The "Allow Chat Valves" permission was only hiding valves in the Chat Controls sidebar, but users could still bypass this by accessing valves through the integrations menu in the chat input field. This fix adds the permission check to hide Valves buttons for both tools and filters/functions in the integrations menu.
2026-01-22 16:25:00 -05:00
Timothy Jaeryang Baek 3bec320bb9 refac 2026-01-23 01:23:34 +04:00
Mikhail GubenkoandCopilot fa6f238777 docs: update pr template with critical checks and improve guidelines (#20872)
* docs: update pull request template with critical checks and improved guidelines

* Update .github/pull_request_template.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* docs: solidify pr checklist definitions and statements

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 16:20:18 -05:00
Tim Baek 86e6b2b68b refac: audit 2026-01-22 16:18:16 -05:00
Timothy Jaeryang Baek 14e51e0977 refac 2026-01-23 01:02:41 +04:00
Timothy Jaeryang Baek 4c6f100b5f refac 2026-01-23 00:56:50 +04:00
Classic298 5a0488bb18 init (#20881) 2026-01-22 20:30:07 +04:00
Timothy Jaeryang Baek 9af40624c5 refac 2026-01-22 18:58:00 +04:00
Timothy Jaeryang Baek 0df561c33c refac 2026-01-22 18:40:54 +04:00
Timothy Jaeryang Baek c7f996d593 refac: AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL for mcp connections 2026-01-22 18:30:16 +04:00
Timothy Jaeryang Baek 907dba4517 feat: easter eggs flag 2026-01-22 18:27:42 +04:00
G30 9e5d6069fe feat: Sort Tools and Functions dropdowns alphabetically (#20871)
Alphabetically sort Tools and Functions selection dropdowns in Chat Controls sidebar for easier navigation and better user experience.
2026-01-22 07:07:07 -05:00
Timothy Jaeryang Baek 14f6747dfc refac 2026-01-22 15:07:14 +04:00
Timothy Jaeryang Baek 68b2872ed6 fix/refac: file batch process issue 2026-01-22 15:03:31 +04:00
Timothy Jaeryang Baek 1a4bdd2b30 refac 2026-01-22 14:59:15 +04:00
Timothy Jaeryang Baek 886c12c566 refac 2026-01-22 14:54:00 +04:00
Timothy Jaeryang Baek a3600e8b21 refac 2026-01-22 04:12:57 +04:00
Timothy Jaeryang Baek 5d48e48e15 fix: ENABLE_PERSISTENT_CONFIG redis issue 2026-01-22 03:59:00 +04:00
Timothy Jaeryang Baek 474427c67e enh: dynamic select options valve 2026-01-22 03:55:07 +04:00
Classic298 00b3583dc2 fix: fix reindex not working due to unnecessary dupe check (#20857)
* Update retrieval.py

* Update knowledge.py

* Update retrieval.py

* Update knowledge.py
2026-01-21 18:36:08 -05:00
Timothy Jaeryang Baek 4d9a7cc6c0 refac: fr-FR priority 2026-01-22 03:25:23 +04:00
Timothy Jaeryang Baek 509bd2bebb refac/fix: default oauth2.1 mcp tool 2026-01-22 03:20:59 +04:00
Timothy Jaeryang Baek 8c70453b2e enh: password valve type 2026-01-22 03:18:38 +04:00
Timothy Jaeryang Baek 8eebc2aea6 fix: mcp get_discovery_urls 2026-01-22 03:11:33 +04:00
Timothy Jaeryang Baek a9a0ce6bea refac 2026-01-22 03:09:04 +04:00
Timothy Jaeryang Baek ecbdef732b enh: PDF_LOADER_MODE 2026-01-21 23:51:36 +04:00
Timothy Jaeryang Baek 4615e8f92b refac 2026-01-20 22:28:10 +04:00
Timothy Jaeryang Baek 91faa9fd5a refac 2026-01-20 16:42:20 +04:00
Timothy Jaeryang Baek 85e92fe3b0 refac 2026-01-20 16:41:46 +04:00
Classic298 38bf0b6eec feat: Add new ENV VAR for custom error message on error on signup / password change due to password not meeting requirements (#20650)
* add env var for custom auth pw message

* Update auth.py

* Update auth.py
2026-01-19 14:00:48 +04:00
G30 6ae3ddd66b fix: truncate long model names and IDs in UI (#20696)
- Added line-clamp-1 truncation and tooltips to long model names and IDs in the Evaluations and Models admin sections to prevent visual overflow. Model names now display truncated with full name and ID available on hover.
2026-01-19 13:59:44 +04:00
G30 98cb2d3411 feat: Add MATLAB syntax highlighting support for code blocks (#20773)
* feat: Add MATLAB syntax highlighting support for code blocks

Add MATLAB syntax highlighting support by fixing issues in both CodeEditor (editable) and CodeBlock (read-only) components.

Changes:
- CodeEditor.svelte: Added 'matlab' alias to CodeMirror's Octave language (MATLAB-compatible syntax)
- CodeBlock.svelte: Fixed highlight.js usage to use highlight() directly when language is recognized, falling back to highlightAuto() only for unknown languages

* revert: not needed
2026-01-19 13:58:48 +04:00
Timothy Jaeryang Baek be75bc506a refac 2026-01-19 13:49:12 +04:00
7a42efec53 i18n: Update translation.json (ja-JP) (#20772)
* i18n: Update Japanese translation

* i18n: Update Japanese translation

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-19 13:42:58 +04:00
G30 e9926694c3 fix: add username search support to workspace and admin pages (#20780)
This fix restores and extends the username/email search functionality across workspace pages that was originally added in PR #14002. The issue was that:

1. The backend search functions for Models and Knowledge only searched `User.name` and `User.email`, but not `User.username`

2. The Functions admin page lacked user search entirely

Changes made:

Added User.username to backend search conditions for Models and Knowledge pages
Added complete user search (name, email, username) to the Functions admin page client-side filter
2026-01-19 13:42:33 +04:00
Timothy Jaeryang Baek 5cfb7a08cb refac 2026-01-17 21:52:12 +04:00
9d642f6354 Added support for connecting to self hosted weaviate deployments using connect_to_custom replacing connect_to_local, which is better suited for cases where HTTP and GRPC are hosted on different ingresses. (#20620)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: rohithshenoyg@gmail.com <rohithshenoyg@gmail.com>
2026-01-17 21:48:52 +04:00
Classic298 716f2986b9 dep bump (#20735) 2026-01-17 21:44:32 +04:00
Timothy Jaeryang Baek 409f565f09 refac 2026-01-17 21:41:48 +04:00
Classic298 26e95f2a92 fix-csv-export (#20688) 2026-01-17 21:39:24 +04:00
Kylapaallikko 711a2cd738 Update translation.json (#20741)
Added missing translations and minor changes to wording of the translations.
2026-01-17 21:37:14 +04:00
Classic298 1c1f72f05c Update builtin.py (#20705) 2026-01-16 00:15:02 +04:00
1d343aeae4 enh: Make builtin search web tools asynchronous (#20630)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-15 10:46:00 +04:00
Kailey Wong e26f6acc3b fix: use proper X-Api-Key header format when docling api key provided (#20652) 2026-01-15 10:44:35 +04:00
G30 1555252c4a fix: handle undefined model in createMessagePair function (#20663)
- Add a null check when looking up model to prevent JavaScript error when trying to add a message pair in a chat with an invalid/corrupt model ID.
2026-01-15 10:43:25 +04:00
Timothy Jaeryang Baek de0cbb9073 refac 2026-01-12 21:56:02 +04:00
Timothy Jaeryang Baek 5a075a2c83 fix: members only groups 2026-01-12 21:53:41 +04:00
Timothy Jaeryang Baek 7da37b4f66 refac 2026-01-12 21:41:23 +04:00
6f80cb6b65 i18n: Update Japanese translation (#20605)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-12 20:34:32 +04:00
Shirasawa 9617df04ae I18n: improve Chinese translation (#20613)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-01-12 20:34:13 +04:00
joaoback 01d5f42755 Update translation.json (pt-BR) (#20599)
translation of the new items recently added
2026-01-12 14:04:09 +04:00
Classic298 84d76cccde Update translation.json (#20593) 2026-01-12 00:41:28 +04:00
Classic298 af584b46f4 feat: code-interpreter native (#20592)
* code-interpreter native

* Update tools.py

* Update builtin.py

* Update builtin.py

* Update tools.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py

* Update builtin.py
2026-01-12 00:18:41 +04:00
_00_ 0fb4cceec1 UPD: i18n: Spanish es-ES Translation v0.7.0 (#20564)
### UPD: i18n: Spanish es-ES Translation v0.7.0

- Added new strings
2026-01-11 23:39:52 +04:00
Classic298 1dc353433a fix(db): release connection before embedding in memory /query (#20579)
Remove Depends(get_session) from POST /query endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

The Memories.get_memories_by_user_id() function manages its own short-lived session internally, releasing the connection before the slow EMBEDDING_FUNCTION() call begins.
2026-01-11 23:37:47 +04:00
Classic298 33e8a09880 fix(db): release connection before embedding in knowledge /create (#20575)
Remove Depends(get_session) from POST /create endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

The has_permission() and Knowledges.insert_new_knowledge() functions manage their own short-lived sessions internally, releasing connections before the slow embed_knowledge_base_metadata() call begins.
2026-01-11 23:37:05 +04:00
Classic298 1cb751d184 fix(db): release connection before embedding in knowledge /{id}/update (#20574)
Remove Depends(get_session) from POST /{id}/update endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

All database operations (get_knowledge_by_id, has_access, has_permission, update_knowledge_by_id, get_file_metadatas_by_id) manage their own short-lived sessions internally, releasing connections before and after the slow embed_knowledge_base_metadata() call.
2026-01-11 23:36:36 +04:00
Classic298 9e596f8616 fix(db): release connection before LLM call in Ollama /v1/completions (#20570)
Remove Depends(get_session) from the /v1/completions endpoint to prevent database connections from being held during the entire duration of LLM calls.

Previously, the database session was acquired at request start and held until the response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after authorization checks complete.
2026-01-11 23:35:46 +04:00
Classic298 24044b42ea fix(db): release connection before LLM call in Ollama /v1/chat/completions (#20569)
Remove Depends(get_session) from the /v1/chat/completions endpoint to prevent database connections from being held during the entire duration of LLM calls.

Previously, the database session was acquired at request start and held until the streaming response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after authorization checks complete.
2026-01-11 23:35:38 +04:00
Aleix Dorca 84263fc6a6 i18n: Updated the Catalan translation file (#20566)
* Update translation.json

* Update catalan translation.json
2026-01-11 23:35:11 +04:00
2faab409d3 i18n(pl-PL): Add missing keys and update existing translations (#20562)
* Added lacking translations to Polish locale and improved some existing ones for better clarity.

* fix: add missing newline at EOF

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-11 23:35:01 +04:00
Classic298 0b5aa6dd60 fix(db): release connection before LLM call in Ollama /api/chat (#20571)
Remove Depends(get_session) from the /api/chat endpoint to prevent database connections from being held during the entire duration of LLM calls (30-60+ seconds for streaming responses).

Previously, the database session was acquired at request start and held until the streaming response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors for other database operations.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after the quick authorization checks complete - before the slow external LLM API call begins.
2026-01-11 23:34:23 +04:00
Classic298 d0c2bfdbff fix(db): release connection before LLM call in OpenAI /chat/completions (#20572)
Remove Depends(get_session) from the /chat/completions endpoint to prevent database connections from being held during the entire duration of LLM calls (30-60+ seconds for streaming responses).

Previously, the database session was acquired at request start and held until the streaming response completed. Under concurrent load, this exhausted the connection pool, causing QueuePool timeout errors for other database operations.

The fix allows Models.get_model_by_id() and has_access() to manage their own short-lived sessions internally, releasing the connection immediately after the quick authorization checks complete - before the slow external LLM API call begins.
2026-01-11 23:34:11 +04:00
Classic298 242625782f fix(db): release connection before embedding in memory /add (#20578)
Remove Depends(get_session) from POST /add endpoint to prevent database connections from being held during embedding API calls (1-5+ seconds).

The Memories.insert_new_memory() function manages its own short-lived session internally, releasing the connection before the slow EMBEDDING_FUNCTION() call begins.
2026-01-11 23:33:17 +04:00
Classic298 826e9ab317 fix(db): release connection before embeddings in knowledge /metadata/reindex (#20577)
Remove Depends(get_session) from POST /metadata/reindex endpoint to prevent database connections from being held during N embedding API calls.

This endpoint is CRITICAL as it loops through ALL knowledge bases and calls embed_knowledge_base_metadata() for each one. With the original code, a single connection would be held for the entire duration (potentially minutes for large deployments), completely exhausting the pool.

The Knowledges.get_knowledge_bases() function manages its own short-lived session, releasing the connection before the embedding loop begins.
2026-01-11 23:33:04 +04:00
Classic298 182d5e8591 fix(db): release connection before embedding in process_files_batch (#20576)
Remove Depends(get_session) from POST /process/files/batch endpoint to prevent database connections from being held during batch embedding API calls (5-60+ seconds for large batches).

The save_docs_to_vector_db() function makes external embedding API calls. Post-embedding file updates (Files.update_file_by_id) manage their own short-lived sessions internally, releasing connections promptly.
2026-01-11 23:32:56 +04:00
Classic298 3fc866117d fix(db): CRITICAL - prevent pool exhaustion in memory /reset (#20580)
Remove Depends(get_session) from POST /reset to prevent catastrophic connection pool exhaustion.

This endpoint was holding a SINGLE database connection while executing N PARALLEL embedding API calls via asyncio.gather(). For a user with 100 memories, this meant one connection blocked for potentially MINUTES (100 calls * 1-5 seconds each, even in parallel due to rate limits).

A single user triggering /reset could completely starve the connection pool, causing QueuePool timeout errors across the entire application.

The Memories.get_memories_by_user_id() function now manages its own short-lived session, releasing the connection immediately before the massive parallel embedding operation begins.
2026-01-11 23:32:40 +04:00
Classic298 b464b48f53 Merge pull request #20581 from Classic298/fix/db-pool-memory-update
fix(db): release connection before embedding in memory /{memory_id}/update
2026-01-11 23:32:27 +04:00
Tim Baek 2b26355002 Merge pull request #20560 from open-webui/dev
0.7.2
2026-01-11 01:00:01 +04:00
Timothy Jaeryang Baek d81a36310c chore: bump 2026-01-11 00:58:57 +04:00
Timothy Jaeryang Baek d56bb2c383 refac 2026-01-11 00:52:43 +04:00
Timothy Jaeryang Baek 2dd09223f2 refac/fix: duplicated api calls for evaluation 2026-01-10 23:58:40 +04:00
Timothy Jaeryang Baek 0c369d195b chore: format 2026-01-10 23:50:18 +04:00
Timothy Jaeryang Baek ab99d3b112 fix: prompt creation issue 2026-01-10 23:40:07 +04:00
Classic298 3f133fad56 fix: release database connections immediately after auth instead of holding during LLM calls (#20545)
fix: release database connections immediately after auth instead of holding during LLM calls

Authentication was using Depends(get_session) which holds a database connection
for the entire request lifecycle. For chat completions, this meant connections
were held for 30-60 seconds while waiting for LLM responses, despite only needing
the connection for ~50ms of actual database work.

With a default pool of 15 connections, this limited concurrent chat users to ~15
before pool exhaustion and timeout errors:

    sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
    connection timed out, timeout 30.00

The fix removes Depends(get_session) from get_current_user. Each database
operation now manages its own short-lived session internally:

    BEFORE: One session held for entire request
    ──────────────────────────────────────────────────
    │ auth │ queries │ LLM wait (30s) │ save │
    │         CONNECTION HELD ENTIRE TIME            │
    ──────────────────────────────────────────────────

    AFTER: Short-lived sessions, released immediately
    ┌──────┐ ┌───────┐                 ┌──────┐
    │ auth │ │ query │   LLM (30s)     │ save │
    │ 10ms │ │ 20ms  │  NO CONNECTION  │ 20ms │
    └──────┘ └───────┘                 └──────┘

This is safe because:
- User model has no lazy-loaded relationships (all simple columns)
- Pydantic conversion (UserModel.model_validate) happens while session is open
- Returned object is pure Pydantic with no SQLAlchemy ties

Combined with the telemetry efficiency fix, this resolves connection pool
exhaustion for high-concurrency deployments, particularly on network-attached
databases like AWS Aurora where connection hold time is more impactful.
2026-01-10 15:34:36 +04:00
Classic298 41d1ccd39c Update channels.py (#20546) 2026-01-10 15:34:12 +04:00
Classic298 7839d043ff fix: use efficient COUNT queries in telemetry metrics to prevent connection pool exhaustion (#20542)
fix: use efficient COUNT queries in telemetry metrics to prevent connection pool exhaustion

This fixes database connection pool exhaustion issues reported after v0.7.0,
particularly affecting PostgreSQL deployments on high-latency networks (e.g., AWS Aurora).

## The Problem

The telemetry metrics callbacks (running every 10 seconds via OpenTelemetry's
PeriodicExportingMetricReader) were using inefficient queries that loaded entire
database tables into memory just to count records:

    len(Users.get_users()["users"])  # Loads ALL user records to count them

On high-latency network-attached databases like AWS Aurora, this would:
1. Hold database connections for hundreds of milliseconds while transferring data
2. Deserialize all records into Python objects
3. Only then count the list length

Under concurrent load, these long-held connections would stack up and drain the
connection pool, resulting in:

    sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
    connection timed out, timeout 30.00

## The Fix

Replace inefficient full-table loads with efficient COUNT(*) queries using
methods that already exist in the codebase:

- `len(Users.get_users()["users"])` → `Users.get_num_users()`
- Similar changes for other telemetry callbacks as needed

COUNT(*) queries use database indexes and return a single integer, completing in
~5-10ms even on Aurora, versus potentially 500ms+ for loading all records.

## Why v0.7.1's Session Sharing Disable "Helped"

The v0.7.1 change to disable DATABASE_ENABLE_SESSION_SHARING by default appeared
to fix the issue, but it was masking the root cause. Disabling session sharing
causes connections to be returned to the pool faster (more connection churn),
which reduced the window for pool exhaustion but didn't address the underlying
inefficient queries.

With this fix, session sharing can be safely re-enabled for deployments that
benefit from it (especially PostgreSQL), as telemetry will no longer hold
connections for extended periods.

## Impact

- Telemetry connection usage drops from potentially seconds to ~30ms total per
  collection cycle
- Connection pool pressure from telemetry becomes negligible (~0.3% utilization)
- Enterprise PostgreSQL deployments (Aurora, RDS, etc.) should no longer
  experience pool exhaustion under normal load
2026-01-10 15:33:42 +04:00
G30 9b9e6ce2ab fix: correct empty STT_ENGINE handling and improve TTS error response (#20534)
- Remove incorrect 403 check that blocked STT when ENGINE="" (local whisper)
- Change TTS empty ENGINE check from 403 to 404 for proper semantics
2026-01-10 15:32:22 +04:00
Classic298 81510e9d8f fix(files): prevent connection pool exhaustion in file status streaming (#20547)
Refactored the file processing status streaming endpoint to avoid holding
a database connection for the entire stream duration (up to 2 hours).
Changes:
- Each status poll now creates its own short-lived database session instead
  of capturing the request's session in the generator closure
- Increased poll interval from 0.5s to 1s, halving database queries with
  negligible UX impact
This prevents a single file status stream from blocking a connection pool
slot for hours, which could contribute to pool exhaustion under load.
2026-01-10 15:23:48 +04:00
EntropyYue c0ff925c2a fix: Settings tab i18n (#20526) 2026-01-10 12:41:06 +04:00
joaoback 2da661fed1 Update translation.json (pt-BR) (#20527)
Translations of the new items added in the latest versions.
2026-01-10 12:37:59 +04:00
G30 59b128bbda Update ChannelItem.svelte (#20535) 2026-01-10 12:37:42 +04:00
Tim Baek f2a360cb87 Merge pull request #20523 from open-webui/dev
chore
2026-01-10 00:57:39 +04:00
Tim Baek 8deef788c1 chore 2026-01-09 15:57:06 -05:00
Tim Baek f9b0534e0c Merge pull request #20522 from open-webui/dev
0.7.1
2026-01-10 00:44:36 +04:00
Timothy Jaeryang Baek c4de5ea50c chore: bump 2026-01-10 00:40:37 +04:00
Timothy Jaeryang Baek 8646aebaab refac/fix: DATABASE_ENABLE_SESSION_SHARING env var 2026-01-10 00:16:04 +04:00
Tim Baek 6adde203cd Merge pull request #20394 from open-webui/dev
0.7
2026-01-09 22:51:34 +04:00
Timothy Jaeryang Baek 5990c51ab5 chore: format 2026-01-09 22:27:53 +04:00
Timothy Jaeryang Baek 3c986adeda enh: kb metadata search 2026-01-09 22:21:00 +04:00
Timothy Jaeryang Baek eff772562b chore: format 2026-01-09 21:19:27 +04:00
Timothy Jaeryang Baek 7bb11f9ef0 refac: wording 2026-01-09 21:05:37 +04:00
Timothy Jaeryang Baek 592a6213b9 doc: changelog 2026-01-09 21:00:30 +04:00
Timothy Jaeryang Baek db02d802a7 refac 2026-01-09 20:48:16 +04:00
Timothy Jaeryang Baek 7a7a0c423b chore: format 2026-01-09 20:44:31 +04:00
Timothy Jaeryang Baek 74c4af6e11 refac 2026-01-09 20:25:51 +04:00
Timothy Jaeryang Baek 9496e8f7b5 feat: model evaluation activity chart 2026-01-09 20:19:51 +04:00
Timothy Jaeryang Baek 636ba171a4 chore: format 2026-01-09 19:46:32 +04:00
Classic298 f4d99135e4 chore: CHANGELOG (#20261)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2026-01-09 19:44:32 +04:00
Timothy Jaeryang Baek a7b4b6e51a enh: WHISPER_MULTILINGUAL 2026-01-09 19:42:13 +04:00
Classic298 83dce80e30 Update translation.json (#20513) 2026-01-09 19:26:48 +04:00
Timothy Jaeryang Baek 869108a3e1 refac: tts split 2026-01-09 19:08:35 +04:00
Timothy Jaeryang Baek bb6188abf0 feat: per model tts voice 2026-01-09 19:05:09 +04:00
Timothy Jaeryang Baek 401c1949a0 refac 2026-01-09 18:51:38 +04:00
Timothy Jaeryang Baek 10838b3654 refac/fix: feedback leaderboard 2026-01-09 18:24:09 +04:00
Timothy Jaeryang Baek 3a57233dd4 chore: aiohttp 2026-01-09 18:10:27 +04:00
Tim Baek daccf0713e enh: file context model setting 2026-01-09 03:41:43 -05:00
Timothy Jaeryang Baek 1138929f4d feat: headless admin creation 2026-01-09 12:01:36 +04:00
Timothy Jaeryang Baek 9a9a824fe9 refac 2026-01-09 03:31:27 +04:00
Timothy Jaeryang Baek b2a1f71d92 refac: get feedback ids 2026-01-09 03:06:24 +04:00
Timothy Jaeryang Baek ffbd6ec7f2 refac 2026-01-09 03:03:25 +04:00
joaobackandTim Baek 15934d208e Update translation.json (pt-BR) (#20493)
Translations of the newly added items

Co-authored-by: Tim Baek <tim@openwebui.com>
2026-01-09 02:47:30 +04:00
Timothy Jaeryang Baek b377e5ff4c chore: format 2026-01-09 02:46:04 +04:00
Timothy Jaeryang Baek 924af3d908 chore: format 2026-01-09 02:45:50 +04:00
Timothy Jaeryang Baek 9e9616b670 fix: input quick note 2026-01-09 02:44:08 +04:00
Timothy Jaeryang Baek 00837d8f99 refac 2026-01-09 02:30:36 +04:00
Timothy Jaeryang Baek cd296fcf0d feat: channel webhooks 2026-01-09 02:30:15 +04:00
Timothy Jaeryang Baek 48bdb3f266 refac 2026-01-09 01:58:31 +04:00
Timothy Jaeryang Baek ef9cd0e0ad refac 2026-01-09 01:47:51 +04:00
Timothy Jaeryang Baek 8e7ebdbf63 refac 2026-01-09 01:25:40 +04:00
Classic298 9451b13dc6 feat: add ENABLE_USER_STATUS toggle for admin-controlled user status visibility (#20488)
* feat: add ENABLE_USER_STATUS toggle for admin-controlled user status visibility

feat: add ENABLE_USER_STATUS toggle for admin-controlled user status visibility

Add a new admin panel toggle (Admin > Settings > General) called "User Status" that allows administrators to globally enable or disable user status functionality.

When disabled:
- User status API endpoints return 403 Forbidden
- Status emoji, message, and "Update your status" button are hidden from the user menu

The setting:
- Defaults to True (enabled)
- Can be overridden via ENABLE_USER_STATUS environment variable
- Persists across restarts using PersistentConfig

Files modified:
- backend/open_webui/config.py - Added ENABLE_USER_STATUS PersistentConfig
- backend/open_webui/main.py - App state init and features dict
- backend/open_webui/routers/auths.py - AdminConfig model and endpoints
- backend/open_webui/routers/users.py - 403 guards on status endpoints
- src/lib/components/admin/Settings/General.svelte - Toggle UI
- src/lib/components/layout/Sidebar/UserMenu.svelte - Conditional status display

* Update UserMenu.svelte

feat: add ENABLE_USER_STATUS toggle for admin-controlled user status visibility

Add a new admin panel toggle (Admin > Settings > General) called "User Status" that allows administrators to globally enable or disable user status functionality.

When disabled:
- User status API endpoints return 403 Forbidden
- Active/Away indicator with blinking dot is hidden from the user menu
- Status emoji, message, and "Update your status" button are hidden from the user menu

The setting:
- Defaults to True (enabled)
- Can be overridden via ENABLE_USER_STATUS environment variable
- Persists across restarts using PersistentConfig

Files modified:
- backend/open_webui/config.py - Added ENABLE_USER_STATUS PersistentConfig
- backend/open_webui/main.py - App state init and features dict
- backend/open_webui/routers/auths.py - AdminConfig model and endpoints
- backend/open_webui/routers/users.py - 403 guards on status endpoints
- src/lib/components/admin/Settings/General.svelte - Toggle UI
- src/lib/components/layout/Sidebar/UserMenu.svelte - Conditional status display

* nuke the indicator

* fix
2026-01-09 00:55:57 +04:00
Timothy Jaeryang Baek 9223efaff0 fix: native function calling system prompt duplication 2026-01-08 23:08:47 +04:00
Shirasawa 7aee9c4783 feat: improve Chinese translation (#20487)
* feat: improve zh-CN translation

* i18n: improve zh-TW translation
2026-01-08 22:59:06 +04:00
Timothy Jaeryang Baek c1147578c0 feat: export kb to zip 2026-01-08 12:49:45 +04:00
Timothy Jaeryang Baek 9b06fdc8fe refac 2026-01-08 03:37:11 +04:00
Timothy Jaeryang BaekandClassic298 a9a979fb3d refac: files search
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-01-08 03:08:11 +04:00
G30 4b4743b497 feat: enforce permissions in backend (#20471)
* feat: enforce image generation permissions in backend

* feat: enforce web search permissions in backend

* feat: enforce audio (tts/stt) permissions in backend
2026-01-08 02:48:35 +04:00
Timothy Jaeryang Baek e7207c8f55 refac: styling 2026-01-08 02:43:19 +04:00
Classic298 ae56bca7c5 Update translation.json (#20472) 2026-01-08 02:29:18 +04:00
Timothy Jaeryang Baek 700349064d chore: format 2026-01-08 01:55:56 +04:00
Timothy Jaeryang Baek c417fdd94d refac 2026-01-08 01:38:40 +04:00
Timothy Jaeryang Baek b6cef30bfc refac 2026-01-08 01:24:31 +04:00
Timothy Jaeryang Baek aa084f804d refac 2026-01-08 00:53:21 +04:00
Timothy Jaeryang Baek e67891a374 refac 2026-01-08 00:42:29 +04:00
G30 b73d30b6df feat: implement global memories toggle and permissions (#20462) 2026-01-07 23:50:04 +04:00
Classic298 48f1b2d547 fix(channels): eliminate N+1 query in get_pinned_messages endpoint (#20459)
Replaced per-message user lookup with batch fetch using SQL IN clause.

Changes:
- Fetch all message user_ids in a single pass
- Use Users.get_users_by_user_ids() for batch lookup
- Build user mapping to avoid DB calls in loop
- Add early return for empty message lists

Performance: Reduces N+1 queries to 2 queries (messages + users)
2026-01-07 23:36:38 +04:00
Classic298 b8fb9e815b fix(channels): eliminate N+1 query in get_channel_messages endpoint (#20458)
Replaced per-message user lookup with batch fetch using SQL IN clause.

Changes:
- Fetch all message user_ids in a single pass
- Use Users.get_users_by_user_ids() for batch lookup
- Build user mapping to avoid DB calls in loop
- Add early return for empty message lists

Performance: Reduces N+1 queries to 2 queries (messages + users)
2026-01-07 23:36:12 +04:00
Classic298 74974334a5 fix: More n+1 channel perf fixes (#20460) 2026-01-07 23:34:19 +04:00
Timothy Jaeryang BaekandClassic298 084f0ef6a5 fix(notes): eliminate N+1 query in get_notes endpoint
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-01-07 23:32:56 +04:00
G30 0e1edc4c01 feat: add search bar to admin settings sidebar (#20434) 2026-01-07 23:27:25 +04:00
Tim Baek 0654df7bdb refac 2026-01-07 10:25:13 -05:00
Tim Baek 35d385e9cc refac 2026-01-07 10:21:05 -05:00
Tim BaekandJannik S. ab400e3eae enh: native tool citations
Co-Authored-By: Jannik S. <jannik@streidl.dev>
2026-01-07 10:14:45 -05:00
Tim Baek 961136413f refac 2026-01-07 09:46:07 -05:00
Tim Baek c8622adcb0 feat: builtin kb tools 2026-01-07 08:58:58 -05:00
Tim Baek 2789f6a24d enh: builtin tools 2026-01-07 07:00:32 -05:00
Tim Baek 60e916d6c0 enh: built-in tools toggle in model editor 2026-01-07 06:22:17 -05:00
Classic298 502813c6c8 chore: aggregation (#20430)
* Update EditGroupModal.svelte

* Update Permissions.svelte

* Create permissions.ts
2026-01-06 23:01:10 +04:00
Classic298 732d9b484d fix: resolve N+1 query pattern in users endpoint (#20427)
## Summary

Fixed N+1 query pattern in the `/api/v1/users` endpoint where groups were being fetched for each user individually.

### Problem

The `GET /api/v1/users` endpoint called `Groups.get_groups_by_member_id()` for each user, resulting in:
- 1 query for users
- N queries for groups (one per user)

### Solution

Added a new `Groups.get_groups_by_member_ids()` method that fetches groups for multiple users in a single query using SQL `IN` clause and `JOIN`.

### Changes

- **[groups.py](open_webui/models/groups.py)**: Added `get_groups_by_member_ids()` method
- **[users.py](open_webui/routers/users.py)**: Updated endpoint to use bulk method

### Result

- Before: 1 + N queries
- After: 2 queries total (1 for users, 1 for all groups)
2026-01-06 21:26:14 +04:00
72698a0465 i18n: Comprehensive improvements to Polish (pl-PL) translation (#20425)
* Updated pl-PL lang

* Updated pl-PL

* polish translation reverted

* updated polish translation

* updated polish translation

* Updated translation

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-01-06 21:20:06 +04:00
Classic298 cc046695e6 feat: add interface user permission (#20424)
* feat: add interface user permission

Adds admin configurable user / group permissions for the interface settings section

* rename
2026-01-06 20:23:46 +04:00
Timothy Jaeryang Baek 17490381f5 refac 2026-01-06 19:11:56 +04:00
Jannik S. 87ebbbe2f4 fix: explicit casting client errors with some datatypes in websearch settings (#20422) 2026-01-06 18:45:11 +04:00
Timothy Jaeryang Baek 1148d1c927 fix/refac: images 2026-01-06 03:25:39 +04:00
Timothy Jaeryang Baek 927a765641 refac 2026-01-06 03:24:08 +04:00
Timothy Jaeryang Baek 1cfbb823ec chore: lock sync 2026-01-06 03:04:09 +04:00
Classic298 c87031e9a6 feat: Tools Atomic PR of #20243 (#20370)
* feat: Add read-only access support for Tools

- Backend: Add write_access field to ToolAccessResponse
- Backend: Update /tools/list to return tools with write_access
- Frontend: Display Read Only badge in Tools list
- Frontend: Disable inputs and save button when no write access
- Frontend: Add readOnly prop to CodeEditor component

* Update Tools.svelte

* fix: Return write_access from getToolById endpoint

fix: Return write_access from getToolById endpoint

- Use ToolAccessResponse instead of raw dict
- Remove inefficient getToolList call in edit page

* refactor: Rename write_access to disabled in ToolkitEditor

- Rename prop from write_access to disabled
- Invert logic where needed
- Update edit page to pass disabled instead of write_access

* rem

* Update +page.svelte

* fix

* Update ToolkitEditor.svelte

* Update CodeEditor.svelte

* Update ToolkitEditor.svelte
2026-01-06 03:00:48 +04:00
Timothy Jaeryang Baek 5921a19519 refac 2026-01-06 02:19:57 +04:00
Timothy Jaeryang Baek 2543252bb0 refac 2026-01-06 02:00:30 +04:00
Timothy Jaeryang Baek 619f260466 refac 2026-01-06 01:53:00 +04:00
Timothy Jaeryang Baek 119fc21257 refac 2026-01-06 01:42:49 +04:00
Timothy Jaeryang Baek 40c45ffe1f feat: text fragment url in citations 2026-01-06 00:49:32 +04:00
Timothy Jaeryang Baek 4cf901f519 refac 2026-01-06 00:05:52 +04:00
Timothy Jaeryang Baek de9cff6b4e refac 2026-01-06 00:01:07 +04:00
Classic298 713a65ee31 fix: inject full context knowledge into system message for KV prefix caching (#20317)
* Update middleware.py

* Update middleware.py

* env var

* address

* upd
2026-01-05 23:58:53 +04:00
Classic298 cd5a38a694 feat: Models Atomic PR of #20243 (#20369)
* feat: Add read-only access support for Models

- Backend: Add write_access field to ModelAccessResponse
- Backend: Update /models/list to return ModelAccessListResponse
- Frontend: Display Read Only badge in Models list
- Frontend: Disable inputs and save button when no write access
- Frontend: Hide action buttons for read-only models

* fix: Handle ModelAccessListResponse format in getModels API

- Backend returns {items, total} instead of {data}
- Update getModels API to handle both formats for backward compatibility

* fix: Show read-only shared models in workspace list

- Backend: Change search_models permission from 'write' to 'read' to include shared models
- Backend: Keep user_id filter to only show owned/shared models (not all public)
- Frontend: Handle ModelAccessListResponse format in getModels API

* fix: Align Read Only badge inline with model name

* fix: Correct badge placement and fix syntax error

* fix: Resolve badge truncation in Models list

- Add w-full to flex container for proper spacing
- Wrap Badge in div to prevent truncation
- Match Knowledge.svelte badge pattern

* fix: Align Read Only badge with Knowledge.svelte pattern

- Match Knowledge.svelte structure for badge placement
- Actions only show when write_access or admin
- Remove w-full from container to prevent right-overflow

* fix: Return write_access from getModelById endpoint

fix: Return write_access from getModelById endpoint

- Use ModelAccessResponse instead of raw dict
- Remove inefficient getModels call in edit page

* revert

* fix

* fix

* fix
2026-01-05 23:37:41 +04:00
Timothy Jaeryang Baek 8ef0f7743b refac 2026-01-05 23:13:05 +04:00
Timothy Jaeryang Baek 8d568cc8c5 refac 2026-01-05 22:41:13 +04:00
Classic298 f5455d48c4 Chore: dup bump for NPM and PIP (#20386)
* Update pyproject.toml

* Update aiohttp version to 3.13.3

* Update aiohttp version to 3.13.3

* Update pyproject.toml

* Update requirements.txt

* Update package.json
2026-01-05 22:34:25 +04:00
Timothy Jaeryang Baek ab7b97e21f refac 2026-01-05 22:12:49 +04:00
Timothy Jaeryang Baek b1d30673b6 refac 2026-01-05 21:54:33 +04:00
Timothy Jaeryang Baek bb821ab654 fix: revert model profile image cache 2026-01-05 21:49:54 +04:00
460ab07f39 chore: y-protocols dep
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-01-05 19:26:27 +04:00
Timothy Jaeryang Baek 1d08376860 refac 2026-01-05 18:55:44 +04:00
Timothy Jaeryang Baek d3ab9f4b96 fix: failed hash in files 2026-01-05 18:21:00 +04:00
G30 9c5773d0af feat: add chat:message:favorite event for action functions (#20375)
* feat: add chat:message:favorite event for action functions

* Update Chat.svelte

* Update Chat.svelte
2026-01-05 18:15:00 +04:00
Timothy Jaeryang Baek 00af37bb4e enh: model gif/webp support 2026-01-05 17:56:41 +04:00
Timothy Jaeryang Baek a3270648d8 refac 2026-01-05 17:50:40 +04:00
Timothy Jaeryang Baek 646835d767 feat: builtin native tools 2026-01-05 17:45:39 +04:00
Timothy Jaeryang Baek e27fb3e291 refac 2026-01-05 17:44:44 +04:00
Timothy Jaeryang Baek 3f577c0c3f refac: notes organization issue 2026-01-05 17:34:29 +04:00
Timothy Jaeryang Baek ca514cd3ed refac: group share to settings 2026-01-05 05:32:56 +04:00
Classic298 6b5643c786 feat: Add read-only access support for Knowledge Bases (#20371)
- Backend: Add BYPASS_ADMIN_ACCESS_CONTROL check to write_access calculation
- Frontend: Knowledge already has Read Only badge and disabled inputs
2026-01-05 04:45:48 +04:00
Timothy Jaeryang Baek 5c1d52231a feat: native function calling for built-in tools 2026-01-05 04:45:17 +04:00
Classic298 1f059fe730 feat: Prompts Atomic PR of #20243 (#20368)
* feat: Add read-only access support for Prompts

- Backend: Add write_access field to PromptAccessResponse
- Backend: Update /prompts/list to return prompts with write_access
- Frontend: Display Read Only badge in Prompts list
- Frontend: Disable inputs and save button when no write access

* feat: Add read-only visual indicators for Prompts workspace

* fix: Return write_access from getPromptByCommand endpoint

- Backend returns write_access directly in response
- Frontend extracts write_access from getPromptByCommand response
- Remove inefficient getPromptList call in edit page

* fix: Align Read Only badge to right in Prompts.svelte

- Title and command stay on left
- Badge pushed to right by justify-between

* fix: Use PromptAccessResponse in get_prompt_by_command endpoint

fix: Use PromptAccessResponse in get_prompt_by_command endpoint

- Return PromptAccessResponse Pydantic model instead of raw dict
- Properly type the response with response_model
2026-01-05 04:36:13 +04:00
Timothy Jaeryang Baek b55a46ae99 refac 2026-01-05 03:46:46 +04:00
Timothy Jaeryang Baek 2bb13d5dbc refac: get_db_context 2026-01-05 03:46:40 +04:00
Classic298 614cb56420 feat: Add configurable DDGS backend selection with UI support (#20366)
* init

* Update WebSearch.svelte

* reorder
2026-01-05 03:05:56 +04:00
Timothy Jaeryang Baek e754940c03 refac 2026-01-05 03:05:10 +04:00
Timothy Jaeryang Baek dc2c2f2295 refac 2026-01-03 19:48:37 +04:00
Timothy Jaeryang BaekandClassic298 c324359580 feat: chunk min size target for md header splitter
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-01-03 19:47:29 +04:00
Timothy Jaeryang Baek e55bf2c2ac refac 2026-01-03 18:47:12 +04:00
Timothy Jaeryang Baek bd07ef87ab refac 2026-01-03 18:43:12 +04:00
Classic298 1c82506bfd fix: Missing field for entering the Mineru Cloud API key (#20328)
* Update Documents.svelte

* Update Documents.svelte
2026-01-03 18:31:59 +04:00
Classic298 a2f8e41fbc chore: dep bump (#20315)
* dep bump

* update
2026-01-03 18:14:25 +04:00
G30 8712446cc1 fix: resolve undefined knowledge base name in file reference tooltip (#20333) 2026-01-03 18:13:19 +04:00
G30 bf42535d12 feat: add support for passing 'steps' in image generation requests (#20337) 2026-01-03 18:11:13 +04:00
G30 cfecca8eda fix: handle undefined file.url in UserMessage to prevent TypeError (#20343)
Add optional chaining to file.url.startsWith() calls to safely handle cases where file.url is undefined, preventing 'can't access property startsWith' TypeError in user messages with file attachments.
2026-01-03 18:10:03 +04:00
Timothy Jaeryang Baek ccd3295a5d refac 2026-01-02 16:56:13 +04:00
Timothy Jaeryang Baek f0829ba6e6 refac: deprecate generate image action button 2026-01-01 16:09:29 +04:00
Timothy Jaeryang Baek e4a5b06ca6 enh: embedding_batch_size for local embedding engine 2026-01-01 16:06:42 +04:00
Timothy Jaeryang Baek d3ee3fd23e refac 2026-01-01 14:10:11 +04:00
Timothy Jaeryang Baekandjamie-dit 89565c58c6 refac/fix: oauth discovery urls
Co-Authored-By: jamie-dit <80016430+jamie-dit@users.noreply.github.com>
2026-01-01 14:01:18 +04:00
Timothy Jaeryang Baek f981843852 refac 2026-01-01 13:45:37 +04:00
Timothy Jaeryang Baek c144122f60 refac/fix: reply to message recursion issue 2026-01-01 03:10:09 +04:00
Timothy Jaeryang Baek b67796465e refac 2026-01-01 03:03:10 +04:00
Timothy Jaeryang Baek a1036e544d enh: folder_max_file_count 2026-01-01 02:51:35 +04:00
Timothy Jaeryang Baek 4eadf84e1f refac 2026-01-01 02:23:42 +04:00
Timothy Jaeryang Baek f7f8a263b9 feat: JINA_API_BASE_URL 2026-01-01 02:17:47 +04:00
Jan Kessler 6c7f966f2a properly handle async-generator Redis methods in SentinelRedisProx to fix changed YDocManager's remove_user_from_all_documents (#20145) 2025-12-31 17:15:24 -05:00
Timothy Jaeryang Baek 89ad1c68d1 enh: FIRECRAWL_TIMEOUT 2026-01-01 02:07:22 +04:00
Timothy Jaeryang Baekandlif b619a157bc refac/fix: rtl support
Co-Authored-By: lif <19658300+majiayu000@users.noreply.github.com>
2026-01-01 01:58:53 +04:00
Timothy Jaeryang Baek fdae5644e3 refac 2026-01-01 01:51:37 +04:00
Timothy Jaeryang BaekandShamray Alexander bf2b296239 fix: oauth server_metadata_url issue
Co-Authored-By: Shamray Alexander <843002+imsamurai@users.noreply.github.com>
2026-01-01 01:37:38 +04:00
Timothy Jaeryang Baek dfc5dad631 enh: REQUESTS_VERIFY 2026-01-01 01:27:07 +04:00
Classic298 431632d530 fix: normalize local CrossEncoder reranking scores for relevance threshold (#20228)
* Update utils.py

* Update retrieval.py

* Update utils.py

* Update retrieval.py

* add env var

* rename to SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION
2025-12-31 15:48:31 -05:00
G30 4f918e7aa4 fix: resolve duplicate key and null message errors in chat (#20268)
* fix: resolve duplicate key and null message errors in chat

* fix: resolve duplicate key and null message errors in chat

* chore: visitedIds -> visitedMessageIds
2025-12-31 15:44:02 -05:00
G30 8b3a4c1e2f fix: rename 'Feedbacks' to 'Feedback' in UI and URL (#20296) 2025-12-31 15:43:29 -05:00
Timothy Jaeryang Baek 71ca25c8ac refac 2025-12-31 20:23:32 +04:00
Classic298 6c203fc7df fix: prevent getChatList crash on null API response (#20269)
Add null check before calling .map() on the API response in getChatList(). When the fetch fails silently or returns null, the function now gracefully returns an empty array instead of crashing with 'Cannot read property map of null'.
2025-12-31 17:41:27 +04:00
Classic298 0bd295b10b fix: prevent getOpenAIUrls and getOpenAIKeys crash on null response (#20272)
Use optional chaining and nullish coalescing when accessing res.OPENAI_API_BASE_URLS and res.OPENAI_API_KEYS. Returns empty array instead of crashing with 'Cannot read property of null'.
2025-12-31 17:40:56 +04:00
G30 252a983091 fix: cross-chat data corruption and tiptap editor access errors (#20266)
* fix: prevent cross-chat corruption and editor access errors

* fix: safety check in Messages.svelte and cleanup in RichTextInput
2025-12-31 17:40:35 +04:00
_00_ 5a556b00c3 UPD: i18n: es-ES language v.0.6.44 (#20265)
### i18n: Update es-ES language v.0.6.44

Added new strings translation.
2025-12-31 17:39:46 +04:00
Classic298 9195d7aeb0 fix: prevent delete_chat_by_id crash when chat is None (#20270)
Add null check after Chats.get_chat_by_id() in both admin and user code paths. When chat doesn't exist, now raises HTTP 404 instead of crashing with AttributeError when accessing chat.meta.
2025-12-31 17:39:33 +04:00
Classic298 f4670b3add fix: prevent get_user_ids_from_room crash on missing session (#20271)
Add null check in list comprehension before accessing session['id']. When a session_id exists in the room but has been removed from SESSION_POOL, the function now skips it instead of crashing with TypeError.
2025-12-31 17:39:22 +04:00
Classic298 048692c068 fix: prevent insert_shared_chat_by_chat_id crash when chat is None (#20273)
Add null check after db.get(Chat, chat_id) before accessing chat.share_id. Returns None instead of crashing with AttributeError when chat doesn't exist.
2025-12-31 17:38:57 +04:00
Classic298 b91e8b73ab fix: properly raise exceptions instead of returning them in chat.py (#20276)
Change 'return Exception(...)' to 'raise Exception(...)' in chat_completed() and chat_action() functions. Returning an exception object instead of raising it causes errors to be silently swallowed, breaking error propagation.
2025-12-31 17:38:47 +04:00
Classic298 201c38a08a fix: prevent delete_entries_from_collection crash when file is None (#20274)
Add null check after Files.get_file_by_id() before accessing file.hash. Raises HTTP 404 instead of crashing with AttributeError when file doesn't exist.
2025-12-31 02:31:26 -05:00
Classic298 46f867cda6 fix: prevent save_docs_to_vector_db crash on empty result.ids (#20275)
Add check that result.ids exists and has length > 0 before accessing result.ids[0]. Prevents IndexError when query returns empty results.
2025-12-31 02:31:05 -05:00
Classic298 6d087202ad fix: prevent invalidate_token crash when decode_token returns None (#20277)
Add null check after decode_token() before calling decoded.get(). Invalid/expired tokens now gracefully exit instead of crashing with AttributeError.
2025-12-31 02:30:45 -05:00
Classic298 07cbc52c9a fix: prevent update_user_settings_by_id crash when user is None (#20278)
Get user first and check for None before accessing .settings attribute. Returns None gracefully instead of crashing with AttributeError.
2025-12-31 02:30:31 -05:00
Classic298 2c0ebbcced fix: prevent null crashes in users and notes routers (#20279)
Add null checks for ui settings in update_user_settings_by_session_user() and for user in get_notes(). Prevents AttributeError when ui dict is None or when note's user has been deleted.
2025-12-31 02:30:14 -05:00
Classic298 a2e9263af0 fix: add explicit HTTPException for access control failures (#20280)
Fix implicit None returns in get_model_by_id, get_knowledge_by_id, get_tools_by_id, and get_prompt_by_command. Now properly returns 401 for access denied and 404 for not found instead of silently returning None.
2025-12-31 02:28:59 -05:00
Tim Baek 6351077958 refac 2025-12-31 02:27:55 -05:00
Classic298 2acee80829 fix: normalize Windows CRLF line endings in clipboard text (#20283)
On Windows, clipboard text uses CRLF (\r\n) line endings which can cause newlines to be lost or displayed incorrectly when pasted via the {{CLIPBOARD}} variable.

Fixed by converting CRLF to LF before inserting clipboard text.

Fixes #19370
2025-12-31 02:22:13 -05:00
Classic298 450421b189 fix: correct pending_user_overlay type definition in Config (#20285)
The Config type incorrectly defined 'pending_user_overlay_description'
but the backend returns 'pending_user_overlay_content' and the
AccountPending.svelte component correctly accesses that property.

This fixes the type mismatch to align with the actual backend response.

Fixes #20284
2025-12-31 02:21:59 -05:00
Timothy Jaeryang Baek c2c389f722 refac 2025-12-30 20:07:15 +04:00
G30 b997d00947 fix: prevent crash when regenerating message with missing parent (#20264) 2025-12-30 20:05:55 +04:00
Timothy Jaeryang Baek fe84afd09a enh: delta annotations support 2025-12-30 20:05:31 +04:00
Timothy Jaeryang Baek 08bf4670ec refac 2025-12-30 19:38:45 +04:00
Timothy Jaeryang Baek 18a33a079b refac 2025-12-30 19:33:30 +04:00
Timothy Jaeryang Baek d3a682759f enh: ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER 2025-12-30 19:31:59 +04:00
Timothy Jaeryang Baek 61e25dc2dc refac 2025-12-30 18:28:57 +04:00
Classic298 697e94e935 fix: prevent crash when invalid OpenAPI spec is loaded for tool servers (#20257)
* enh

* fix
2025-12-30 18:02:56 +04:00
Shirasawa 935808f5ea feat: Dynamically load mammoth to speed up page loading (#20202) 2025-12-30 17:51:50 +04:00
Classic298 464846d4a3 fix: retry Brave Search on HTTP 429 rate limit with 1s delay (#20255)
* Update brave.py

* Update brave.py
2025-12-30 17:37:44 +04:00
Classic298 95ce70c4fd Update Chat.svelte (#20256) 2025-12-30 17:31:13 +04:00
Classic298 214dd50ce9 feat: make changelog modal X button persist dismissal like "Okay, Let's Go!" button (#20258)
* Update ChangelogModal.svelte

* function
2025-12-30 17:29:38 +04:00
Classic298 4750a7cab0 feat: expose ADMIN_EMAIL setting in Admin Panel (#20260) 2025-12-30 17:28:25 +04:00
Classic298 3b6601631b fix: Update ResponseMessage.svelte (#20210)
* Update ResponseMessage.svelte

* Update ResponseMessage.svelte
2025-12-30 14:06:23 +04:00
Classic298 d5ccd95293 fix: Update CitationModal.svelte (#20209)
* Update CitationModal.svelte

* Update CitationModal.svelte
2025-12-30 14:06:11 +04:00
Classic298 b3371033be chore(deps): update and synchronize backend dependencies (#20225)
* chore(deps): update and synchronize backend dependencies

- Updated dependencies in requirements files and pyproject.toml to latest versions.
- Preserved pinned versions for av, pinecone, and pyarrow.
- Added missing dependencies to pyproject.toml to match requirements.txt.
- Ensured all dependency versions are synchronized across files.

* Update pyproject.toml
2025-12-30 14:05:56 +04:00
Timothy Jaeryang BaekandPaul aaea9a5956 refac/fix: comfyui filter output node type
Co-Authored-By: Paul <239564541+mirrordna-reflection-protocol@users.noreply.github.com>
2025-12-30 12:24:48 +04:00
Classic298 c02451178c init (#20212) 2025-12-30 12:15:31 +04:00
Timothy Jaeryang Baek fe3047d53c refac 2025-12-29 02:05:55 +04:00
Timothy Jaeryang Baek 5649a668fa refac 2025-12-29 01:42:13 +04:00
Timothy Jaeryang Baek 2453b75ff0 refac 2025-12-29 01:31:27 +04:00
Timothy Jaeryang Baek 5d1459df16 refac 2025-12-29 01:20:04 +04:00
Timothy Jaeryang Baek 88dbc14abc refac 2025-12-29 00:55:53 +04:00
Classic298 ca176c5c03 Update KnowledgeSelector.svelte (#20213) 2025-12-29 00:48:51 +04:00
Shirasawa 87e8d39a90 feat: Dynamically load xlsx to speed up page loading (#20203) 2025-12-29 00:47:37 +04:00
G30 9a9b5ef699 feat: add sidebar toggle to workspace and admin models pages (#20176) 2025-12-29 00:45:45 +04:00
Shirasawa 3994d88c90 chore: remove vega import to reduce build size (#20200) 2025-12-29 00:44:44 +04:00
Shirasawa cc602abcb2 feat: Dynamically load yaml to speed up page loading (#20204) 2025-12-29 00:44:22 +04:00
Timothy Jaeryang Baek 267cc14d2d chore: rm google-generativeai 2025-12-29 00:42:49 +04:00
Timothy Jaeryang Baek 26af1f92e2 enh: WHISPER_COMPUTE_TYPE env var 2025-12-29 00:41:47 +04:00
Timothy Jaeryang Baek 475dd91ed7 refac 2025-12-29 00:39:43 +04:00
Timothy Jaeryang Baek 9405628e46 chore: dep-min 2025-12-29 00:24:49 +04:00
Shirasawa 93db0a4e57 I18n: improve chinese translation (#20197)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2025-12-29 00:23:28 +04:00
Timothy Jaeryang Baek b1d0f00d8c refac/enh: db session sharing 2025-12-29 00:21:18 +04:00
Timothy Jaeryang Baek 6dd0f99b90 rm: outdated tests 2025-12-28 23:35:09 +04:00
Timothy Jaeryang Baek eb4b085b86 refac 2025-12-28 23:23:56 +04:00
Timothy Jaeryang Baek c79fb7db74 refac 2025-12-28 22:40:28 +04:00
Timothy Jaeryang Baek 145c7516f2 refac 2025-12-28 22:26:35 +04:00
Timothy Jaeryang Baek 2041ab483e refac/enh: db session sharing 2025-12-28 22:00:44 +04:00
Timothy Jaeryang Baek d4de26bd05 refac 2025-12-27 22:31:18 +04:00
Timothy Jaeryang Baek 7b6f8b49e0 refac 2025-12-27 22:16:24 +04:00
Timothy Jaeryang Baek 37b15604de refac 2025-12-27 22:16:10 +04:00
Timothy Jaeryang Baek a9609585af refac 2025-12-27 01:41:06 +04:00
Timothy Jaeryang Baek ac0ae2ae20 refac 2025-12-27 01:06:21 +04:00
Timothy Jaeryang Baek 3b0d25ad2b refac 2025-12-27 00:40:08 +04:00
Timothy Jaeryang Baek adf463059d refac 2025-12-27 00:02:03 +04:00
Timothy Jaeryang Baek 5f20102b33 refac 2025-12-26 23:55:00 +04:00
Timothy Jaeryang Baek d08f9ed556 refac 2025-12-26 19:09:05 +04:00
Dechao Sun 25db8225f8 openWebUI supports openGauss vector store (#20179) 2025-12-26 18:32:05 +04:00
Timothy Jaeryang Baek 9c2f5148d9 refac 2025-12-26 18:30:50 +04:00
landerrosette d29271184e fix: extract correct MIME type from base64 profile images (#20171) 2025-12-26 18:27:18 +04:00
Timothy Jaeryang Baek f5c07e317e refac 2025-12-26 16:59:14 +04:00
Timothy Jaeryang Baek 39a2b9789c refac 2025-12-26 16:16:10 +04:00
Timothy Jaeryang Baek 51dd488417 refac 2025-12-26 16:09:51 +04:00
Timothy Jaeryang Baek 6c14ba1fb4 refac 2025-12-26 15:57:19 +04:00
Timothy Jaeryang Baek 44faf00fac refac 2025-12-26 15:46:32 +04:00
Timothy Jaeryang Baek 4a17a7c20a refac 2025-12-26 15:44:41 +04:00
Timothy Jaeryang Baek 459cfde1f8 refac 2025-12-26 15:36:56 +04:00
Timothy Jaeryang Baek 85bbed3ec5 enh: sync stats 2025-12-26 15:13:50 +04:00
Timothy Jaeryang Baek 9c61e95ecb refac 2025-12-26 14:05:16 +04:00
Tim Baek df106099a1 refac 2025-12-25 18:32:13 -05:00
Tim Baek 4be99174be refac 2025-12-25 18:11:17 -05:00
Timothy Jaeryang Baek c0ec04935b refac: citation 2025-12-26 02:05:03 +04:00
joaoback 2e9e46a9f1 Update translation.json (pt-BR) (#20131)
Correcting a translation that had an incomplete variable.
2025-12-23 11:28:53 +04:00
Timothy Jaeryang Baek 5995771583 fix: channel model mention 2025-12-23 10:42:33 +04:00
Timothy Jaeryang Baek 758325a203 infra: dockerfile base image 2025-12-23 10:15:05 +04:00
Timothy Jaeryang Baek 9824f0e333 enh: ENABLE_DB_MIGRATIONS 2025-12-23 10:14:54 +04:00
Timothy Jaeryang Baek 117b1bfa65 refac 2025-12-22 19:06:15 +04:00
Tim Baek e9706cb1e5 Merge pull request #20104 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve Chinese translation
2025-12-22 19:04:56 +04:00
Timothy Jaeryang Baek 769110f290 refac: styling 2025-12-22 17:21:31 +04:00
Timothy Jaeryang Baek d053600096 refac: styling 2025-12-22 17:20:19 +04:00
Shirasawa 42b6cf32bc i18n: improve zh-TW translation 2025-12-22 18:39:38 +08:00
Shirasawa 8f37a1b721 i18n: improve zh-CN translation 2025-12-22 18:38:18 +08:00
Tim Baekandjoaoback a7271532f8 0.6.43 (#20093)
* refac

* fix: python pip install dep issue

* Merge pull request #20085 from joaoback/patch-19

Update translation.json (pt-BR)

* fix/refac: stt default content type

* fix/refac: temp chat image handling

* fix/refac: image action button

* chore: bump

---------

Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2025-12-22 01:03:34 -05:00
Timothy Jaeryang Baek 5088583721 chore: bump 2025-12-22 10:02:57 +04:00
Timothy Jaeryang Baek 0a78323406 fix/refac: image action button 2025-12-22 09:56:44 +04:00
Timothy Jaeryang Baek 423983f5a7 fix/refac: temp chat image handling 2025-12-22 09:48:53 +04:00
Timothy Jaeryang Baek 4ab917c74b fix/refac: stt default content type 2025-12-22 09:45:55 +04:00
joaoback 7f0ca8c4b7 Merge pull request #20085 from joaoback/patch-19
Update translation.json (pt-BR)
2025-12-22 00:43:25 -05:00
Timothy Jaeryang Baek 5c5f87a63c fix: python pip install dep issue 2025-12-22 09:42:50 +04:00
Timothy Jaeryang Baek def8a0027d refac 2025-12-22 09:41:12 +04:00
Tim Baek d95f533214 Merge pull request #19729 from open-webui/dev
0.6.42
2025-12-21 16:08:58 -05:00
Timothy Jaeryang Baek de97f797ce refac 2025-12-22 01:05:54 +04:00
Timothy Jaeryang Baek f0a04427e2 refac 2025-12-22 01:02:50 +04:00
Timothy Jaeryang Baek 088f62f1e1 refac: hf github action 2025-12-22 01:00:52 +04:00
Timothy Jaeryang Baek cfa8b3a396 chore: format 2025-12-22 01:00:20 +04:00
Classic298 a8bb000616 chore: CHANGELOG: 0.6.42 (#19827)
* Update CHANGELOG for version 0.6.42

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG for version 0.6.42

Updated release date for version 0.6.42 and added detailed notes on new features.

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2025-12-21 15:57:32 -05:00
Timothy Jaeryang Baek 837f7022d5 doc 2025-12-22 00:56:53 +04:00
Timothy Jaeryang Baek 67fd2ec759 doc: banner 2025-12-22 00:53:49 +04:00
Timothy Jaeryang Baek baf0f51827 doc: demo image 2025-12-22 00:46:41 +04:00
Classic298 f7d5750210 Update translation.json (#20083) 2025-12-21 15:42:47 -05:00
Timothy Jaeryang Baek 446cc0ac60 refac 2025-12-22 00:39:05 +04:00
Timothy Jaeryang Baek 01e88c6ac2 chore: format 2025-12-21 23:34:08 +04:00
Timothy Jaeryang Baek 45e3237756 fix/refac: shared chat files behaviour 2025-12-21 23:29:54 +04:00
Timothy Jaeryang Baek bd7f1869ce refac 2025-12-21 23:22:14 +04:00
Timothy Jaeryang Baek f1bf4f20c5 feat: chat_file table 2025-12-21 23:17:53 +04:00
Timothy Jaeryang Baek a3458f492c fix/refac: note editor file upload behaviour 2025-12-21 21:28:43 +04:00
Timothy Jaeryang Baek 96ec66a89c refac 2025-12-21 20:49:17 +04:00
Timothy Jaeryang Baek a1155dfeb7 refac 2025-12-21 20:48:37 +04:00
Timothy Jaeryang Baek fa924946b0 refac 2025-12-21 20:37:51 +04:00
Timothy Jaeryang BaekandClassic298 b35aeb8f46 feat: custom model base model fallback
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2025-12-21 20:22:37 +04:00
Classic298 60c93b4ccc chore: dep bump (#20077)
* Update pyproject.toml

* Update requirements-min.txt

* Update requirements.txt
2025-12-21 10:52:06 -05:00
Classic298 ef43e81f9a fix: MCP OAuth 2.1 token exchange and multi-node propagation (#20076)
* sequential

* zero default

* fix

* fix: preserve absolute paths in sqlite+sqlcipher URLs

Previously, the connection logic incorrectly stripped the leading slash
from `sqlite+sqlcipher` paths, forcibly converting absolute paths
(e.g., `sqlite+sqlcipher:////app/data.db`) into relative paths
(which became `app/data.db`). This caused database initialization failures
when using absolute paths, such as with Docker volume mounts.
This change removes the slash-stripping logic, ensuring that absolute
path conventions (starting with `/`) are respected while maintaining
support for relative paths (which do not start with `/`).

* fix: MCP OAuth 2.1 token exchange and multi-node propagation

Fix two MCP OAuth 2.1 bugs affecting tool server authentication:

1. Token exchange failing with duplicate credentials (#19823)
   - Removed explicit client_id/client_secret passing in handle_callback()
   - Authlib already has credentials configured during add_client(),
     passing them again caused concatenation (e.g., "ID1,ID1") and 401 errors
   - Added token validation to detect missing access_token and provide
     clear error messages instead of cryptic database constraint errors

2. OAuth clients not propagating across multi-node setups (#19901)
   - Updated get_client() and get_client_info() to auto-lazy-load
     OAuth clients from the Redis-synced TOOL_SERVER_CONNECTIONS config
   - Clients are now instantiated on-demand on any node that needs them

Fixes #19823, #19901

* Update db.py

* Update wrappers.py
2025-12-21 10:51:52 -05:00
Timothy Jaeryang Baek f826d3ed75 refac/enh: sidebar section expand behaviour 2025-12-21 18:40:40 +04:00
Classic298 b3904b6ecb fix: Fix handling of absolute paths for SQLCipher databases (#20074)
* sequential

* zero default

* fix

* fix: preserve absolute paths in sqlite+sqlcipher URLs

Previously, the connection logic incorrectly stripped the leading slash
from `sqlite+sqlcipher` paths, forcibly converting absolute paths
(e.g., `sqlite+sqlcipher:////app/data.db`) into relative paths
(which became `app/data.db`). This caused database initialization failures
when using absolute paths, such as with Docker volume mounts.
This change removes the slash-stripping logic, ensuring that absolute
path conventions (starting with `/`) are respected while maintaining
support for relative paths (which do not start with `/`).
2025-12-21 09:18:20 -05:00
Timothy Jaeryang Baek c96549eaa7 refac 2025-12-21 18:08:36 +04:00
Timothy Jaeryang Baek 7746e9f4b8 refac 2025-12-21 17:04:39 +04:00
Classic298 4fd790f7dd feat: Apply WEB_SEARCH_CONCURRENT_REQUESTS to all search engines using semaphore (#20070)
* sequential

* zero default

* fix
2025-12-21 07:18:00 -05:00
Timothy Jaeryang Baek ae203d8952 refac 2025-12-21 16:15:28 +04:00
Classic298 48ccb1e170 fix: consolidate psql cleanup logic and fix web add with cleanup (#20072)
* sequential

* consolidate logic and fix for web add

* Update WebSearch.svelte

* Update retrieval.py

* Update retrieval.py

* Update WebSearch.svelte
2025-12-21 07:14:29 -05:00
Timothy Jaeryang Baek 5077676d33 refac 2025-12-21 16:10:51 +04:00
Timothy Jaeryang BaekandClassic298 0dd2cfe1f2 enh: models endpoint optimization
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>

#20010
2025-12-21 15:43:02 +04:00
Timothy Jaeryang Baek 53c1ca64b7 enh: feedback ids endpoint 2025-12-21 15:33:06 +04:00
Timothy Jaeryang Baek 870e29e373 fix: multimodel messages 2025-12-21 15:27:33 +04:00
Timothy Jaeryang Baek 7e4848e10b refac: usage in model editor 2025-12-21 14:42:32 +04:00
Timothy Jaeryang Baek 6993b0b40b enh: temp chat docx file support 2025-12-21 14:37:55 +04:00
Timothy Jaeryang Baek 360289db21 refac: file item modal 2025-12-21 14:24:53 +04:00
G30 0eeda79048 fix: user group pagination reset and model image caching (#19959)
- Changed default sort order in 'Edit Group' modal to 'created_at' (newest first).
  Previously it sorted by group membership, causing toggled users to invisible jump
  to the top of the list (often changing pages). using a stable sort fixes this UX issue.
- Removed unnecessary `page = 1` reset in toggleMember function so admins
  don't lose their place when selecting multiple users.

Bug 2 - Model Image Display Bug:
- Added `Cache-Control: no-cache, must-revalidate` headers to the model profile
  image endpoint. This ensures that when a model avatar is updated, the browser
  fetches the new image instead of serving a stale cached version (favicon).

Fixes #19885
2025-12-21 05:04:45 -05:00
G30 8eddff83cb fix(utils): add safety check for attributes.result in processDetails (#19923) 2025-12-21 04:59:21 -05:00
Timothy Jaeryang Baek 28b2fcab0c refac 2025-12-21 13:58:49 +04:00
0b0962a2ae fix: Show DefaultFiltersSelector for global toggleable filters (#123) (#20066)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-21 04:50:36 -05:00
Timothy Jaeryang Baek 90c0c5da00 refac: PASSWORD_VALIDATION_REGEX_PATTERN 2025-12-21 13:49:54 +04:00
Timothy Jaeryang Baek fe653a1336 refac 2025-12-20 18:12:03 +04:00
Timothy Jaeryang Baek 4c2e5c93e9 refac: gpt-image support 2025-12-20 17:56:46 +04:00
Timothy Jaeryang Baek d4bd938a77 refac: ollama web 2025-12-20 17:54:13 +04:00
okamototk 37085ed42b chore: update langchain 1.2.0 (#19991)
* chore: update langchain 1.2.0

* chore: format
2025-12-20 08:50:44 -05:00
2e7c7d635d fix: prevent ExternalReranker from blocking event loop during RAG queries (#20049)
* fix: prevent ExternalReranker from blocking event loop during RAG queries (#120)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19900

* Merge pull request open-webui#19030 from open-webui/dev (#122)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19900

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 08:43:40 -05:00
Timothy Jaeryang Baek b0d8372d31 refac 2025-12-20 17:41:57 +04:00
Timothy Jaeryang Baek afaa404fe4 enh: mineru api timeout 2025-12-20 17:39:33 +04:00
G30 8890555dab Fix: Make meta optional in FileMetadataResponse to prevent batch add errors (#20022) 2025-12-20 08:25:03 -05:00
Timothy Jaeryang Baekandzengxy b5bd8704fe fix: bocha filter list
Co-Authored-By: zengxy <11961641+zengxy@users.noreply.github.com>
2025-12-20 17:24:24 +04:00
823b9a6dd9 chore/perf: Remove old SRC level log env vars with no impact (#20045)
* Update openai.py

* Update env.py

* Merge pull request open-webui#19030 from open-webui/dev (#119)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 08:16:14 -05:00
G30 d65116282c fix: enforce global ENABLE_CHANNELS check on all channel endpoints (#19957)
* fix: enforce global ENABLE_CHANNELS check on all channel endpoints

When channels are disabled globally (ENABLE_CHANNELS=false), users with
channel permissions could still fetch channels via API endpoints. This
fix adds a get_enabled_channels dependency to all 22 channel endpoints
that returns 403 Forbidden when channels are globally disabled.

Fixes #19914

* refac

* refac
2025-12-20 08:15:29 -05:00
G30 e61724d2b1 feat: Enhanced File Viewer Modal (Excel, CSV, Markdown & Code) (#20035)
* feat: Add Excel file viewer to FileItemModal

* feat: Add CSV file viewer to FileItemModal

* feat: Add Markdown and Code syntax highlighting to file viewer

* chore: add dependency

* fix: default to raw text view for Excel/Code/MD files

* fix: only show rows count in preview tab for excel files
2025-12-20 08:15:01 -05:00
Classic298 cd170735c5 chore: dep bump (#20012)
* Update requirements-min.txt

* Update pyproject.toml

* Update requirements.txt

* Update pyproject.toml

* Update requirements.txt

* Update requirements-min.txt
2025-12-20 07:30:30 -05:00
3d5aaa9ead feat: Align conditional fetching with conditional rendering for API Keys and Channels (#118) (#20043)
Fixes #19967

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 07:29:51 -05:00
149bb9dae2 feat/perf: Add ENABLE_PUBLIC_ACTIVE_USERS_COUNT environment variable (#20027)
* Merge pull request open-webui#19030 from open-webui/dev (#115)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
Resolves #13026

* Claude/find active user count 1ct t1 (#116)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
Resolves #13026

* Claude/find active user count 1ct t1 (#117)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
Resolves #13026

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 05:38:16 -05:00
Cyp ed8bb028a0 improve Korean translation (#20021) 2025-12-20 05:34:30 -05:00
Shirasawa ffbd774d32 I18n: improve Chinese translation (#20055)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2025-12-20 05:32:12 -05:00
G30 05a3aae59c fix: preserve access_control when cloning prompts (#19960)
* fix: preserve access_control when cloning prompts

Cloned prompts now inherit the original prompt's access_control settings
instead of defaulting to null/public. This ensures that permission and
sharing restrictions are carried over with clones as expected.

If the original prompt doesn't have access_control set, defaults to {}
(private) which matches the behavior of newly created prompts.

Fixes #19360

* fix: clone access control
2025-12-16 13:56:28 -05:00
Timothy Jaeryang Baek f509f5542d refac: channel name 2025-12-16 13:51:39 -05:00
Timothy Jaeryang Baek 0eb33e8e12 refac: logit bias handling 2025-12-16 13:49:00 -05:00
Timothy Jaeryang Baek 59d6eb2bad refac 2025-12-16 13:45:11 -05:00
Timothy Jaeryang Baek 7bd43f00b1 refac: channel name styling 2025-12-16 13:44:15 -05:00
Timothy Jaeryang Baek cad4c61a25 refac: readme 2025-12-16 13:20:29 -05:00
Classic298 dce52f33c0 chore: small dep bump (#19972)
* Update requirements.txt

* Update pyproject.toml
2025-12-15 13:31:40 -05:00
G30 0413c973e7 fix: add functional null safety checks and boolean casting to Chat.svelte (#19921)
* fix(chat): add functional null safety checks and boolean casting

* Update Chat.svelte
2025-12-14 15:13:39 -05:00
Boris Bocquet bc681f8258 feat : new environment variable SEARXNG_LANGUAGE , in the persistent config, that you can also edit in Admin > Web Search pannel in case you choose Searxng. This is used in the request to searxng as the "search language" (arguement "language"). Before this feature, it was set to en-US only. Now default is "all". (#19909) 2025-12-14 12:38:47 -05:00
Taylor Wilsdon 195801f74a ensure from config (#19902) 2025-12-14 12:38:19 -05:00
G30 07d28ad781 fix: enable COMFYUI_WORKFLOW_NODES and IMAGES_EDIT_COMFYUI_WORKFLOW_NODES configuration via env vars (#19918)
* fix: enable COMFYUI_WORKFLOW_NODES loading from environment variables

* fix: enable IMAGES_EDIT_COMFYUI_WORKFLOW_NODES loading from environment variables
2025-12-14 12:15:20 -05:00
Kylapaallikko 8f7f5db1fd Update fi-FI translation.json (#19926)
Added missing translations.
2025-12-14 12:14:38 -05:00
G30 06038cd73d fix: resolve NPE in Node.svelte when model is undefined (#19935) 2025-12-14 12:13:49 -05:00
G30 e11c6dca75 fix(model-editor): add null safety for tools, functions, and file input (#19939) 2025-12-14 12:12:57 -05:00
Leo Wang a1bde74e97 fix(prompt): preserve non-ASCII characters in tool specs serialization (#19942)
Use json.dumps with ensure_ascii=False to avoid converting Chinese
descriptions into Unicode escape sequences.

This makes logs easier to read and significantly improves LLM tool
selection accuracy (from ~48% to ~67%) by preserving the original
natural language context.
2025-12-14 12:12:14 -05:00
Classic298 1c62be4406 chore: dep bump (#19937)
* Update requirements.txt

* Update requirements-min.txt

* Update pyproject.toml
2025-12-13 14:42:08 -05:00
MichaelandTim Baek 67290e1c2a i18n(de): Optimize and standardize German translations (#19911)
* i18n(de): Optimize and standardize German translations for consistency and tone

- Unified user addressing to the formal "Sie" (polite form) throughout the UI, retaining "Du" only for specific persona contexts
- Fixed context-specific translation errors (e.g., "Call" from "Anrufen" to "Aufrufen", "Generation" from "Generation" to "Generierung", "Dokomentenintelligenz" typo)
- Corrected hyphenation for compound nouns (e.g., "JSON-Datei", "API-Schlüssel", "Prompt-Variable") and general grammar
- Improved tone and clarity of error messages and prompts for better readability and a more professional UI tone
- Standardized technical concepts across all translation files

* Update translations based on the feedback provided

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-12 11:16:07 -05:00
ZyfaxandZyfax 64dbc71643 fix: reload ollama models list after model deletion (#19912)
Co-authored-by: Zyfax <kemon@hey.com>
2025-12-12 11:15:26 -05:00
Joseph Roberts 90209a30a8 fix: respect RTL text direction in file attachment containers (#19891)
Fixes #19742 where file attachment containers did not inherit the chat direction setting, causing layout issues when typing in RTL languages like Persian.

Added dir={$settings?.chatDirection ?? 'auto'} to file container divs in:
- MessageInput.svelte (chat input file attachments)
- UserMessage.svelte (user message file display)
- ResponseMessage.svelte (response message file display)
- channel/Messages/Message.svelte (channel message file display)

The fix ensures file containers automatically detect and respect RTL text direction, maintaining proper layout alignment for RTL languages while preserving LTR behavior for Latin text.
2025-12-11 20:51:40 -05:00
G30 18e6cfb1fd fix: add null checks to chat iterators in ArchivedChats and FolderPlaceholder (#19898) 2025-12-11 20:34:39 -05:00
Joseph Low 8e661a4e73 Fix: Use SCAN instead of KEYS for Redis cluster compatibility (#19871) 2025-12-11 14:30:51 -05:00
Timothy Jaeryang Baek 379f888c9d enh/refac: ldap handle multiple usernames 2025-12-11 14:26:35 -05:00
Timothy Jaeryang Baek 4c4b9d19a1 refac: dockerfile PYTHONUNBUFFERED 2025-12-11 14:05:34 -05:00
ZyfaxandZyfax 7364b67455 fix: regenerate response shortcut (#19875)
* fix: regenerate shortcut

* Refactor shortcut handling for regenerate response

* refac

---------

Co-authored-by: Zyfax <kemon@hey.com>
2025-12-11 14:02:16 -05:00
Jeppe Kuhlmann Andersen 3418f53d07 Updated Danish translations (#19881) 2025-12-11 14:01:51 -05:00
Timothy Jaeryang Baek 3b3e12b43a refac 2025-12-11 01:09:14 -05:00
Timothy Jaeryang Baek 4d9a51ba33 refac 2025-12-11 00:11:12 -05:00
Timothy Jaeryang Baek 4b4241273d refac: styling 2025-12-11 00:07:32 -05:00
Timothy Jaeryang Baek db95e96688 chore: dep 2025-12-10 23:59:52 -05:00
Shirasawa 99c820d607 fix: fixed the issue of mismatched spaces in audio MIME types (#17771) 2025-12-10 23:59:10 -05:00
Timothy Jaeryang Baek 282c541427 refac 2025-12-10 23:56:20 -05:00
Timothy Jaeryang BaekandALiNew b364cf43d3 feat: resizable sidebar
Co-Authored-By: ALiNew <42788336+sukjinkim@users.noreply.github.com>
2025-12-10 23:54:36 -05:00
Timothy Jaeryang Baek b9676cf36f refac: styling 2025-12-10 23:35:46 -05:00
G30andTim Baek 258caaeced fix: resolve layout shift in knowledge items with long names (#19832)
Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-10 23:34:36 -05:00
Timothy Jaeryang Baek 6e99b10163 refac 2025-12-10 23:31:11 -05:00
Timothy Jaeryang Baek a2a9a9bcf4 refac 2025-12-10 23:28:40 -05:00
Timothy Jaeryang Baek 0addc1ea46 refac 2025-12-10 23:28:33 -05:00
Timothy Jaeryang Baek 6812d3b9d1 refac 2025-12-10 23:20:38 -05:00
Timothy Jaeryang Baek ceae3d48e6 enh/refac: kb pagination 2025-12-10 23:19:19 -05:00
Timothy Jaeryang Baek 3ed1df2e53 refac: search notes db query 2025-12-10 21:06:53 -05:00
Timothy Jaeryang Baek 68219d84a9 refac 2025-12-10 17:08:31 -05:00
Timothy Jaeryang Baek 6068e23590 refac 2025-12-10 17:08:18 -05:00
Timothy Jaeryang Baek d7467a86e2 refac 2025-12-10 17:03:51 -05:00
Timothy Jaeryang Baek d098c57d4d refac 2025-12-10 17:00:28 -05:00
Timothy Jaeryang Baek 693636d971 enh/refac: show read only kbs 2025-12-10 16:58:53 -05:00
Timothy Jaeryang Baek a6ef82c5ed refac: styling 2025-12-10 16:43:43 -05:00
Timothy Jaeryang Baek 79cfe29bb2 refac: channel_file and knowledge table migration 2025-12-10 16:41:22 -05:00
Timothy Jaeryang Baek d1d42128e5 refac/fix: channel files 2025-12-10 15:53:45 -05:00
Timothy Jaeryang Baek 2bccf8350d enh: channel files 2025-12-10 15:48:42 -05:00
Timothy Jaeryang Baek c15201620d refac: kb files 2025-12-10 15:48:27 -05:00
Andreas f31ca75892 Fix typo in user permission environment variables (#19860) 2025-12-10 15:09:15 -05:00
Timothy Jaeryang Baek a7993f6f4e refac 2025-12-10 12:22:40 -05:00
Timothy Jaeryang Baek ae47101dc6 refac 2025-12-10 11:07:41 -05:00
Timothy Jaeryang Baek cf6a1300ca enh: experimental chat usage stats endpoint 2025-12-10 02:00:00 -05:00
Timothy Jaeryang Baek a934dc997e refac: drop legacy kb support 2025-12-10 01:07:12 -05:00
Timothy Jaeryang Baek ed2db0d04b refac 2025-12-10 00:58:08 -05:00
Timothy Jaeryang Baek 4ecacda28c refac 2025-12-10 00:55:31 -05:00
Timothy Jaeryang Baek 94a8439105 feat/enh: kb file pagination 2025-12-10 00:53:41 -05:00
Timothy Jaeryang Baek 7b0b16ebbd refac 2025-12-09 23:57:46 -05:00
Timothy Jaeryang Baek 49d54c5821 refac 2025-12-09 23:33:48 -05:00
Timothy Jaeryang Baek 0eafc09965 refac: styling 2025-12-09 22:28:38 -05:00
Timothy Jaeryang Baek 6a75620fcb refac: styling 2025-12-09 21:53:34 -05:00
Timothy Jaeryang Baek 205c711120 enh: expand input 2025-12-09 21:50:27 -05:00
Timothy Jaeryang Baek 3af96c9d4e refac: styling 2025-12-09 21:11:49 -05:00
Timothy Jaeryang Baek 6e0badde67 refac: styling 2025-12-09 21:10:50 -05:00
Timothy Jaeryang Baek b29e7fd0be refac/fix: styling 2025-12-09 21:01:39 -05:00
Timothy Jaeryang Baek 02df867843 refac 2025-12-09 20:52:18 -05:00
Timothy Jaeryang Baek 00c2b6ca40 feat/enh: create note from input 2025-12-09 20:49:46 -05:00
Timothy Jaeryang Baek 65d4b22c7c refac 2025-12-09 19:47:38 -05:00
Timothy Jaeryang Baek a4fe823893 refac: styling 2025-12-09 19:45:13 -05:00
Timothy Jaeryang Baek 103ff0c5e4 refac 2025-12-09 18:03:01 -05:00
Timothy Jaeryang Baek 4363df175d enh: read only notes 2025-12-09 17:57:15 -05:00
Timothy Jaeryang Baek 307b37d5e2 refac 2025-12-09 17:19:42 -05:00
Timothy Jaeryang Baek 9b24cddef6 enh/refac: notes 2025-12-09 16:45:08 -05:00
Shirasawa 1ea555a5ac i18n: improve Chinese translation (#19830) 2025-12-09 15:29:43 -05:00
Shirasawa c24b1207a0 fix: fixed missing text in the explanation feature (#19829) 2025-12-09 15:29:27 -05:00
Classic298 44e41806f2 chore: dep bump across many dependencies (#19850)
* Update pyproject.toml (#101)

* Update pyproject.toml

* Update requirements.txt

* Update requirements-min.txt

* Upgrade Playwright version to 1.57.0

* Update langchain-community version to 0.3.29

* Update requirements.txt

* Update requirements-min.txt
2025-12-09 15:28:21 -05:00
Timothy Jaeryang Baek 2b1a29d44b enh: display user groups in user preview 2025-12-08 12:45:52 -05:00
Timothy Jaeryang BaekandJan Kessler f5fbbaf060 refac: redis config log
Co-Authored-By: Jan Kessler <Ithanil@users.noreply.github.com>
2025-12-08 12:18:25 -05:00
Timothy Jaeryang BaekandJan Kessler ba158d378f feat: REDIS_SOCKET_CONNECT_TIMEOUT
Co-Authored-By: Jan Kessler <Ithanil@users.noreply.github.com>
2025-12-08 11:59:45 -05:00
Timothy Jaeryang Baek b02397e460 feat: WEB_LOADER_TIMEOUT 2025-12-08 11:49:27 -05:00
Timothy Jaeryang Baek bcd50ed8f1 refac 2025-12-08 11:30:38 -05:00
_00_ 8cea0cf746 FIX: Pipeline save settings - Handle undefined valves property (#19791)
### FIX: Pipeline save settings - Handle undefined valves property

When a Pipeline valve have a null value the settings isn't saved.
The error occurs because the code tries to call `.split()` on a `null` value when saving pipeline valves.
This happens when you set a valve to "None" (null) and then click save.

This PR Fix this issue.
2025-12-08 10:09:53 -05:00
Timothy Jaeryang Baek ce945a9334 refac 2025-12-07 23:56:37 -05:00
Timothy Jaeryang Baek 3c8f1cf8e5 fix: source citations user message display issue 2025-12-07 23:53:46 -05:00
Timothy Jaeryang Baek 4d4ed743ae fix: styling 2025-12-07 18:13:28 -05:00
Timothy Jaeryang Baek aa9c0389c3 refac 2025-12-07 18:06:06 -05:00
bitsofinfo 492c8bac09 feat: new OAUTH_AUDIENCE config (#19768)
* feat: new config AUTH0_AUDIENCE

* feat: OAUTH_AUDIENCE config
2025-12-06 10:45:34 -05:00
Classic298 0617ede0eb Upgrade Playwright image and command to v1.56.0 (#19774) 2025-12-06 09:27:45 -05:00
Timothy Jaeryang Baek 7e6b8a9a71 refac/fix: docling auth 2025-12-06 08:06:42 -05:00
Classic298 dcdbafa5ae chore: bump pymilvus (#19773)
* Update pymilvus version to 2.6.5

* Update pyproject.toml
2025-12-06 08:03:42 -05:00
Classic298 cdd75ade50 BREAKING/CAUTION: chore: chromadb dep bump - needs testing (#19780)
* Update requirements.txt

* Update requirements-min.txt

* Update pyproject.toml
2025-12-06 08:03:04 -05:00
Timothy Jaeryang Baek 508fde70ff fix: temp chat perm 2025-12-06 08:02:12 -05:00
Timothy Jaeryang Baek 2a8a2f1ba3 refac 2025-12-04 20:40:52 -05:00
_00_andTim Baek d90d036d23 UPD: i18n es-ES Translation v.0.6.41 (#19745)
### UPD: i18n es-ES Translation v.0.6.41

Added new strings

Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-04 15:00:15 -05:00
James Westbrook 2f68ac33b3 chore: remove very outdated kubernetes configs (#19731)
* cleanup: remove very outdated kubernetes configs

* chore: remove INSTALLATION.md that references k8s manifests

* chore: remove kubernetes/ from .prettierignore
2025-12-04 14:59:30 -05:00
Aleix Dorca 01284b92db Update catalan translation.json (#19741) 2025-12-04 14:59:20 -05:00
Classic298 1779090bdb fix: add missing env var parameter pass through for enable async embedding (#19748)
* Add enable_async parameter to embedding function

* Add enable_async parameter to RAG configuration
2025-12-04 14:59:09 -05:00
Classic298 d27e019af3 chore: update qdrant dependency to fix Startup Warning (#19757)
* Update pyproject.toml

* Update requirements.txt
2025-12-04 14:58:04 -05:00
Timothy Jaeryang Baek 5c2df97f04 fix: user channels issue 2025-12-04 14:43:23 -05:00
Timothy Jaeryang Baek 6fe737bf8f refac 2025-12-03 19:31:23 -05:00
Timothy Jaeryang Baek 7b126b23d5 enh: channels video file upload behaviour 2025-12-03 19:15:30 -05:00
Timothy Jaeryang Baek 22f1b764a7 refac/perf: channel image upload behaviour 2025-12-03 19:06:02 -05:00
Timothy Jaeryang Baek 39f778c275 refac 2025-12-03 18:22:00 -05:00
Timothy Jaeryang Baek 54b7ec56d6 enh/refac: channels message lazy load data 2025-12-03 17:52:44 -05:00
G30 133618aaf0 fix: UI scale buttons (- and +) (#19699) 2025-12-03 17:17:05 -05:00
Shirasawa b560775713 fix: do not display the move button when no folders (#19705) 2025-12-03 17:16:38 -05:00
Shirasawa 964333ed65 i18n: improve Chinese translation (#19703) 2025-12-03 17:16:03 -05:00
joaoback 85b1e21765 Update translation.json (pt-BR) (#19700)
Translations of the new items added in the latest version.
2025-12-03 17:15:47 -05:00
Timothy Jaeryang Baek c86fcb75b7 chore 2025-12-02 19:59:14 -05:00
Tim Baek 6f1486ffd0 Merge pull request #19466 from open-webui/dev
0.6.41
2025-12-02 17:28:46 -05:00
Timothy Jaeryang Baek 73f7e91dec chore: format 2025-12-02 17:16:12 -05:00
Classic298 8361f73ca6 chore: 0.6.41 Changelog (#19473)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2025-12-02 17:15:19 -05:00
Timothy Jaeryang Baek 11efb982c1 refac 2025-12-02 17:14:45 -05:00
Timothy Jaeryang Baek 9d87688ecc chore: bump 2025-12-02 16:53:05 -05:00
Classic298 4f9677ffcf Update translation.json (#19697)
* Update translation.json

* Update translation.json
2025-12-02 16:48:11 -05:00
a49e1d87ad fix: Default Group ID assignment on SSO/OAUTH and LDAP (#19685)
* fix (#99)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>

* Update auths.py

* unified logic

* PUSH

* remove getattr

* rem getattr

* whitespace

* Update oauth.py

* trusted header group sync

Added default group re-application after trusted header group sync

* not apply after syncs

* .

* rem

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 16:48:00 -05:00
Timothy Jaeryang Baek 9a65ed2260 chore: format 2025-12-02 16:06:57 -05:00
Classic298 864d54095f Update translation.json (#19696) 2025-12-02 16:06:06 -05:00
Classic298 b29fdc2a0c Update milvus_multitenancy.py (#19695) 2025-12-02 15:38:06 -05:00
Classic298andTim Baek 12f237ff80 fix: Update milvus.py (#19602)
* Update milvus.py

* Update milvus.py

* Update milvus.py

* Update milvus.py

* Update milvus.py

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-02 15:30:31 -05:00
Timothy Jaeryang Baek 192c2af7ba refac 2025-12-02 15:17:47 -05:00
Matthew Kusz 17bfd38696 Fix dropdown backgrounds (#19693) 2025-12-02 15:16:36 -05:00
Henne a7e614ca4c feat: Adds document intelligence model configuration (#19692)
* Adds document intelligence model configuration

Enables the configuration of the Document Intelligence model to be used by the RAG pipeline.

This allows users to specify the model they want to use for document processing, providing flexibility and control over the extraction process.

* Added Titel to Document Intelligence Model Config

Added Titel to Document Intelligence Model Config
2025-12-02 14:41:09 -05:00
Timothy Jaeryang Baek e5c6b739c2 refac 2025-12-02 11:43:00 -05:00
Timothy Jaeryang Baek 34169b3581 refac 2025-12-02 11:31:23 -05:00
Timothy Jaeryang Baek 01868e856a enh: group members endpoint 2025-12-02 11:24:23 -05:00
Timothy Jaeryang Baek e301d1962e refac/perf: has_access_to_file optimization 2025-12-02 11:11:17 -05:00
Timothy Jaeryang Baek 9f6c91987f refac 2025-12-02 11:00:34 -05:00
Timothy Jaeryang Baek d19023288e feat/enh: kb files db migration 2025-12-02 10:53:32 -05:00
Timothy Jaeryang Baek 29236aefe8 refac 2025-12-02 10:25:38 -05:00
Timothy Jaeryang Baek 6ce9afd95d refac 2025-12-02 09:21:03 -05:00
Timothy Jaeryang Baek 39f7575b64 refac: show connection type for custom models 2025-12-02 06:19:48 -05:00
Timothy Jaeryang Baek 954aaa6bdc refac: styling 2025-12-02 05:47:05 -05:00
Timothy Jaeryang Baek aa589fcbd9 refac 2025-12-02 05:36:45 -05:00
Timothy Jaeryang Baek 9f42b9369f refac 2025-12-02 05:29:34 -05:00
Timothy Jaeryang Baek 143d3fbce2 refac 2025-12-02 04:18:19 -05:00
PocciaandTim Baek 6e531679f4 fix/adjust web search to properly block domains (#19670)
Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-02 04:17:32 -05:00
Timothy Jaeryang Baek 562f22960c refac 2025-12-02 04:07:02 -05:00
Timothy Jaeryang Baek 5388cc1bc6 refac 2025-12-02 04:03:44 -05:00
Classic298 0a14196afb Update milvus_multitenancy.py (#19680) 2025-12-02 03:57:14 -05:00
Timothy Jaeryang Baek 7b16637043 feat: signin rate limit 2025-12-02 03:52:38 -05:00
Timothy Jaeryang Baek 734c04ebf0 refac 2025-12-02 02:53:49 -05:00
Classic298andTim Baek 4f50571b53 Chore: dep bump (#19667)
* Update pyproject.toml

* Update requirements-min.txt

* Update requirements.txt

---------

Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-02 02:34:57 -05:00
Timothy Jaeryang Baek 52ccab8fc0 refac 2025-12-01 13:52:09 -05:00
Timothy Jaeryang Baek f5e8d4d5a0 refac 2025-12-01 13:34:57 -05:00
Timothy Jaeryang Baek 51621ba91a feat/enh: user status 2025-12-01 13:18:59 -05:00
Timothy Jaeryang Baek dba86bc980 fix: audit 2025-12-01 10:59:01 -05:00
Shirasawa 21f3411692 i18n: improve Chinese translation (#19651) 2025-12-01 10:58:06 -05:00
Timothy Jaeryang Baek 91473c788c chore: otel bump 2025-12-01 10:57:00 -05:00
Timothy Jaeryang Baek 25f0c26b25 chore: otel bump 2025-12-01 10:29:20 -05:00
Timothy Jaeryang Baek 9791c9bd8b refac 2025-11-30 15:56:42 -05:00
Timothy Jaeryang Baek c62609faba refac 2025-11-30 14:51:44 -05:00
Timothy Jaeryang Baek 88decab9be refac 2025-11-30 14:23:08 -05:00
Timothy Jaeryang Baek d499c3aed8 refac 2025-11-30 14:17:54 -05:00
Timothy Jaeryang Baek 277f3a91f1 refac 2025-11-30 14:06:16 -05:00
joaoback 1818f2b3d9 Update translation.json (pt-BR) (#19603)
translations of the new items that have been included
2025-11-30 11:09:16 -05:00
Timothy Jaeryang Baek a0826ec9fe feat/enh: dm from user profile preview 2025-11-30 11:04:06 -05:00
Timothy Jaeryang Baek 3c846617cd refac 2025-11-30 10:45:54 -05:00
Timothy Jaeryang Baek 39645102d1 refac 2025-11-30 10:40:24 -05:00
Timothy Jaeryang Baek 3f1d9ccbf8 feat/enh: add/remove users from group channel 2025-11-30 10:33:50 -05:00
Timothy Jaeryang Baek 781aeebd2a refac 2025-11-30 08:28:19 -05:00
Timothy Jaeryang Baek f589b7c189 feat/enh: group channel 2025-11-30 08:24:27 -05:00
Timothy Jaeryang Baek 696f356881 refac 2025-11-30 08:12:22 -05:00
Timothy Jaeryang Baek 515f85fe1c refac 2025-11-30 05:17:52 -05:00
Timothy Jaeryang Baek 4d74e6cefa refac: styling 2025-11-30 05:01:41 -05:00
Timothy Jaeryang Baek 3ebb3e2143 refac: styling 2025-11-30 03:56:12 -05:00
Timothy Jaeryang Baek 69b82edd63 refac 2025-11-30 03:50:14 -05:00
Timothy Jaeryang Baek 9d39b9b42c refac: styling 2025-11-30 03:47:34 -05:00
Timothy Jaeryang Baek e65d92fc6f refac 2025-11-30 02:32:34 -05:00
Timothy Jaeryang Baek f3c8c7045d refac 2025-11-29 14:19:55 -05:00
Timothy Jaeryang Baek c9185aaf44 refac 2025-11-29 14:18:22 -05:00
Timothy Jaeryang Baek 05e79bdd0c enh: message reaction user names 2025-11-29 13:54:52 -05:00
Timothy Jaeryang Baek fb6b18faef refac: knowledge file delete behaviour 2025-11-29 13:19:06 -05:00
Tim Baek b56adf01e3 Merge pull request #19584 from Classic298/patch-1
refac: improve weaker model's ability of understanding that the image was created successfully
2025-11-29 13:09:45 -05:00
Timothy Jaeryang Baek 356e982d30 refac 2025-11-29 12:57:17 -05:00
Classic298 bb4b547574 Update middleware.py 2025-11-29 11:11:21 +01:00
Timothy Jaeryang Baek 20340c3e4e refac 2025-11-29 01:05:39 -05:00
Timothy Jaeryang Baek 6c53bf7175 refac: styling 2025-11-28 23:10:01 -05:00
Timothy Jaeryang Baek ff121413da refac 2025-11-28 22:54:00 -05:00
Timothy Jaeryang Baek c1d760692f refac: db group 2025-11-28 22:48:58 -05:00
Timothy Jaeryang Baek a7c7993bbf refac/fix: temp chat image generation 2025-11-28 11:11:56 -05:00
Timothy Jaeryang Baek 0f3156651c refac/fix: ollama model delete 2025-11-28 11:01:22 -05:00
Timothy Jaeryang Baek c8071a3180 refac 2025-11-28 10:51:30 -05:00
Timothy Jaeryang Baek 25994dd3da refac/enh: channel message 2025-11-28 10:45:48 -05:00
Timothy Jaeryang Baek b9e849f17d refac: styling 2025-11-28 10:08:37 -05:00
Timothy Jaeryang Baek 80fbb29ccc refac: styling 2025-11-28 10:07:57 -05:00
Timothy Jaeryang Baek 7b1895ec8a refac 2025-11-28 10:04:06 -05:00
Timothy Jaeryang Baek aae2fce173 feat/enh: pinned messages in channels 2025-11-28 09:58:44 -05:00
Timothy Jaeryang Baek 451907cc92 refac 2025-11-28 09:32:37 -05:00
Timothy Jaeryang Baek 1b095d12ff refac: admin user list active indicator 2025-11-28 08:45:31 -05:00
Timothy Jaeryang Baek 0518749d51 refac: pin icons 2025-11-28 08:01:42 -05:00
Tim Baek fc06c16dd4 Merge pull request #19573 from open-webui/update-user-table
refac/db: update user table
2025-11-28 07:55:00 -05:00
Timothy Jaeryang Baek 33b59adf27 refac 2025-11-28 07:42:45 -05:00
Timothy Jaeryang Baek 70948f8803 enh/refac: deprecate USER_POOL 2025-11-28 07:39:02 -05:00
Timothy Jaeryang Baek c2634d45ad refac 2025-11-28 07:27:55 -05:00
Timothy Jaeryang Baek 8ef482a52a refac: user oauth display 2025-11-28 06:59:59 -05:00
Timothy Jaeryang Baek dcf50c4758 refac: api_key table migration 2025-11-28 06:49:10 -05:00
Timothy Jaeryang Baek 742832a850 refac 2025-11-28 06:41:41 -05:00
Timothy Jaeryang Baek 0a4358c3d1 refac: oauth_sub -> oauth migration 2025-11-28 06:39:36 -05:00
Timothy Jaeryang Baek 369298a83e refac: user table db migration 2025-11-28 06:29:41 -05:00
Timothy Jaeryang Baek b99c9b277a refac: styling 2025-11-28 04:29:50 -05:00
Timothy Jaeryang Baek 4b6773885c enh: dm active user indicator 2025-11-28 04:24:25 -05:00
Timothy Jaeryang Baek d232e433e8 refac: profile preview 2025-11-28 04:22:54 -05:00
Timothy Jaeryang Baek 848f3fd4d8 refac: hide active user count in sidebar user menu 2025-11-28 03:40:16 -05:00
Timothy Jaeryang Baek 453ea9b9a1 refac/fix: db migration issue 2025-11-28 03:10:48 -05:00
Timothy Jaeryang Baek 6ee50770cd refac 2025-11-28 02:44:36 -05:00
Timothy Jaeryang Baek 15dc607779 refac: rm print 2025-11-28 02:34:25 -05:00
Timothy Jaeryang Baek 32c888c280 refac 2025-11-28 01:40:52 -05:00
Timothy Jaeryang Baek 99a7823e01 refac: db 2025-11-28 01:17:43 -05:00
RomualdYT 022f9ff3a5 Update french translation.json (#19547) 2025-11-27 09:21:27 -05:00
Timothy Jaeryang Baek ad86707605 refac 2025-11-27 08:20:14 -05:00
Timothy Jaeryang Baek 289801b608 refac: styling 2025-11-27 08:12:06 -05:00
Timothy Jaeryang Baek 6bb204eb80 refac 2025-11-27 08:07:39 -05:00
Timothy Jaeryang Baek 560702a8f7 refac 2025-11-27 08:04:41 -05:00
Timothy Jaeryang Baek 6752772c1d chore: format 2025-11-27 07:57:31 -05:00
Timothy Jaeryang Baek d645cdbaf3 refac 2025-11-27 07:49:19 -05:00
Timothy Jaeryang Baek 3b4d7d568b refac 2025-11-27 07:43:10 -05:00
Timothy Jaeryang Baek d5d0e72590 refac 2025-11-27 07:39:00 -05:00
Timothy Jaeryang Baek f1a7de94ba refac 2025-11-27 07:33:33 -05:00
Timothy Jaeryang Baek acccb9afdd feat: dm channels 2025-11-27 07:27:32 -05:00
Timothy Jaeryang Baek f2c56fc839 refac 2025-11-27 06:03:22 -05:00
Timothy Jaeryang Baek dd6b808e69 refac 2025-11-27 05:25:38 -05:00
Timothy Jaeryang Baek 7a374ca2a5 refac 2025-11-27 05:11:17 -05:00
Timothy Jaeryang Baek 421aba7cd7 refac: hide channel add button for users 2025-11-27 04:49:29 -05:00
Timothy Jaeryang Baek 09b6ea38c5 feat/enh: group export endpoint 2025-11-27 04:44:01 -05:00
Timothy Jaeryang Baek 28659f6af5 refac/fix: files batch/add endpoint 2025-11-27 04:35:12 -05:00
Timothy Jaeryang Baek 64b4d5d9c2 feat/enh: channels unread messages count 2025-11-27 04:31:04 -05:00
Aleix Dorca c7a48c50a3 Update catalan translation.json (#19536) 2025-11-27 03:02:08 -05:00
Timothy Jaeryang Baekandcw.a b5e5617a41 enh: redis dict for internal models state
Co-Authored-By: cw.a <57549718+acwoo97@users.noreply.github.com>
2025-11-27 01:33:52 -05:00
Timothy Jaeryang Baek ff4b1b9824 refac: chat history data structure 2025-11-27 00:10:53 -05:00
stevessr 86cdcda29a fix: button without type (#19534) 2025-11-27 00:01:36 -05:00
Timothy Jaeryang Baek 5a32ea9b49 refac 2025-11-26 23:54:55 -05:00
Timothy Jaeryang Baek 457af65df6 enh/feat: toggle folders & user perm 2025-11-26 22:47:48 -05:00
Tobias Genannt 04b337323a fix: correct role check on OAuth login (#19476)
When a users role is switched from admin to user in the OAuth provider
their groups are not correctly updated when ENABLE_OAUTH_GROUP_MANAGEMENT
is enabled.
2025-11-26 21:48:06 -05:00
Timothy Jaeryang Baek 384753c6ca refac/enh: drop profile_image_url field in responses 2025-11-26 21:47:20 -05:00
Timothy Jaeryang Baek 3fe5a47050 refac/enh: knowledge base name on icon hover 2025-11-26 21:33:27 -05:00
Timothy Jaeryang Baek d1bbf6ba92 refac 2025-11-26 21:29:52 -05:00
Timothy Jaeryang Baek 9f89cc5adc refac 2025-11-26 21:28:32 -05:00
Shirasawa fa0efae4d5 i18n: improve Chinese translation (#19497) 2025-11-26 17:42:56 -05:00
gerhardj-b f2d6a425de feat: also consider OAUTH_ROLES_SEPARATOR for string claims themselves (#19514) 2025-11-26 17:38:26 -05:00
Classic298 d071cdf7d4 chore: update transformers dependency to fix issue #19512 (#19513)
* Update pyproject.toml

* Update requirements.txt

* Update requirements.txt

* Update pyproject.toml
2025-11-26 16:41:56 -05:00
Classic298 4b21704498 chore: Update pymilvus dep (#19507)
* Update requirements.txt

* Update pyproject.toml
2025-11-26 16:41:20 -05:00
Classic298 9fca4969db chore: dep bump pypdf to ver 6.4.0 (#19508)
* Update pyproject.toml

* Update requirements.txt
2025-11-26 16:41:12 -05:00
Timothy Jaeryang Baek 4370dee79e fix: async save docs to vector db 2025-11-25 17:19:33 -05:00
Classic298 c631659327 i18n: de-de (#19471) 2025-11-25 16:31:38 -05:00
Classic298 4df5b7eb2e fix: update dependency to prevent rediss:// failure (#19488)
* Update pyproject.toml

* Update requirements.txt

* Update requirements-min.txt
2025-11-25 16:28:58 -05:00
Timothy Jaeryang Baek 8b2015a97b refac 2025-11-25 16:28:06 -05:00
Timothy Jaeryang Baek 477097c2e4 refac 2025-11-25 16:27:27 -05:00
Timothy Jaeryang Baek c5b73d7184 refac/fix: function name filter type 2025-11-25 16:25:40 -05:00
Timothy Jaeryang Baek c7eb713689 fix: user preview profile image 2025-11-25 08:00:30 -05:00
Aleix Dorca 1bfe2c92ba Merge pull request #19464 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-11-25 07:03:49 -05:00
Timothy Jaeryang Baek 69722ba973 fix/refac: workspace shared model list 2025-11-25 06:32:27 -05:00
Tim Baek 140605e660 Merge pull request #19462 from open-webui/dev
0.6.40
2025-11-25 06:01:33 -05:00
Timothy Jaeryang Baek f3547568e4 refac: channel user list order by 2025-11-25 05:53:31 -05:00
Classic298 15c6860a49 Update CHANGELOG.md (#19463)
* Update CHANGELOG.md

* Update CHANGELOG.md
2025-11-25 05:50:39 -05:00
Timothy Jaeryang Baek 363ef194d8 chore: bump python-socketio==5.14.0 2025-11-25 05:49:30 -05:00
Timothy Jaeryang Baek 33a52628e6 chore: bump 2025-11-25 05:48:12 -05:00
Timothy Jaeryang Baek 35ab6b7667 fix: postgres user list issue 2025-11-25 05:47:04 -05:00
Timothy Jaeryang Baek 97ba5b8436 fix: changelog 2025-11-25 05:42:18 -05:00
535 changed files with 65983 additions and 16762 deletions
+18 -4
View File
@@ -1,3 +1,9 @@
<!--
⚠️ CRITICAL CHECKS FOR CONTRIBUTORS (READ, DON'T DELETE) ⚠️
1. Target the `dev` branch. PRs targeting `main` will be automatically closed.
2. Do NOT delete the CLA section at the bottom. It is required for the bot to accept your PR.
-->
# Pull Request Checklist
### Note to first-time contributors: Please open a discussion post in [Discussions](https://github.com/open-webui/open-webui/discussions) to discuss your idea/fix with the community before creating a pull request, and describe your changes before submitting a pull request.
@@ -6,14 +12,16 @@ This is to ensure large feature PRs are discussed with the community first, befo
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **Not targeting the `dev` branch will lead to immediate closure of the PR.**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Take this as an opportunity to **make screenshots of the feature/fix and include it in the PR description**.
- [ ] **Documentation:** Add docs in [Open WebUI Docs Repository](https://github.com/open-webui/docs). Document user-facing behavior, environment variables, public APIs/interfaces, or deployment steps.
- [ ] **Dependencies:** Are there any new or upgraded dependencies? If so, explain why, update the changelog/docs, and include any compatibility notes. Actually run the code/function that uses updated library to ensure it doesn't crash.
- [ ] **Testing:** Perform manual tests to **verify the implemented fix/feature works as intended AND does not break any other functionality**. Include reproducible steps to demonstrate the issue before the fix. Test edge cases (URL encoding, HTML entities, types). Take this as an opportunity to **make screenshots of the feature/fix and include them in the PR description**.
- [ ] **Agentic AI Code:** Confirm this Pull Request is **not written by any AI Agent** or has at least **gone through additional human review AND manual testing**. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Design & Architecture:** Prefer smart defaults over adding new settings; use local state for ephemeral UI logic. Open a Discussion for major architectural or UX changes.
- [ ] **Git Hygiene:** Keep PRs atomic (one logical change). Clean up commits and rebase on `dev` to ensure no unrelated commits (e.g. from `main`) are included. Push updates to the existing PR branch instead of closing and reopening.
- [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies
@@ -76,6 +84,12 @@ This is to ensure large feature PRs are discussed with the community first, befo
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
-->
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
+2 -1
View File
@@ -57,7 +57,8 @@ jobs:
git lfs install
git lfs track "*.ttf"
git lfs track "*.jpg"
rm demo.gif
rm demo.png
rm banner.png
git add .
git commit -m "GitHub deploy: ${{ github.sha }}"
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main
-2
View File
@@ -3,8 +3,6 @@ pnpm-lock.yaml
package-lock.json
yarn.lock
kubernetes/
# Copy of .gitignore
.DS_Store
node_modules
+554 -1
View File
@@ -5,7 +5,560 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### [0.6.39] - 2025-11-25
## [0.8.3] - 2026-02-17
### Added
- ✏️ **Model edit shortcut.** Users can now edit models directly from the model selector dropdown menu, making it faster to modify model settings without navigating to separate admin or workspace pages. [Commit](https://github.com/open-webui/open-webui/commit/519ff40cb69cdc1d215cee369e9db70ff7438153)
- 🎨 **Image edit API background support.** The image edit API now supports the background parameter for OpenAI's gpt-image-1 model, enabling background transparency control ("transparent", "opaque", "auto") when the feature is exposed in the UI. [#21459](https://github.com/open-webui/open-webui/pull/21459)
- ⚡ **Faster model filtering.** Model access control filtering no longer makes a redundant database query to re-fetch model info that is already available in memory, reducing latency when loading model lists for non-admin users. [Commit](https://github.com/open-webui/open-webui/commit/34cd3d79e8688f589e3dd2f03415f8a8f9a13115)
- 🔧 **Tool call display improvements.** Tool call results now display arguments in a cleaner key-value format instead of raw JSON, with a responsive layout that shows only the tool name on narrow screens and the full label on wider screens, preventing text wrapping to multiple lines. [Commit](https://github.com/open-webui/open-webui/commit/2ce935bdb10d2b26b230cd54cb649f5c667ed96a)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
### Fixed
- 📧 **USER_EMAIL variable fix.** The {{USER_EMAIL}} template variable now correctly returns the user's email address instead of "Unknown" in prompts. [#21479](https://github.com/open-webui/open-webui/pull/21479), [#21465](https://github.com/open-webui/open-webui/issues/21465)
- 🖼️ **Image and file attachment handling fixes.** Uploaded images are now correctly sent to vision-enabled models, and file attachments now work even when no user text is entered alongside a system prompt. This fixes two issues where the backend was not properly processing file attachments: images weren't converted to the expected format for API requests, and file context was dropped when the user sent only a file without accompanying text. [Commit](https://github.com/open-webui/open-webui/commit/f1053d94c7ef7b8b78682dd73586b65a84d202a1), [#21477](https://github.com/open-webui/open-webui/issues/21477), [#21457](https://github.com/open-webui/open-webui/issues/21457)
- 🛡️ **Missing function error handling.** Models that reference deleted functions no longer cause the entire /api/models endpoint to crash; instead, the missing functions are skipped and logged, allowing the rest of the models to load successfully. [#21476](https://github.com/open-webui/open-webui/pull/21476), [#21464](https://github.com/open-webui/open-webui/issues/21464)
- 🚀 **Startup model pre-fetch error handling.** If model pre-fetching fails during app startup, the application now logs a warning and continues instead of crashing entirely. [Commit](https://github.com/open-webui/open-webui/commit/337109e99ce390f55a9085d0a301853637923779)
- ⚙️ **Function module loading error handling.** Function modules that fail to load during startup or model processing are now caught and logged, preventing crashes when models reference functions with loading errors. [Commit](https://github.com/open-webui/open-webui/commit/15b893e651de71b033408e1b713e0b51f6829ab8)
- 🗄️ **PostgreSQL group query fix.** The '/api/v1/groups/' endpoint no longer fails with a GROUP BY error when using PostgreSQL; member counts are now calculated using correlated subqueries for better database compatibility. [#21458](https://github.com/open-webui/open-webui/pull/21458), [#21467](https://github.com/open-webui/open-webui/issues/21467)
## [0.8.2] - 2026-02-16
### Added
- 🧠 **Skill content handling.** User-selected skills now have their full content injected into the chat, while model-attached skills only display name and description in the available skills list. This allows users to override skill behavior while model-attached skills remain flexible. [Commit](https://github.com/open-webui/open-webui/commit/393c0071dc612c5ac982fb37dfc0288cb9911439)
- ⚙️ **Chat toggles now control built-in tools.** Users can now disable web search, image generation, and code execution on a per-conversation basis, even when those tools are enabled as builtin tools on the model. [#20641](https://github.com/open-webui/open-webui/issues/20641), [#21318](https://github.com/open-webui/open-webui/discussions/21318), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c), [Commit](https://github.com/open-webui/open-webui/commit/f1a1e64d2e9ad953b2bc2a9543e9a308b7c669c8)
- 🖼️ **Image preview in file modal.** Images uploaded to chats can now be previewed directly in the file management modal, making it easier to identify and manage image files. [#21413](https://github.com/open-webui/open-webui/issues/21413), [Commit](https://github.com/open-webui/open-webui/commit/e1b3e7252c1896c04d498547908f0fce111434e1)
- 🏷️ **Batch tag operations.** Tag creation, deletion, and orphan cleanup for chats now use batch database queries instead of per-tag loops, significantly reducing database round trips when updating, archiving, or deleting chats with multiple tags. [Commit](https://github.com/open-webui/open-webui/commit/c748c3ede)
- 💨 **Faster group list loading.** Group lists and search results now load with a single database query that joins member counts, replacing the previous pattern of fetching groups first and then counting members in a separate batch query. [Commit](https://github.com/open-webui/open-webui/commit/33308022f)
- 🔐 **Skills sharing permissions.** Administrators can now control skills sharing and public sharing permissions per-group, matching the existing capabilities for tools, knowledge, and prompts. [Commit](https://github.com/open-webui/open-webui/commit/88401e91c)
- ⚡ **Long content truncation in preview modals.** Citation and file content modals now truncate markdown-rendered content at 10,000 characters with a "Show all" expansion button, preventing UI jank when previewing very large documents.
- 🌐 **Translation updates.** Translations for Spanish and German were enhanced and expanded.
### Fixed
- 🔐 **OAuth session error handling.** Corrupted OAuth sessions are now gracefully handled and automatically cleaned up instead of causing errors. [Commit](https://github.com/open-webui/open-webui/commit/7e224e4a536b07ec008613f06592e34050e7067c)
- 🐛 **Task model selector validation.** The task model selector in admin settings now correctly accepts models based on the new access grants system instead of rejecting all models with an incorrect error. [Commit](https://github.com/open-webui/open-webui/commit/9a2595f0706d0c9d809ae7746001cf799f98db1d)
- 🔗 **Tool call message preservation.** Models no longer hallucinate tool outputs in multi-turn conversations because tool call history is now properly preserved instead of being merged into assistant messages. [#21098](https://github.com/open-webui/open-webui/discussions/21098), [#20600](https://github.com/open-webui/open-webui/issues/20600), [Commit](https://github.com/open-webui/open-webui/commit/f2aca781c87244cffc130aa2722e700c19a81d66)
- 🔧 **Tool server startup initialization.** External tool servers configured via the "TOOL_SERVER_CONNECTIONS" environment variable now initialize automatically on startup, eliminating the need to manually visit the Admin Panel and save for tools to become available. This enables proper GitOps and containerized deployments. [#18140](https://github.com/open-webui/open-webui/issues/18140), [#20914](https://github.com/open-webui/open-webui/pull/20914), [Commit](https://github.com/open-webui/open-webui/commit/f20cc6d7e6da493eb75ca1618f5cbd068fa57684)
- ♻️ **Resource handle cleanup.** File handles are now properly closed during audio transcription and pipeline uploads, preventing resource leaks that could cause system instability over time. [#21411](https://github.com/open-webui/open-webui/issues/21411)
- ⌨️ **Strikethrough shortcut conflict fix.** Pressing Ctrl+Shift+S to toggle the sidebar no longer causes text to become struck through in the chat input, by disabling the TipTap Strike extension's default keyboard shortcut when rich text mode is off. [Commit](https://github.com/open-webui/open-webui/commit/38ae91ae2)
- 🔧 **Tool call finish_reason fix.** API responses now correctly set finish_reason to "tool_calls" instead of "stop" when tool calls are present, fixing an issue where external API clients (such as OpenCode) would halt prematurely after tool execution when routing Ollama models through the Open WebUI API. [#20896](https://github.com/open-webui/open-webui/issues/20896)
## [0.8.1] - 2026-02-14
### Added
- 🚀 **Channel user active status.** Checking user active status in channels is now faster thanks to optimized database queries. [Commit](https://github.com/open-webui/open-webui/commit/ca6b18ab5cb94153a9dae233f975d36bf6b19b76)
- 🔗 **Responses API endpoint with model routing.** The OpenAI API proxy now supports a /responses endpoint that routes requests to the correct backend based on the model field in the request, instead of always using the first configured endpoint. This enables support for backends like vLLM that provide /skills and /v1/responses endpoints. [Commit](https://github.com/open-webui/open-webui/commit/abc9b63093d65f4d74342db85b7d5df1809aa0f0), [Commit](https://github.com/open-webui/open-webui/commit/79ecbfc757f0642740d0e44fab98263d84295490)
- ⚡ **Model and prompt list optimization.** Improved performance when loading models and prompts by pre-fetching user group IDs once instead of making multiple database queries. [Commit](https://github.com/open-webui/open-webui/commit/20de5a87da0c12e4052b50887a42ddd7228c5ef5)
- 🗄️ **Batch access control queries.** Improved performance when loading models, prompts, and knowledge bases by replacing multiple individual access checks with single batch queries, significantly reducing database load for large deployments. [Commit](https://github.com/open-webui/open-webui/commit/589c4e64c1b7bb7a7a5abc20382b92fb860e28c2)
- 💨 **Faster user list loading.** User lists now load significantly faster by deferring profile image loading; images are fetched separately in parallel by the browser, improving caching and reducing database load. [Commit](https://github.com/open-webui/open-webui/commit/b7549d2f6ca2843661ec79a5a1e55da9e7553368)
- 🔍 **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#21373](https://github.com/open-webui/open-webui/pull/21373), [#21371](https://github.com/open-webui/open-webui/issues/21371)
- 🔐 **SCIM externalId support.** SCIM-enabled deployments can now store and manage externalId for user provisioning, enabling better integration with identity providers like Microsoft Entra ID and Okta. [#21099](https://github.com/open-webui/open-webui/pull/21099), [#21280](https://github.com/open-webui/open-webui/issues/21280), [Commit](https://github.com/open-webui/open-webui/commit/d1d1efe212b16e0052359991d67fd813125077e8)
- 🌐 **Translation updates.** Portuguese (Brazil) translations were updated.
### Fixed
- 🛡️ **Public sharing security fix.** Fixed a security issue where users with write access could see the Public sharing option regardless of their actual public sharing permission, and direct API calls could bypass frontend sharing restrictions. [#21358](https://github.com/open-webui/open-webui/pull/21358), [#21356](https://github.com/open-webui/open-webui/issues/21356)
- 🔒 **Direct model access control fix.** Model access control changes now persist correctly for direct Ollama and OpenAI models that don't have database entries, and error messages display properly instead of showing "[object Object]". [Commit](https://github.com/open-webui/open-webui/commit/f027a01ab2ff3b6175af3dd13a4478c265c0544a), [#21377](https://github.com/open-webui/open-webui/issues/21377)
- 💭 **Reasoning trace rendering performance.** Reasoning traces from models now render properly without being split into many fragments, preventing browser slowdowns during streaming responses. [#21348](https://github.com/open-webui/open-webui/issues/21348), [Commit](https://github.com/open-webui/open-webui/commit/3b61562c82448cf83710d8b6ed29b797991aa83a)
- 🖥️ **ARM device compatibility fix.** Fixed an issue where upgrading to 0.8.0 would fail to start on ARM devices (like Raspberry Pi 4) due to torch 2.10.0 causing SIGILL errors; now pinned to torch<=2.9.1. [#21385](https://github.com/open-webui/open-webui/pull/21385), [#21349](https://github.com/open-webui/open-webui/issues/21349)
- 🗄️ **Skills PostgreSQL compatibility fix.** Fixed a PostgreSQL compatibility issue where creating or listing skills would fail with a TypeError, while SQLite worked correctly. [#21372](https://github.com/open-webui/open-webui/pull/21372), [Commit](https://github.com/open-webui/open-webui/commit/b4c3f54f9648c4232a0fd6557703ffa66fcf4caa), [#21365](https://github.com/open-webui/open-webui/issues/21365)
- 🗄️ **PostgreSQL analytics query fix.** Fixed an issue where retrieving chat IDs by model ID would fail on PostgreSQL due to incompatible DISTINCT ordering, while SQLite worked correctly. [#21347](https://github.com/open-webui/open-webui/issues/21347), [Commit](https://github.com/open-webui/open-webui/commit/7bda6bf767d5d5c4dc1111465096a88e10b5030e)
- 🗃️ **SQLite cascade delete fix.** Deleting chats now properly removes all associated messages in SQLite, matching PostgreSQL behavior and preventing orphaned data. [#21362](https://github.com/open-webui/open-webui/pull/21362)
- ☁️ **Ollama Cloud model naming fix.** Fixed an issue where using Ollama Cloud models would fail with "Model not found" errors because ":latest" was incorrectly appended to model names. [#21386](https://github.com/open-webui/open-webui/issues/21386)
- 🛠️ **Knowledge selector tooltip z-index.** Fixed an issue where tooltips in the "Select Knowledge" dropdown were hidden behind the menu, making it difficult to read knowledge item names and descriptions. [#21375](https://github.com/open-webui/open-webui/pull/21375)
- 🎯 **Model selector scroll position.** The model selector dropdown now correctly scrolls to and centers the currently selected model when opened, and resets scroll position when reopened. [Commit](https://github.com/open-webui/open-webui/commit/0b05b2fc7ed4c38af158707438ff404d1beb7c91)
- 🐛 **Sync modal unexpected appearance.** Fixed an issue where the Sync Modal would appear unexpectedly after enabling the "Community Sharing" feature if the user had previously visited the app with the sync parameter. [#21376](https://github.com/open-webui/open-webui/pull/21376)
- 🎨 **Knowledge collection layout fix.** Fixed a layout issue in the Knowledge integration menu where long collection names caused indentation artifacts and now properly truncate with ellipsis. [#21374](https://github.com/open-webui/open-webui/pull/21374)
- 📝 **Metadata processing crash fix.** Fixed a latent bug where processing document metadata containing certain keys (content, pages, tables, paragraphs, sections, figures) would cause a RuntimeError due to dictionary mutation during iteration. [#21105](https://github.com/open-webui/open-webui/pull/21105)
- 🔑 **Password validation regex fix.** Fixed the password validation regex by adding the raw string prefix, ensuring escape sequences like \d and \w are interpreted correctly. [#21400](https://github.com/open-webui/open-webui/pull/21400), [#21399](https://github.com/open-webui/open-webui/issues/21399)
### Changed
- ⚠️ **Database Migrations:** This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
## [0.8.0] - 2026-02-12
### Added
- 📊 **Analytics dashboard.** Administrators now have access to an Analytics dashboard showing model usage statistics, token consumption by model and user, user activity rankings, and time-series charts with hourly or daily granularity; clicking any model opens a detail view with feedback history, associated tags, and chat browser, and results can be filtered by user group. [#21106](https://github.com/open-webui/open-webui/pull/21106), [Commit](https://github.com/open-webui/open-webui/commit/68a1e87b66a7ec8831d5ed52940c4ef110e3e264), [Commit](https://github.com/open-webui/open-webui/commit/e62649f94044abfed4d7d60647a2050383a67e3d)
- 🎯 **Experimental support for Skills.** Open WebUI now supports the Skill standard — allowing users to create and manage reusable AI skills with detailed instructions, reference them in chats using the "$" command, or attach them to specific models for automatic context in conversations. [#21312](https://github.com/open-webui/open-webui/pull/21312)
- 🧪 **Experimental support for Open Responses protocol.** Connections can now be configured to use the experimental Open Responses protocol instead of Chat Completions, enabling native support for extended thinking, streaming reasoning tokens, and richer tool call handling for compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/d2c695eb11ddca9fc93499bb0c3fcafcff7099b5), [Commit](https://github.com/open-webui/open-webui/commit/90a057f4005c000bda6ff8703e13e529190af73a), [Commit](https://github.com/open-webui/open-webui/commit/0dc74a8a2e7adb76fb503ef0cd3c02daddd2f4bb), [Commit](https://github.com/open-webui/open-webui/commit/ea9c58ea80646cef05e06d0beaf5e81cc2f78cb1), [Commit](https://github.com/open-webui/open-webui/commit/6ffce4bccdc13b8b61a8b286e34094c981932eda), [Commit](https://github.com/open-webui/open-webui/commit/6719558150920f570d8febe021da65903e53c976), [Commit](https://github.com/open-webui/open-webui/commit/117c091b95a1b1a76a31c31b97304bac289d6f18), [Commit](https://github.com/open-webui/open-webui/commit/aa8c2959ca8476f269786e1317fb6d2938abd3f9), [Commit](https://github.com/open-webui/open-webui/commit/e2d09ac36174de48a7d85bafc8d3291c9ffe44cd)
- 👥 **Redesigned access control UI.** The access control UI was redesigned with a more intuitive interface that makes it easier to add multiple groups at once. [#21277](https://github.com/open-webui/open-webui/pull/21277)
- 👤 **Per-user resource sharing.** Resources including knowledge bases, prompts, models, tools, channels, and base models can now be shared directly to individual users alongside the existing per-group sharing capability. [#21277](https://github.com/open-webui/open-webui/pull/21277)
- 📨 **Message queuing.** Messages can now be queued while a response is generating rather than being blocked, allowing you to continue your train of thought; queued messages are automatically combined and sent when generation completes, and can be edited, deleted, or sent immediately from the input area. [Commit](https://github.com/open-webui/open-webui/commit/62750b8980ef0a3f2da7bc64b5416706a7495686), [Commit](https://github.com/open-webui/open-webui/commit/d3f2cf74748db42311ca04a56ccd1ea15399eca0)
- 💡 **Active task sidebar indicator.** Users can now see which chats have active tasks running directly in the sidebar. [Commit](https://github.com/open-webui/open-webui/commit/48522271586a5bf24b649610f03b4ffd8afb2782)
- 📝 **Prompt version control.** Prompts now include version control with full history tracking, allowing users to commit changes with messages, view past versions, compare differences between versions, and roll back to previous versions when needed. [#20945](https://github.com/open-webui/open-webui/pull/20945)
- 🏷️ **Prompt tags.** Prompts can now be organized with tags, and users can filter the prompt workspace by tag to quickly find related prompts across large collections. [#20945](https://github.com/open-webui/open-webui/pull/20945)
- 🐍 **Native function calling code execution.** Code execution now works with Native function calling mode, allowing models to autonomously run Python code for calculations, data analysis, and visualizations without requiring Default mode. [#20592](https://github.com/open-webui/open-webui/pull/20592), [Docs:#998](https://github.com/open-webui/docs/pull/998)
- 🚀 **Async web search.** Web search operations now run asynchronously in the background, allowing users to continue interacting with the application while searches complete. [#20630](https://github.com/open-webui/open-webui/pull/20630)
- ⚡ **Search debouncing.** Search operations across the application now respond more efficiently with debouncing that reduces unnecessary server requests while typing, improving responsiveness when searching users, groups, functions, tools, prompts, knowledge bases, notes, and when using the knowledge and prompts commands in chat. [#20982](https://github.com/open-webui/open-webui/pull/20982), [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50), [Commit](https://github.com/open-webui/open-webui/commit/fa859de460376782bd0fa35512c8426c9cd0462c), [Commit](https://github.com/open-webui/open-webui/commit/57ec2aa088ffd5a8c3553c53d39799497ff70479)
- 🤝 **Shared chats management.** Users can now view and manage all their shared chats from Settings, with options to copy share links or unshare conversations they no longer want public. [Commit](https://github.com/open-webui/open-webui/commit/a10ac774ab5d47b505e840b029c0c0340002508b)
- 📁 **User file management.** Users can now view, search, and delete all their uploaded files from Settings, providing centralized file management in one place. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7), [#21047](https://github.com/open-webui/open-webui/pull/21047)
- 🗑️ **Shift-click quick delete.** Files in the File Manager can now be quickly deleted by holding Shift and clicking the delete button, bypassing the confirmation dialog for faster bulk cleanup. [#21044](https://github.com/open-webui/open-webui/pull/21044)
- ⌨️ **Model selector shortcut.** The model selector can now be opened with Ctrl+Shift+M keyboard shortcut. [#21130](https://github.com/open-webui/open-webui/pull/21130)
- 🧠 **Smarter knowledge vs web search.** Models now choose more intelligently between knowledge base search and web search rather than always trying knowledge first. [#21115](https://github.com/open-webui/open-webui/pull/21115)
- 🌍 **Community model reviews.** Users can now access community reviews for models directly from the model selector menu and are prompted to leave reviews after rating responses, with administrators able to disable this via the "Community Sharing" setting. [Commit](https://github.com/open-webui/open-webui/commit/bc90463ea60c9a66accb1fd242cf1853910ca838)
- 📄 **Prompts workspace pagination.** The prompts workspace now includes pagination for large prompt collections, loading 30 prompts at a time with search, filtering, and sorting capabilities for improved performance and navigation. [Commit](https://github.com/open-webui/open-webui/commit/36766f157d46102fd76c526b42579400ca70de50)
- 🎨 **Action function HTML rendering.** Action functions can now render rich HTML content directly in chat as embedded iframes, matching the capabilities that tools already had and eliminating the need for action authors to inject codeblocks. [#21294](https://github.com/open-webui/open-webui/pull/21294), [Commit](https://github.com/open-webui/open-webui/commit/60ada21c152ed642971429fdbe88dcbf478cf83a)
- 🔒 **Password-masked valve fields.** Tool and function developers can now mark sensitive fields as passwords, which are automatically masked in the settings UI to prevent shoulder surfing and accidental exposure. [#20852](https://github.com/open-webui/open-webui/issues/20852), [Commit](https://github.com/open-webui/open-webui/commit/8c70453b2e3a6958437d951751e84acbbaafd9aa)
- 📋 **Prompt quick copy.** Prompts in the workspace now include a quick copy button for easily copying prompt content to the clipboard. [Commit](https://github.com/open-webui/open-webui/commit/78f856e2049991441a3469230ae52799cb86954e)
- 🔔 **Dismissible notification toasts.** Notification toasts for new messages and other events now include a close button that appears on hover, allowing users to dismiss them immediately instead of waiting for auto-dismissal. [#21056](https://github.com/open-webui/open-webui/issues/21056), [Commit](https://github.com/open-webui/open-webui/commit/73bb600034c8532e30726129743a5ffe9002c5fb)
- 🔔 **Temporary chat notification privacy.** Notifications from temporary chats now only appear on the device where the chat is running, preventing privacy leaks across logged-in sessions. [#21292](https://github.com/open-webui/open-webui/pull/21292)
- 💡 **Null chat title fallback.** Notifications without chat titles now display "New Chat" instead of showing null. [#21292](https://github.com/open-webui/open-webui/pull/21292)
- 🖼️ **Concurrent image editing.** Image editing operations with multiple images now complete faster by loading all images concurrently instead of sequentially. [#20911](https://github.com/open-webui/open-webui/pull/20911)
- 📧 **USER_EMAIL template variable.** Users can now reference their email address in prompts and system messages using the "{{USER_EMAIL}}" template variable. [#20881](https://github.com/open-webui/open-webui/pull/20881)
- 🔤 **Alphabetical tool ordering.** Tools and Functions in the Chat Controls sidebar now appear in alphabetical order, making it easier to locate specific tools when working with multiple integrations. [#20871](https://github.com/open-webui/open-webui/pull/20871)
- 👁️ **Model list status filtering.** Administrators can now filter the model list by status (enabled, disabled, visible, hidden) and bulk enable or disable all filtered models at once. [#20553](https://github.com/open-webui/open-webui/issues/20553), [#20774](https://github.com/open-webui/open-webui/issues/20774), [Commit](https://github.com/open-webui/open-webui/commit/96a9696383d450dad2cbb230f3756ebfa258e029)
- ⚙️ **Per-model built-in tool toggles.** Administrators can now enable or disable individual built-in tools for each model, including time utilities, memory, chat history, notes, knowledge base, and channels. [#20641](https://github.com/open-webui/open-webui/issues/20641), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c)
- 📑 **PDF loading modes.** Administrators can now choose between "page" and "single" PDF loading modes, allowing documents to be processed as individual pages or as complete documents for better chunking across page boundaries. [Commit](https://github.com/open-webui/open-webui/commit/ecbdef732bc71a07c21bbb679edb420f26eac181)
- 📑 **Model Settings pagination.** Administrators can now navigate large model lists more efficiently in Model Settings, with pagination displaying 30 models per page for smoother navigation. [Commit](https://github.com/open-webui/open-webui/commit/2f584c9f88aeb34ece07b10d05794020d1d656b8)
- 📌 **Pin read-only models.** Users can now pin read-only models from the workspace. [#21308](https://github.com/open-webui/open-webui/issues/21308), [Commit](https://github.com/open-webui/open-webui/commit/97331bf11d41ca54e47f86777fb8dbd73988c631)
- 🔍 **Yandex search provider.** Administrators can now configure Yandex as a web search provider, expanding search engine options for retrieval-augmented generation. [#20922](https://github.com/open-webui/open-webui/pull/20922)
- 🔐 **Custom password hints.** Administrators can now provide custom password requirement hints to users via the "PASSWORD_VALIDATION_HINT" environment variable, making it clearer what password criteria must be met during signup or password changes. [#20647](https://github.com/open-webui/open-webui/issues/20647), [#20650](https://github.com/open-webui/open-webui/pull/20650)
- 🔑 **OAuth token exchange.** Administrators can now enable OAuth token exchange via "ENABLE_OAUTH_TOKEN_EXCHANGE", allowing external applications to authenticate users by exchanging OAuth provider tokens for Open WebUI session tokens. [Commit](https://github.com/open-webui/open-webui/commit/655420fd25ed0ea872954baa485030079c00c10e)
- 🗄️ **Weaviate custom endpoints.** Administrators can now connect to self-hosted Weaviate deployments with separate HTTP and gRPC endpoints via new environment variables. [#20620](https://github.com/open-webui/open-webui/pull/20620)
- 🛡️ **MCP custom SSL certificates.** Administrators can now connect to MCP servers with self-signed or custom SSL certificates via the "AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL" environment variable. [#20875](https://github.com/open-webui/open-webui/issues/20875), [Commit](https://github.com/open-webui/open-webui/commit/c7f996d593e4bb48103b91316204fe7e50e25b35)
- 🗃️ **Redis Sentinel reconnection delay.** Administrators using Redis Sentinel can now configure a reconnection delay via "REDIS_RECONNECT_DELAY" to prevent retry exhaustion during failover elections. [#21021](https://github.com/open-webui/open-webui/pull/21021)
- 📡 **Custom user info headers.** Administrators can now customize the header names used when forwarding user information to external services, enabling compatibility with services like AWS Bedrock AgentCore that require specific header prefixes. [Commit](https://github.com/open-webui/open-webui/commit/6c0f886cdf4b4249dca29e9340b3b998a7262d61)
- 🔗 **Forward user info to tool servers.** User identity and chat context can now be forwarded to MCP servers and external tool servers when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled, allowing tool providers to implement per-user authorization, auditing, and rate limiting. [#21092](https://github.com/open-webui/open-webui/pull/21092), [Commit](https://github.com/open-webui/open-webui/commit/2c37daef86a058e370151ecead17f10078102307)
- 📬 **External tool event emitters.** External tools (OpenAPI/MCP) can now send tool events back to Open WebUI using the event emitter endpoint, as message ID is now forwarded alongside chat ID when "ENABLE_FORWARD_USER_INFO_HEADERS" is enabled. [#21214](https://github.com/open-webui/open-webui/pull/21214)
- 📥 **Playground chat export.** Administrators can now export playground chats as JSON or plain text files, allowing them to save their conversations for backup or sharing outside the platform. [Commit](https://github.com/open-webui/open-webui/commit/8e2b0b6fd2ac99c833a110e2bc6aa655f1682669)
- 🖼️ **Images playground.** Administrators can now test image generation and editing directly in a new Images playground, with support for uploading source images for edits and downloading results. [Commit](https://github.com/open-webui/open-webui/commit/94302de49b27bdf1df86b5c26f2cafb98f964e52)
- 🛠️ **Dynamic dropdown valve fields.** Tool and function developers can now create dropdown fields with dynamically-generated options that update based on runtime context, such as available models or user permissions. [Commit](https://github.com/open-webui/open-webui/commit/474427c67e953bb9f7d122757a756a639214e0b2)
- 🏎️ **Faster profile updates.** User profile updates and role changes are now faster by eliminating redundant database queries. [#21011](https://github.com/open-webui/open-webui/pull/21011)
- 🔑 **Faster authentication.** User authentication is now 34% faster by combining database lookups into a single query. [#21010](https://github.com/open-webui/open-webui/pull/21010)
- 🔋 **Faster chat completions.** Chat completions and embeddings now respond much faster by checking the model cache before fetching model lists, reducing Time To First Token from several seconds to subsecond for most requests. [#20886](https://github.com/open-webui/open-webui/pull/20886), [#20069](https://github.com/open-webui/open-webui/discussions/20069)
- 🏎️ **Faster Redis model list loading.** Model list loading is now significantly faster when using Redis with many models, reducing API response latency by caching configuration values locally instead of making repeated Redis lookups on every model iteration. [#21306](https://github.com/open-webui/open-webui/pull/21306)
- 💨 **Faster knowledge base file batch-add.** Batch-adding files to knowledge bases is now faster with a single database query instead of one query per file. [#21006](https://github.com/open-webui/open-webui/pull/21006)
- ⚡ **Smoother model selector dropdown.** The model selector dropdown now renders smoothly even with hundreds of models, eliminating the lag and freezing that occurred when opening the dropdown with large model lists. [Commit](https://github.com/open-webui/open-webui/commit/4331029926245b7b74fa8e254610c91400b239b0)
- 🚗 **Faster model visibility toggling.** Toggling model visibility in the admin panel is now faster with optimized database access. [#21009](https://github.com/open-webui/open-webui/pull/21009)
- 💾 **Faster model access control checks.** Model access control checks are now faster by batch-fetching model info and group memberships upfront instead of querying for each model. [#21008](https://github.com/open-webui/open-webui/pull/21008)
- ⚙️ **Faster model list and imports.** Model list loading and model imports are now faster by eliminating redundant database queries. [#21004](https://github.com/open-webui/open-webui/pull/21004)
- 🏃 **Faster SCIM group member lookups.** SCIM group member lookups are now up to 13x faster by batching user queries instead of fetching each member individually. [#21005](https://github.com/open-webui/open-webui/pull/21005)
- 💨 **Batched group member counts.** Group member counts are now fetched in a single batch query when loading group lists, eliminating redundant database lookups. [Commit](https://github.com/open-webui/open-webui/commit/96c07f44a8f5e6346b2ea6ac529ff4ec3c47e90a)
- 💨 **Faster bulk operations.** Bulk feedback deletion and group member removal are now 4-5x faster with optimized batch operations. [#21019](https://github.com/open-webui/open-webui/pull/21019)
- 🧠 **Faster memory updates.** Memory updates are now up to 39% faster by eliminating redundant database queries. [#21013](https://github.com/open-webui/open-webui/pull/21013)
- ⚙️ **Faster filter function loading.** Filter function loading is now faster by batching database queries instead of fetching each function individually. [#21018](https://github.com/open-webui/open-webui/pull/21018)
- 🖼️ **Image model regex configuration.** Administrators can now configure which image generation models support auto-sizing and URL responses via new regex environment variables, improving compatibility with LiteLLM and other proxies that use prefixed model names. [#21126](https://github.com/open-webui/open-webui/pull/21126), [Commit](https://github.com/open-webui/open-webui/commit/ecf3fa2feb28e74ff6c17ca97d94581f316da56a)
- 🎁 **Easter eggs toggle.** Administrators can now control the visibility of easter egg features via the "ENABLE_EASTER_EGGS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/907dba4517903e5646e40223a0edca26a7107bc8)
- 🔌 **Independent access control updates.** API endpoints now support independent access control updates for models, tools, knowledge bases, and notes, enabling finer-grained permission management. [Commit](https://github.com/open-webui/open-webui/commit/0044902c082f8475336cc7d5c57fe3f35ab0555d), [Commit](https://github.com/open-webui/open-webui/commit/c259c878060af1b03b702c943e8813d7b4fc3199), [Commit](https://github.com/open-webui/open-webui/commit/e3a825769063cee486650cc2eb9a032676e630c5)
- ♿ **Screen reader accessibility.** Screen reader users now hear the password field label only once on the login page, improving form navigation for assistive technology users. [Commit](https://github.com/open-webui/open-webui/commit/1441d0d735c7a1470070b33327e1dd4dc5ca1131)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Catalan, Finnish, Irish, French, German, Japanese, Latvian, Polish, Portuguese (Brazil), Simplified Chinese, Slovak, Spanish, and Traditional Chinese were enhanced and expanded.
### Fixed
- ⚡ **Connection pool exhaustion fix.** Database connection pool exhaustion and timeout errors during concurrent usage have been resolved by releasing connections before chat completion requests and embedding operations for memory and knowledge base processing. [#20569](https://github.com/open-webui/open-webui/pull/20569), [#20570](https://github.com/open-webui/open-webui/pull/20570), [#20571](https://github.com/open-webui/open-webui/pull/20571), [#20572](https://github.com/open-webui/open-webui/pull/20572), [#20573](https://github.com/open-webui/open-webui/pull/20573), [#20574](https://github.com/open-webui/open-webui/pull/20574), [#20575](https://github.com/open-webui/open-webui/pull/20575), [#20576](https://github.com/open-webui/open-webui/pull/20576), [#20577](https://github.com/open-webui/open-webui/pull/20577), [#20578](https://github.com/open-webui/open-webui/pull/20578), [#20579](https://github.com/open-webui/open-webui/pull/20579), [#20580](https://github.com/open-webui/open-webui/pull/20580), [#20581](https://github.com/open-webui/open-webui/pull/20581), [Commit](https://github.com/open-webui/open-webui/commit/7da37b4f66b9b2e821796b06b75e03cb0237e0a9), [Commit](https://github.com/open-webui/open-webui/commit/9af40624c5f0f8f7f640a11356e167543b07b2bb)
- 🚫 **LDAP authentication hang fix.** LDAP authentication no longer freezes the entire service when logging in with non-existent accounts, preventing application hangs. [Commit](https://github.com/open-webui/open-webui/commit/a4281f6a7fbc9764b57830e4ef81bb780aa34af9), [#21300](https://github.com/open-webui/open-webui/issues/21300)
- ✅ **Trusted Header auto-registration fix.** Trusted Header Authentication now properly auto-registers new users after the first login, assigning the configured default role instead of failing for users not yet in the database. [Commit](https://github.com/open-webui/open-webui/commit/9b30e8f6894c8c8bad0a9ce4693eab810962adc9)
- 🛡️ **SSRF protection for image loading.** External image loading now validates URLs before fetching to prevent SSRF attacks against local and private network addresses. [Commit](https://github.com/open-webui/open-webui/commit/ce50d9bac4f30b054b09a2fbda52569b73ea591c)
- 🛡️ **Malformed Authorization header fix.** Malformed Authorization headers no longer cause server crashes; requests are now handled gracefully instead of returning HTTP 500 errors. [#20938](https://github.com/open-webui/open-webui/issues/20938), [Commit](https://github.com/open-webui/open-webui/commit/7e79f8d1c6b5a02f1a46e792540c6bbf7bed8edc)
- 🚪 **Channel notification access control.** Users without channel permissions can no longer access channels through notifications, properly enforcing access controls across all channel entry points. [#20883](https://github.com/open-webui/open-webui/pull/20883), [#20789](https://github.com/open-webui/open-webui/discussions/20789)
- 🐛 **Ollama model name suffix fix.** Ollama-compatible providers that do not use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
- ♻️ **Streaming connection cleanup.** Streaming responses now properly clean up network connections when interrupted, preventing "Unclosed client session" errors from accumulating over time. [#20889](https://github.com/open-webui/open-webui/pull/20889), [#17058](https://github.com/open-webui/open-webui/issues/17058)
- 💾 **Inline image context exhaustion fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
- 🚀 **Status indicator GPU usage fix.** High GPU usage caused by the user online status indicator animation has been resolved, reducing consumption from 35-40% to near-zero in browsers with hardware acceleration. [#21062](https://github.com/open-webui/open-webui/issues/21062), [Commit](https://github.com/open-webui/open-webui/commit/938d1b0743c64f0ce513d68e57dfbb86987cb06b)
- 🔧 **Async pipeline operations.** Pipeline operations now run asynchronously instead of blocking the FastAPI event loop, allowing the server to handle other requests while waiting for external pipeline API calls. [#20910](https://github.com/open-webui/open-webui/pull/20910)
- 🔌 **MCP tools regression fix.** MCP tools now work reliably again after a regression in v0.7.2 that caused "cannot pickle '\_asyncio.Future' object" errors when attempting to use MCP servers in chat. [#20629](https://github.com/open-webui/open-webui/issues/20629), [#20500](https://github.com/open-webui/open-webui/issues/20500), [Commit](https://github.com/open-webui/open-webui/commit/886c12c5664bc2dd73313330f61c2257169da6d1)
- 🔗 **Function chat ID propagation fix.** Functions now reliably receive the chat identifier during internal task invocations like web search query generation, RAG query generation, and image prompt generation, enabling stateful functions to maintain consistent per-chat state without fragmentation. [#20563](https://github.com/open-webui/open-webui/issues/20563), [#20585](https://github.com/open-webui/open-webui/pull/20585)
- 💻 **Markdown fence code execution fix.** Code execution now works reliably when models wrap code in markdown fences, automatically stripping the backticks before execution to prevent syntax errors that affected most non-GPT models. [#20941](https://github.com/open-webui/open-webui/issues/20941), [Commit](https://github.com/open-webui/open-webui/commit/4a5516775927aaf002212f2e09c55a17c699bc46), [Commit](https://github.com/open-webui/open-webui/commit/683438b418fb3b453a8ad88c1ba1a9944eac3593)
- 💻 **ANSI code execution fix.** Code execution is now reliable when LLMs include ANSI terminal color codes in their output, preventing random failures that previously caused syntax errors. [#21091](https://github.com/open-webui/open-webui/issues/21091), [Commit](https://github.com/open-webui/open-webui/commit/b1737040a7d3bb5efcfe0f1432e89d7e82e51d2d)
- 🗨️ **Incomplete model metadata crash fix.** Starting chats with models that have incomplete metadata information no longer crashes the application. [#20565](https://github.com/open-webui/open-webui/issues/20565), [Commit](https://github.com/open-webui/open-webui/commit/14f6747dfc66fb7e942b930650286012121e5262)
- 💬 **Unavailable model crash fix.** Adding message pairs with Ctrl+Shift+Enter no longer crashes when the chat's model is unavailable, showing a helpful error message instead. [#20663](https://github.com/open-webui/open-webui/pull/20663)
- 📚 **Knowledge base file upload fix.** Uploading files to knowledge bases now works correctly, fixing database mapping errors that prevented file uploads. [#20925](https://github.com/open-webui/open-webui/issues/20925), [#20931](https://github.com/open-webui/open-webui/pull/20931)
- 🧠 **Knowledge base query type fix.** Knowledge base queries no longer fail intermittently when models send tool call parameters as strings instead of their expected types. [#20705](https://github.com/open-webui/open-webui/pull/20705)
- 📚 **Knowledge base reindex fix.** Reindexing knowledge base files now works correctly instead of failing with duplicate content errors. [#20854](https://github.com/open-webui/open-webui/issues/20854), [#20857](https://github.com/open-webui/open-webui/pull/20857)
- 🔧 **Multi-worker knowledge base timeout fix.** In multi-worker deployments, uploading very large documents to knowledge bases no longer causes workers to be killed by health check timeouts, and administrators can now configure a custom embedding timeout via "RAG_EMBEDDING_TIMEOUT". [#21158](https://github.com/open-webui/open-webui/pull/21158), [Discussion](https://github.com/open-webui/open-webui/discussions/21151), [Commit](https://github.com/open-webui/open-webui/commit/c653e4ec54d070aee5e9568d016daebb61f06632)
- 🌅 **Dark mode icon inversion fix.** Icons in chat and action menus are now displayed correctly in dark mode, fixing an issue where PNG icons with "svg" in their base64 encoding were randomly inverted. [#21272](https://github.com/open-webui/open-webui/pull/21272), [Commit](https://github.com/open-webui/open-webui/commit/0a44d80252afae73de4098ab1c3eb6cf54157fd6)
- 🛠️ **Admin model write permission fix.** Fixed the admin panel allowing models to be assigned write permissions, since users with write permission are not admins and cannot write. [Commit](https://github.com/open-webui/open-webui/commit/4aedfdc5471a1f13c1084b34b48ea3ed6311cd42)
- 🛠️ **Prompt access control save fix.** Prompt access control settings are now saved correctly when modifying resource permissions. [Commit](https://github.com/open-webui/open-webui/commit/30f72672fac2579c267a076e6ba89dfe1812137b)
- ✏️ **Knowledge base file edit fix.** Editing files within knowledge bases now saves correctly and can be used for retrieval, fixing a silent failure where the save appeared successful but the file could not be searched. [Commit](https://github.com/open-webui/open-webui/commit/f9ab66f51a52388a4eb084c8f69044e79bf5cb04)
- 🖼️ **Reasoning section artifact rendering fix.** Code blocks within model reasoning sections no longer incorrectly render as interactive artifacts, ensuring only intended output displays as previews. [#20801](https://github.com/open-webui/open-webui/issues/20801), [#20877](https://github.com/open-webui/open-webui/pull/20877), [Commit](https://github.com/open-webui/open-webui/commit/4c6f100b5fe2145a3d676b70b5f7c0e7f07cee20)
- 🔐 **Group resource sharing fix.** Sharing resources with groups now works correctly, fixing database errors and an issue where models shared with read-only access were not visible to group members. [#20666](https://github.com/open-webui/open-webui/issues/20666), [#21043](https://github.com/open-webui/open-webui/issues/21043), [Commit](https://github.com/open-webui/open-webui/commit/5a075a2c836e46b83f8710285f09aff1f6125072)
- 🔑 **Docling API key fix.** Docling API key authentication now works correctly by using the proper "X-Api-Key" header format instead of the incorrect "Bearer" authorization prefix. [#20652](https://github.com/open-webui/open-webui/pull/20652)
- 🔌 **MCP OAuth 2.1 fix.** MCP OAuth 2.1 authentication now works correctly, resolving connection verification failures and 401 errors during the authorization callback. [#20808](https://github.com/open-webui/open-webui/issues/20808), [#20828](https://github.com/open-webui/open-webui/issues/20828), [Commit](https://github.com/open-webui/open-webui/commit/8eebc2aea63b7045e61c9689a65a2dfa9c797bcb)
- 💻 **MATLAB syntax highlighting.** MATLAB code blocks now display with proper syntax highlighting in chat messages. [#20719](https://github.com/open-webui/open-webui/issues/20719), [#20773](https://github.com/open-webui/open-webui/pull/20773)
- 📊 **CSV export HTML entity decoding.** Exporting tables to CSV now properly decodes HTML entities, ensuring special characters display correctly in the exported file. [#20688](https://github.com/open-webui/open-webui/pull/20688)
- 📄 **Markdown Header Text Splitter persistence.** The "Markdown Header Text Splitter" document setting now persists correctly when disabled, preventing it from reverting to enabled after page refresh. [#20929](https://github.com/open-webui/open-webui/issues/20929), [#20930](https://github.com/open-webui/open-webui/pull/20930)
- 🔌 **Audio service timeout handling.** Audio transcription and text-to-speech requests now have proper timeouts, preventing the UI from freezing when external services don't respond. [#21055](https://github.com/open-webui/open-webui/pull/21055)
- 💬 **Reference Chats visibility fix.** The "Reference Chats" option now appears in the message input menu even when the sidebar is collapsed, fixing the issue where it was hidden on mobile devices and at first load. [#20827](https://github.com/open-webui/open-webui/issues/20827), [Commit](https://github.com/open-webui/open-webui/commit/a3600e8b219fc4c019b95258d16bd3e2827490c6)
- 🔍 **Chat search self-exclusion.** The "search_chats" builtin tool now excludes the current conversation from search results, preventing redundant matches. [#20718](https://github.com/open-webui/open-webui/issues/20718), [Commit](https://github.com/open-webui/open-webui/commit/1a4bdd2b30017d901b9cac1e2e10684ec1edd062)
- 📚 **Knowledge base pagination fix.** Paginating through knowledge base files no longer shows duplicates or skips files when multiple documents share the same update timestamp. [#20846](https://github.com/open-webui/open-webui/issues/20846), [Commit](https://github.com/open-webui/open-webui/commit/a9a0ce6beaa286cc18eff24b518a6f3d7a560e2f)
- 📋 **Batch file error reporting.** Batch file processing operations now return properly structured error information when failures occur, making it clearer what went wrong during multi-file operations. [#20795](https://github.com/open-webui/open-webui/issues/20795), [Commit](https://github.com/open-webui/open-webui/commit/68b2872ed645cffb641fa5a21a784d6e9ea0d72b)
- ⚙️ **Persistent config with Redis fix.** Configuration values now respect the "ENABLE_PERSISTENT_CONFIG" setting when Redis is used, ensuring environment variables are reloaded on restart when persistent config is disabled. [#20830](https://github.com/open-webui/open-webui/issues/20830), [Commit](https://github.com/open-webui/open-webui/commit/5d48e48e15b003874cc821d896998a01e87580a0)
- 🔧 **Engine.IO logging fix.** The "WEBSOCKET_SERVER_ENGINEIO_LOGGING" environment variable now works correctly, allowing administrators to configure Engine.IO logging independently from general websocket logging. [#20727](https://github.com/open-webui/open-webui/pull/20727), [Commit](https://github.com/open-webui/open-webui/commit/5cfb7a08cbde5d39aaf4097b849a80da87c30d66)
- 🌐 **French language default fix.** Browsers requesting French language now default to French (France) instead of French (Canada), matching standard language preference expectations. [#20603](https://github.com/open-webui/open-webui/pull/20603), [Commit](https://github.com/open-webui/open-webui/commit/4d9a7cc6c0adea54b58046c576250a0c3ae7b512)
- 🔘 **Firefox delete button fix.** Pressing Enter after clicking delete buttons no longer incorrectly retriggers confirmation modals in Firefox. [Commit](https://github.com/open-webui/open-webui/commit/57a2024c58b9c674f2ae08eeb552994ef1796888)
- 🌍 **RTL table rendering fix.** Chat markdown tables now correctly display right-to-left when containing RTL language content (Arabic, Hebrew, Farsi, etc.), matching the "Auto" direction setting behavior. [#21160](https://github.com/open-webui/open-webui/issues/21160), [Commit](https://github.com/open-webui/open-webui/commit/284b97bd84c824013ad00ea07621192ec69a5e93)
- 🔒 **Write permission enforcement for tools.** Users without write permissions are now properly prevented from editing tools, with a clear error message displayed when attempting unauthorized edits. [Commit](https://github.com/open-webui/open-webui/commit/85e92fe3b062ae669985c09495f6ff1baf8176ab), [Commit](https://github.com/open-webui/open-webui/commit/91faa9fd5a1cfc5d3ab531d2d91d28db52bcc702)
- 🛡️ **Chat Valves permission enforcement.** The "Allow Chat Valves" permission is now properly enforced in the integrations menu, preventing users from bypassing access restrictions. [#20691](https://github.com/open-webui/open-webui/pull/20691)
- 📝 **Audit log browser session fix.** Audit logs now properly capture all user activity including browser-based sessions, not just API key requests. [#20651](https://github.com/open-webui/open-webui/issues/20651), [Commit](https://github.com/open-webui/open-webui/commit/86e6b2b68b85e958188881785495030de1a30402), [Commit](https://github.com/open-webui/open-webui/commit/ee5fd1246cb3f8f16ca5cbb24feeea43b7800dcb)
- 🎨 **Long model name truncation.** Long model names and IDs in the admin panel now truncate properly to prevent visual overflow, with full names visible on hover. [#20696](https://github.com/open-webui/open-webui/pull/20696)
- 👥 **Admin user filter pagination fix.** Filtering users in the admin panel now automatically resets to page 1, preventing empty results when searching from pages beyond the first. [#20723](https://github.com/open-webui/open-webui/pull/20723), [Commit](https://github.com/open-webui/open-webui/commit/be75bc506adb048ef11b1612c0e3662511c920d0)
- 🔎 **Username search on workspace pages.** Searching for users by username now works correctly on Models, Knowledge, and Functions workspace pages, making it easier to find resources owned by specific users. [#20780](https://github.com/open-webui/open-webui/pull/20780)
- 🗑️ **File deletion orphaned embeddings fix.** Deleting files now properly removes associated knowledge base embeddings, preventing orphaned data from accumulating. [Commit](https://github.com/open-webui/open-webui/commit/93ed4ae2cda2f4311143e51f586aaa73b83a37a7)
- 🧹 **Event listener memory leak fix.** Memory leaks caused by event listeners not being cleaned up during navigation have been resolved. [#20913](https://github.com/open-webui/open-webui/pull/20913)
- 🐳 **Docker Ollama update fix.** Ollama can now be updated within Docker containers after adding a missing zstd dependency. [#20994](https://github.com/open-webui/open-webui/issues/20994), [#21052](https://github.com/open-webui/open-webui/pull/21052)
- 📝 **Workspace duplicate API request fix.** The prompts, knowledge, and models workspaces no longer make duplicate API requests when loading. [Commit](https://github.com/open-webui/open-webui/commit/ab5dfbda54664c9278b0d807ba06cad94edd798f), [Commit](https://github.com/open-webui/open-webui/commit/e5dbfc420dd3e7f6ba047a3e11584449ff0742b4)
- 📡 **OpenTelemetry Redis cluster fix.** OpenTelemetry instrumentation now works correctly with Redis cluster mode deployments. [#21129](https://github.com/open-webui/open-webui/pull/21129)
- 🐳 **Airgapped NLTK tokenizer fix.** Document extraction now works reliably in airgapped environments after container restarts by bundling NLTK tokenizer data in the Docker image. [#21165](https://github.com/open-webui/open-webui/pull/21165), [#21150](https://github.com/open-webui/open-webui/issues/21150)
- 💬 **Channel model mention crash fix.** Mentioning a model in channels no longer crashes when older thread messages have missing data. [#21112](https://github.com/open-webui/open-webui/pull/21112)
- 🔧 **OpenAPI tool import fix.** Importing OpenAPI tool specifications no longer crashes when parameters lack explicit name fields, fixing compatibility with complex request body definitions. [#21121](https://github.com/open-webui/open-webui/pull/21121), [Commit](https://github.com/open-webui/open-webui/commit/8e79b3d0bc4903f30e747b663ac818976618c83c)
- 🌐 **Webpage attachment content fix.** Attaching webpages to chats now retrieves full content instead of only metadata, fixing an unawaited coroutine in SSL certificate verification. [#21166](https://github.com/open-webui/open-webui/issues/21166), [Commit](https://github.com/open-webui/open-webui/commit/a214ec40ea00eebcba49570647ca6ab8f61765d5)
- 💾 **File upload settings persistence.** File upload settings (Max Upload Size, Max File Count, Image Compression dimensions) now persist correctly and are no longer erased when updating other RAG configuration settings. [#21057](https://github.com/open-webui/open-webui/issues/21057), [Commit](https://github.com/open-webui/open-webui/commit/258454276e1ef8ded24968515f7bf5e1833ca011)
- 📦 **Tool call expand/collapse fix.** Tool call results in chat can now be expanded and collapsed again after a recent refactor disabled this behavior. [#21205](https://github.com/open-webui/open-webui/pull/21205)
- 🪛 **Disabled API endpoint bypass fix.** Fixed Ollama/OpenAI API endpoints bypassing 'ENABLE_OLLAMA_API' and 'ENABLE_OPENAI_API' flags when the 'url_idx' parameter was provided. Endpoints now properly return a 503 error with a clear "API is disabled" message instead of attempting to connect and logging confusing connection errors.
- 🛠️ **OpenSearch 3.0 compatibility fix.** Document uploads to knowledge bases now work correctly when using OpenSearch backend with opensearch-py >= 3.0.0, fixing a TypeError that previously caused failures. [#21248](https://github.com/open-webui/open-webui/pull/21248), [#20649](https://github.com/open-webui/open-webui/issues/20649)
- 📱 **Gboard multi-line paste fix.** Multi-line text pasted from Gboard on Android now inserts correctly instead of being replaced with a single newline, fixing a bug where the keyboard's clipboard suggestion strip sent text via 'insertText' events instead of standard paste events. [#21265](https://github.com/open-webui/open-webui/pull/21265)
- 🔧 **Batch embeddings endpoint fix.** The '/api/embeddings' endpoint now correctly returns separate embeddings for each input string when processing batch requests to Ollama providers. [Commit](https://github.com/open-webui/open-webui/commit/8fd5c06e5bf7e0ccbda15d83338912ea17f66783), [#21279](https://github.com/open-webui/open-webui/issues/21279)
- 🗝️ **SSL verification for embeddings.** SSL certificate verification now respects the "AIOHTTP_CLIENT_SESSION_SSL" setting for OpenAI and Azure OpenAI embedding requests, allowing connections to self-signed certificate endpoints when disabled. [Commit](https://github.com/open-webui/open-webui/commit/cd31b8301b38bfa86872608cfbd022ff74e3ae52)
- 🔧 **Tool call HTML entity fix.** Models now receive properly formatted tool call results in multi-turn conversations, fixing an issue where HTML entities caused malformed content that was hard to parse. [#20755](https://github.com/open-webui/open-webui/pull/20755)
- 💾 **Duplicate inline image context fix.** Inline images no longer exhaust the model's context window by including their full base64 data in chat metadata, preventing premature context exhaustion with image-heavy conversations. [#20916](https://github.com/open-webui/open-webui/pull/20916)
- 🐛 **OpenAI model cache lookup fix.** The OpenAI API router model lookup was corrected to use the proper model identifier when checking the cache, ensuring consistent and correct model retrieval during chat completions. [#21327](https://github.com/open-webui/open-webui/pull/21327)
- 🐛 **Ollama latest suffix fix.** Ollama-compatible providers that don't use ":latest" in model names can now successfully chat, fixing errors where model names were incorrectly appended with ":latest" suffixes. [#21331](https://github.com/open-webui/open-webui/issues/21331), [Commit](https://github.com/open-webui/open-webui/commit/05ae44b98dc279ee12cc8eab17278ccbfec60301)
- ⛔ **OpenAI endpoint detection fix.** OpenAI API endpoint detection was corrected to use exact hostname matching instead of substring matching, preventing third-party providers with similar URL patterns from being incorrectly filtered. [Commit](https://github.com/open-webui/open-webui/commit/423d8b18170a0b92b582aba6ef7bb9ba173e876e)
- 🛠️ **RedisCluster task stopping fix.** Task stopping now works correctly in RedisCluster deployments, fixing an issue where tasks would remain active after cancellation attempts. [#20803](https://github.com/open-webui/open-webui/pull/20803), [Commit](https://github.com/open-webui/open-webui/commit/0dcbd05e2436929ae9d2c559a204844ae0239b57)
- 📎 **Citation parsing error fix.** Citation parsing no longer crashes when builtin tools return error responses, fixing AttributeError issues when tools like search_web fail. [#21071](https://github.com/open-webui/open-webui/pull/21071)
### Changed
- ‼️ **Database Migration Required** — This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
- ⚠️ **Chat Message Table Migration** — This release includes a new chat message table migration that can take a significant amount of time to complete in larger deployments with extensive chat histories. Administrators should plan for adequate maintenance windows and allow the migration to complete fully without interruption. Running the migration with insufficient time or resources may result in data integrity issues.
- 🔗 **Prompt ID-based URLs.** Prompts now use unique ID-based URLs instead of command-based URLs, allowing more flexible command renaming without breaking saved links or integrations. [#20945](https://github.com/open-webui/open-webui/pull/20945)
## [0.7.2] - 2026-01-10
### Fixed
- ⚡ Users no longer experience database connection timeouts under high concurrency due to connections being held during LLM calls, telemetry collection, and file status streaming. [#20545](https://github.com/open-webui/open-webui/pull/20545), [#20542](https://github.com/open-webui/open-webui/pull/20542), [#20547](https://github.com/open-webui/open-webui/pull/20547)
- 📝 Users can now create and save prompts in the workspace prompts editor without encountering errors. [Commit](https://github.com/open-webui/open-webui/commit/ab99d3b1129cffbc13cf7de5aa897692e3f8662e)
- 🎙️ Users can now use local Whisper for speech-to-text when STT_ENGINE is left empty (the default for local mode). [#20534](https://github.com/open-webui/open-webui/pull/20534)
- 📊 The Evaluations page now loads faster by eliminating duplicate API calls to the leaderboard and feedbacks endpoints. [Commit](https://github.com/open-webui/open-webui/commit/2dd09223f2aac301a4d5c17fb667d974c34f3ff1)
- 🌐 Fixed missing Settings tab i18n label keys. [#20526](https://github.com/open-webui/open-webui/pull/20526)
## [0.7.1] - 2026-01-09
### Fixed
- ⚡ **Improved reliability for low-spec and SQLite deployments.** Fixed page timeouts by disabling database session sharing by default, improving stability for resource-constrained environments. Users can re-enable via 'DATABASE_ENABLE_SESSION_SHARING=true' if needed. [#20520](https://github.com/open-webui/open-webui/issues/20520)
## [0.7.0] - 2026-01-09
### Added
- 🤖 **Native Function Calling with Built-in Tools.** Users can now ask models to perform multi-step tasks that combine web research, knowledge base queries, note-taking, and image generation in a single conversation—for example, "research the latest on X, save key findings to a note, and generate an infographic." Requires models with native function calling support and function calling mode set to "Native" in Chat Controls. [#19397](https://github.com/open-webui/open-webui/issues/19397), [Commit](https://github.com/open-webui/open-webui/commit/5c1d52231a3997a17381c48639bd7e339262cf7c)
- 🧠 Users can now ask the model to find relevant context from their notes, past chats, and channel messages—for example, "what did I discuss about project X last week?" or "find the conversation where I brainstormed ideas for Y." [Commit](https://github.com/open-webui/open-webui/commit/646835d76744ad9b2e67ede0407a61d62e969aab)
- 📚 Users can now ask the model to search their knowledge bases and retrieve documents without manually attaching files—for example, "find the section about authentication in our API docs" or "what do our internal guidelines say about X?" [Commit](https://github.com/open-webui/open-webui/commit/c8622adcb01f3091b17ca50f8c8e2f20c7b9cd2a)
- 💭 Users with models that support interleaved thinking now get more refined results from multi-step workflows, as the model can analyze each tool's output before deciding what to do next.
- 🔍 When models invoke web search, search results appear as clickable citations in real-time for full source verification. [Commit](https://github.com/open-webui/open-webui/commit/2789f6a24d8405c30cd48ae460071f6a4f2c35f9)
- 🎚️ Users can selectively disable specific built-in tools (timestamps, memory, chat history, notes, web search, knowledge bases) per model via the model editor's capabilities settings. [Commit](https://github.com/open-webui/open-webui/commit/60e916d6c0c5f7db9e6d670e12be2d1d4abc2dd6)
- 👁️ Pending tool calls are now displayed during response generation, so users know which tools are being invoked. [Commit](https://github.com/open-webui/open-webui/commit/1d08376860e775049abd1dd5f568ac0c6466c944)
- 📁 Administrators can now limit the number of files that can be uploaded to folders using the "FOLDER_MAX_FILE_COUNT" setting, preventing resource exhaustion from bulk uploads. [#19810](https://github.com/open-webui/open-webui/issues/19810), [Commit](https://github.com/open-webui/open-webui/commit/a1036e544d573e3d35e05c1c2472ba762c32431b), [Commit](https://github.com/open-webui/open-webui/commit/d3ee3fd23e762c9d83fe1da5636d03259e186e57)
- ⚡ Users experience transformative speed improvements across the entire application through completely reengineered database connection handling, delivering noticeably faster page loads, butter-smooth interactions, and rock-solid stability during intensive operations like user management and bulk data processing. [Commit](https://github.com/open-webui/open-webui/commit/2041ab483e21b3a757baa25c47dc2fa29018674f), [Commit](https://github.com/open-webui/open-webui/commit/145c7516f227ce56fd52373cee86217fadf16181), [Commit](https://github.com/open-webui/open-webui/commit/475dd91ed798f2efcdf27799d5c5cae3f0e6e847), [Commit](https://github.com/open-webui/open-webui/commit/5d1459df166cce8445eb1556cc26abbd65a3f9f4), [Commit](https://github.com/open-webui/open-webui/commit/2453b75ff0fb2dc75b929d96f417d84e332922d9), [Commit](https://github.com/open-webui/open-webui/commit/5649a668fad15393a52c27a2f188841af8b66989)
- 🚀 Users experience significantly faster initial page load times through dynamic loading of document processing libraries, reducing the initial bundle size. [#20200](https://github.com/open-webui/open-webui/pull/20200), [#20202](https://github.com/open-webui/open-webui/pull/20202), [#20203](https://github.com/open-webui/open-webui/pull/20203), [#20204](https://github.com/open-webui/open-webui/pull/20204)
- 💨 Administrators experience dramatically faster user list loading through optimized database queries that eliminate N+1 query patterns, reducing query count from 1+N to just 2 total queries regardless of user count. [#20427](https://github.com/open-webui/open-webui/pull/20427)
- 📋 Notes now load faster through optimized database queries that batch user lookups instead of fetching each note's author individually. [Commit](https://github.com/open-webui/open-webui/commit/084f0ef6a5491e186bf6b71c6386973ba18ef2fa)
- 💬 Channel messages, pinned messages, and thread replies now load faster through batched user lookups instead of individual queries per message. [#20458](https://github.com/open-webui/open-webui/pull/20458), [#20459](https://github.com/open-webui/open-webui/pull/20459), [#20460](https://github.com/open-webui/open-webui/pull/20460)
- 🔗 Users can now click citation content links to jump directly to the relevant portion of source documents with automatic text highlighting, making it easier to verify AI responses against their original sources. [#20116](https://github.com/open-webui/open-webui/pull/20116), [Commit](https://github.com/open-webui/open-webui/commit/40c45ffe1f9b45538d32c8ecba8cac62c6eca503)
- 📌 Users can now pin or hide models directly from the Workspace Models page and Admin Settings Models page, making it easier to manage which models appear in the sidebar without switching to the chat interface. [#20176](https://github.com/open-webui/open-webui/pull/20176)
- 🔎 Administrators can now quickly find settings using the new search bar in the Admin Settings sidebar, which supports fuzzy filtering by category names and related keywords like "whisper" for Audio or "rag" for Documents. [#20434](https://github.com/open-webui/open-webui/pull/20434)
- 🎛️ Users can now view read-only models in the workspace models list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20369](https://github.com/open-webui/open-webui/pull/20369)
- 📝 Users can now view read-only prompts in the workspace prompts list, with clear "Read Only" badges indicating when editing is restricted. [#20368](https://github.com/open-webui/open-webui/pull/20368)
- 🔧 Users can now view read-only tools in the workspace tools list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20370](https://github.com/open-webui/open-webui/pull/20370)
- 📂 Searching for files is now significantly faster, especially for users with large file collections. [Commit](https://github.com/open-webui/open-webui/commit/a9a979fb3db1743553ca0705f571c0b9c252841f)
- 🏆 The Evaluations leaderboard now calculates Elo ratings on the backend instead of in the browser, improving performance and enabling topic-based model ranking through semantic search. [#15392](https://github.com/open-webui/open-webui/pull/15392), [#20476](https://github.com/open-webui/open-webui/issues/20476), [Commit](https://github.com/open-webui/open-webui/commit/10838b3654bf6fdef02d57311f7f1c01df4cd033)
- 📊 The Evaluations leaderboard now includes a per-model activity chart displaying daily wins and losses as a diverging bar chart, with 30-day, 1-year, and all-time views using weekly aggregation for longer timeframes.
- 🎞️ Users can now upload animated GIF and WebP formats as model profile images, with animation preserved by skipping resize processing for these file types. [Commit](https://github.com/open-webui/open-webui/commit/00af37bb4ed1ea0957c7c84a6a8def3a7998b8ca)
- 📸 Users uploading profile images for users, models, and arena models now benefit from WebP compression at 80% quality instead of JPEG, resulting in significantly smaller file sizes and faster uploads while maintaining visual quality. [Commit](https://github.com/open-webui/open-webui/commit/b1d30673b69571e081abb881d34944cf33cdc67e)
- ⭐ Action Function developers can now update message favorite status using the new "chat:message:favorite" event, enabling the development of pin/unpin message actions without race conditions from frontend auto-save. [#20375](https://github.com/open-webui/open-webui/pull/20375)
- 🌐 Users with OpenAI-compatible models that have web search capabilities now see URL citations displayed as sources in the interface. [#20172](https://github.com/open-webui/open-webui/pull/20172), [Commit](https://github.com/open-webui/open-webui/commit/fe84afd09a2bc8a89311f30186ec2608a4edda3a)
- 📰 Users can now dismiss the "What's New" changelog modal permanently using the X button, matching the behavior of the "Okay, Let's Go!" button. [#20258](https://github.com/open-webui/open-webui/pull/20258)
- 📧 Administrators can now configure the admin contact email displayed in the Account Pending overlay directly from the Admin Panel instead of only through environment variables. [#12500](https://github.com/open-webui/open-webui/issues/12500), [#20260](https://github.com/open-webui/open-webui/pull/20260)
- 📄 Administrators can now enable markdown header text splitting as a preprocessing step that works with either character or token splitting, through the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" setting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6)
- 🧩 Administrators can now set a minimum chunk size target using the "CHUNK_MIN_SIZE_TARGET" setting to merge small markdown header chunks with neighbors, which improves retrieval quality by eliminating tiny meaningless fragments, significantly speeds up document processing and embedding, reduces storage costs, and lowers embedding API costs or local compute requirements. [#19595](https://github.com/open-webui/open-webui/issues/19595), [#20314](https://github.com/open-webui/open-webui/pull/20314), [Commit](https://github.com/open-webui/open-webui/commit/c32435958073cf002d87e78544baa88bc4e15d7f)
- 💨 Administrators can now enable KV prefix caching optimization by setting "RAG_SYSTEM_CONTEXT" to true, which injects RAG context into the system message instead of user messages, enabling models to reuse cached tokens for follow-up questions instead of reprocessing the entire context on each turn, significantly improving response times and reducing costs for cloud-based models. [#20301](https://github.com/open-webui/open-webui/discussions/20301), [#20317](https://github.com/open-webui/open-webui/pull/20317)
- 🖼️ Administrators and Action developers can now control image generation denoising steps per-request using a steps parameter, allowing Actions and API calls to override the global IMAGE_STEPS configuration for both ComfyUI and Automatic1111 engines. [#20337](https://github.com/open-webui/open-webui/pull/20337)
- 🗄️ Administrators running multi-pod deployments can now designate a master pod to handle database migrations using the "ENABLE_DB_MIGRATIONS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/9824f0e33359a917ac07b60bf1f972074d5c8203)
- 🎙️ Administrators can now configure Whisper's compute type using the "WHISPER_COMPUTE_TYPE" environment variable to fix compatibility issues with CUDA/GPU deployments. [Commit](https://github.com/open-webui/open-webui/commit/26af1f92e21ddfd08348570bf54a6f345ac69648)
- 🔍 Administrators can now control sigmoid normalization for CrossEncoder reranking models using the "SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION" environment variable, enabled by default for proper relevance threshold behavior with MS MARCO models. [#20228](https://github.com/open-webui/open-webui/pull/20228)
- 🔒 Administrators can now disable SSL certificate verification for external tools using the "REQUESTS_VERIFY" environment variable, enabling integration with self-signed certificates for Tika, Ollama embeddings, and external rerankers. [#19968](https://github.com/open-webui/open-webui/issues/19968), [Commit](https://github.com/open-webui/open-webui/commit/dfc5dad63167eabb7fb027e63c324675b23f2e9d)
- 📈 Administrators can now control audit log output destinations using "ENABLE_AUDIT_STDOUT" and "ENABLE_AUDIT_LOGS_FILE" environment variables, allowing audit logs to be sent to container logs for centralized logging systems. [#20114](https://github.com/open-webui/open-webui/pull/20114), [Commit](https://github.com/open-webui/open-webui/commit/fdae5644e36972384b3e2513e3074f95f9f7381f)
- 🛡️ Administrators can now restrict non-admin user access to Interface Settings through per-user or per-group permissions. [#20424](https://github.com/open-webui/open-webui/pull/20424)
- 🧠 Administrators can now globally enable or disable the Memories feature and control access through per-user or per-group permissions, with the Personalization tab automatically hidden when the feature is disabled. [#20462](https://github.com/open-webui/open-webui/pull/20462)
- 🟢 Administrators can now globally enable or disable user status visibility through the "ENABLE_USER_STATUS" setting in Admin Settings. [#20488](https://github.com/open-webui/open-webui/pull/20488)
- 🪝 Channel managers can now create webhooks to allow external services to post messages to channels without authentication. [Commit](https://github.com/open-webui/open-webui/commit/cd296fcf0d79cecd1a6a3ee4e492c6b5246ca7ae)
- 📄 In the model editor users can now disable the "File Context" capability to skip automatic file content extraction and injection, forwarding raw messages with file attachment metadata instead for use with custom tools or future built-in file access tools. [Commit](https://github.com/open-webui/open-webui/commit/daccf0713e3ecd6d24f003a87b5f8b3c61852958), [Docs:Commit](https://github.com/open-webui/docs/commit/18ec6eaefc071a278ec57d4d1b8d66d686af0870)
- 🔊 In the model editor users can now configure a specific TTS voice for each model, overriding user preferences and global defaults to give different AI personas distinct voices. [#3097](https://github.com/open-webui/open-webui/issues/3097), [Commit](https://github.com/open-webui/open-webui/commit/bb6188abf04302f79d80b0d6cc42c232624b5757)
- 👥 Administrators now have three granular group sharing permission options instead of a simple on/off toggle, allowing them to choose between "No one", "Members", or "Anyone" for who can share content to each group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8)
- 📦 Administrators can now export knowledge bases as zip files containing text files for backup and archival purposes. [#20120](https://github.com/open-webui/open-webui/issues/20120), [Commit](https://github.com/open-webui/open-webui/commit/c1147578c073a8c7fa7e7f836149e1cdfec8f18d)
- 🚀 Administrators can now create an admin account automatically at startup via "WEBUI_ADMIN_EMAIL", "WEBUI_ADMIN_PASSWORD", and "WEBUI_ADMIN_NAME" environment variables, enabling headless and automated deployments without exposing the signup page. [#17654](https://github.com/open-webui/open-webui/issues/17654), [Commit](https://github.com/open-webui/open-webui/commit/1138929f4d083931305f1f925899971b190562ae)
- 🦆 Administrators can now select a specific search backend for DDGS instead of random selection, with options including Bing, Brave, DuckDuckGo, Google, Wikipedia, Yahoo, and others. [#20330](https://github.com/open-webui/open-webui/issues/20330), [#20366](https://github.com/open-webui/open-webui/pull/20366)
- 🧭 Administrators can now configure custom Jina Search API endpoints using the "JINA_API_BASE_URL" environment variable, enabling region-specific deployments such as EU data processing. [#19718](https://github.com/open-webui/open-webui/pull/19718), [Commit](https://github.com/open-webui/open-webui/commit/f7f8a263b92289df8d4f8dbc3bae09bd009a5699)
- 🔥 Administrators can now configure Firecrawl timeout values using the "FIRECRAWL_TIMEOUT" environment variable to control web scraping wait times. [#19973](https://github.com/open-webui/open-webui/pull/19973), [Commit](https://github.com/open-webui/open-webui/commit/89ad1c68d1aadf849960b5e202aa4651096b05f5)
- 💾 Administrators can now use openGauss as the vector database backend for knowledge base document storage and retrieval. [#20179](https://github.com/open-webui/open-webui/pull/20179)
- 🔄 Various improvements were implemented across the application to enhance performance, stability, and security.
- 📊 Users can now sync their anonymous usage statistics to the Open WebUI Community platform to power community leaderboards, drive model evaluations, and contribute to open-source AI research that benefits everyone, all while keeping conversations completely private (only metadata like model names, message counts, and ratings are shared). By sharing your stats, you're helping the community identify which models perform best, contributing to transparent AI benchmarking, and supporting the collective effort to make AI better for all. You can also download your stats as JSON for personal analysis.
- 🌐 Translations for German, Portuguese (Brazil), Spanish, Simplified Chinese, Traditional Chinese, and Polish were enhanced and expanded.
### Fixed
- 🔊 Text-to-speech now correctly splits on newlines in addition to punctuation, so markdown bullet points and lists are spoken as separate sentences instead of being merged together. [#5924](https://github.com/open-webui/open-webui/issues/5924), [Commit](https://github.com/open-webui/open-webui/commit/869108a3e1ce2b8110084113c1b392072e98fd5f)
- 🔒 Users are now protected from stored XSS vulnerabilities in iFrame embeds for citations and response messages through configurable same-origin sandbox settings instead of hardcoded values. [#20209](https://github.com/open-webui/open-webui/pull/20209), [#20210](https://github.com/open-webui/open-webui/pull/20210)
- 🔑 Image Generation, Web Search, and Audio (TTS/STT) API endpoints now enforce permission checks on the backend, closing a security gap where disabled features could previously be accessed via direct API calls. [#20471](https://github.com/open-webui/open-webui/pull/20471)
- 🛠️ Tools and Tool Servers (MCP and OpenAPI) now enforce access control checks on the backend, ensuring users can only access tools they have permission to use even via direct API calls. [#20443](https://github.com/open-webui/open-webui/issues/20443), [Commit](https://github.com/open-webui/open-webui/commit/9b06fdc8fe1c933071610336be05f11e77e6c8eb)
- 🔁 System prompts are no longer duplicated when using native function calling, fixing an issue where the prompt would be applied twice during tool-calling workflows. [Commit](https://github.com/open-webui/open-webui/commit/9223efaff0db6e56bfa157ef214d9590005156d2)
- 🗂️ Knowledge base uploads to folders no longer fail when "FOLDER_MAX_FILE_COUNT" is unset, fixing an issue where the default null value caused all uploads to error. [Commit](https://github.com/open-webui/open-webui/commit/ef9cd0e0ad6e45b8a3efec6f3858b3d69d42f619)
- 📝 The "Create Note" button in the chat input now correctly hides for users without Notes permissions instead of showing and returning a 401 error when clicked. [#20486](https://github.com/open-webui/open-webui/issues/20486), [Commit](https://github.com/open-webui/open-webui/commit/9e9616b670c1c4389193b18500a7d80d86d7e280)
- 📊 The Evaluations page no longer crashes when administrators have large amounts of feedback data, as the leaderboard now fetches only the minimal required fields instead of loading entire conversation snapshots. [#20476](https://github.com/open-webui/open-webui/issues/20476), [#20489](https://github.com/open-webui/open-webui/pull/20489), [Commit](https://github.com/open-webui/open-webui/commit/b2a1f71d920e55b143f1c02e61104938d2588762)
- 💬 Users can now export chats, use the Ask/Explain popup, and view chat lists correctly again after these features were broken by recent refactoring changes that caused 500 and 400 server errors. [#20146](https://github.com/open-webui/open-webui/issues/20146), [#20205](https://github.com/open-webui/open-webui/issues/20205), [#20206](https://github.com/open-webui/open-webui/issues/20206), [#20212](https://github.com/open-webui/open-webui/pull/20212)
- 💭 Users no longer experience data corruption when switching between chats during background operations like image generation, where messages from one chat would appear in another chat's history. [#20266](https://github.com/open-webui/open-webui/pull/20266)
- 🛡️ Users no longer encounter critical chat stability errors, including duplicate key errors from circular message dependencies, null message access during chat loading, and errors in the chat overview visualization. [#20268](https://github.com/open-webui/open-webui/pull/20268)
- 📡 Users with Channels no longer experience infinite recursion and connection pool exhaustion when fetching threaded replies, preventing RecursionError crashes during chat history loading. [#20299](https://github.com/open-webui/open-webui/pull/20299), [Commit](https://github.com/open-webui/open-webui/commit/c144122f608759c2b79472e1f6948a7c1600a3d1)
- 📎 Users no longer encounter TypeError crashes when viewing messages with file attachments that have undefined URL properties. [#20343](https://github.com/open-webui/open-webui/pull/20343)
- 🔐 Users with MCP integrations now experience reliable OAuth 2.1 token refresh after access token expiration through proper Protected Resource discovery, preventing integration failures that caused sessions to be deleted. [#19794](https://github.com/open-webui/open-webui/issues/19794), [#20138](https://github.com/open-webui/open-webui/pull/20138), [#20291](https://github.com/open-webui/open-webui/issues/20291), [Commit](https://github.com/open-webui/open-webui/commit/bf2b2962399e341926bdbf9e0a82101f31a90b23), [Commit](https://github.com/open-webui/open-webui/commit/89565c58c6ae6b5b129559ef68b5a0c18c110765)
- 📚 Users who belong to multiple groups can now see Knowledge Bases shared with those groups, fixing an issue where they would disappear when shared with more than one group. [#20124](https://github.com/open-webui/open-webui/issues/20124), [#20229](https://github.com/open-webui/open-webui/issues/20229), [Commit](https://github.com/open-webui/open-webui/commit/61e25dc2dce9c12dcb5b88a6b814060c4338e67b)
- 📂 Users now see the correct Knowledge Base name when hovering over # file references in chat input instead of "undefined". [#20329](https://github.com/open-webui/open-webui/issues/20329), [#20333](https://github.com/open-webui/open-webui/pull/20333)
- 📋 Users now see notes displayed in correct chronological order within their time range groupings, fixing an issue where insertion order was not preserved. [Commit](https://github.com/open-webui/open-webui/commit/3f577c0c3fbfd9f09c02940e4ae474f987149277)
- 📑 Users collaborating on notes now experience proper content sync when initializing from both HTML and JSON formats, fixing sync failures in collaborative editing sessions. [Commit](https://github.com/open-webui/open-webui/commit/e27fb3e291a735c715a089a80e7a49d2c2209096)
- 🔎 Users searching notes can now find hyphenated words and variations with spaces, so searching "todo" now finds "to-do" and "to do". [Commit](https://github.com/open-webui/open-webui/commit/a3270648d8b8535443d8ce2ea719f8e678e4e358)
- 📥 Users no longer experience false duplicate file warnings when reuploading files after initial processing failed, as the file hash is now only stored after successful processing completion. [#19264](https://github.com/open-webui/open-webui/issues/19264), [#20282](https://github.com/open-webui/open-webui/pull/20282), [Commit](https://github.com/open-webui/open-webui/commit/d3ab9f4b96eee7f91c9b1355cee055fdabca9730)
- 💾 Users experience significantly improved page load performance as model profile images now cache properly in browsers, avoiding unnecessary image refetches. [Commit](https://github.com/open-webui/open-webui/commit/bb821ab654e93908a3b4632c359753eeff053264)
- 🎨 Users can now successfully edit uploaded images instead of having new images generated, fixing an issue introduced by the file storage refactor where images with type "file" and content_type starting with "image/" weren't being recognized as editable images. [#20237](https://github.com/open-webui/open-webui/issues/20237), [#20169](https://github.com/open-webui/open-webui/pull/20169), [#20239](https://github.com/open-webui/open-webui/pull/20239), [Commit](https://github.com/open-webui/open-webui/commit/1148d1c927d096e14917b6d762789fca3188f281)
- 🌐 Users writing in Persian and Arabic now see properly displayed right-to-left text in the notes section through automatic text direction detection. [#19743](https://github.com/open-webui/open-webui/issues/19743), [#20102](https://github.com/open-webui/open-webui/pull/20102), [Commit](https://github.com/open-webui/open-webui/commit/b619a157bc54c5bc44d223d2ae3acb9ce4ac6a6c)
- 🤖 Users can now successfully @ mention models in Channels instead of experiencing silent failures. [Commit](https://github.com/open-webui/open-webui/commit/59957715836acb635f4b1c4ddbfb4ba7b82b3281)
- 📋 Users on Windows now see correctly preserved line breaks when using the {{CLIPBOARD}} variable through CRLF to LF normalization. [#19370](https://github.com/open-webui/open-webui/issues/19370), [#20283](https://github.com/open-webui/open-webui/pull/20283)
- 📁 Users now see the Knowledge Selector dropdown correctly displayed above the Create Folder modal instead of being hidden behind it. [#20219](https://github.com/open-webui/open-webui/issues/20219), [#20213](https://github.com/open-webui/open-webui/pull/20213)
- 🌅 Users now see profile images in non-PNG formats like SVG, JPEG, and GIF displayed correctly instead of appearing broken. [#20171](https://github.com/open-webui/open-webui/pull/20171)
- 🆕 Non-admin users with disabled temporary chat permissions can now successfully create new chats and use pinned models from the sidebar. [#20336](https://github.com/open-webui/open-webui/issues/20336), [#20367](https://github.com/open-webui/open-webui/pull/20367), [Commit](https://github.com/open-webui/open-webui/commit/e754940c031f9689fb4f6edb3625aa06aeb53377)
- 🎛️ Users can now successfully use workspace models in chat, fixing "Model not found" errors that occurred when using custom model presets. [#20340](https://github.com/open-webui/open-webui/issues/20340), [#20344](https://github.com/open-webui/open-webui/pull/20344), [Commit](https://github.com/open-webui/open-webui/commit/b55a46ae99c32068ed306a5ecdaafa9f75504cd7), [Commit](https://github.com/open-webui/open-webui/commit/2bb13d5dbc6e233856e8aa26143222ceda8f6c11)
- 🔁 Users can now regenerate messages without crashes when the parent message is missing or corrupted in the chat history. [#20264](https://github.com/open-webui/open-webui/pull/20264)
- ✏️ Users no longer experience TipTap rich text editor crashes with "editor view is not available" errors when plugins or async methods try to access the editor after it has been destroyed. [#20266](https://github.com/open-webui/open-webui/pull/20266)
- 📗 Administrators with bypass access control enabled now correctly have write access to all knowledge bases. [#20371](https://github.com/open-webui/open-webui/pull/20371)
- 🔍 Administrators using local CrossEncoder reranking models now see proper relevance threshold behavior through MS MARCO model score normalization to the 0-1 range via sigmoid activation. [#19999](https://github.com/open-webui/open-webui/issues/19999), [#20228](https://github.com/open-webui/open-webui/pull/20228)
- 🎯 Administrators using local SentenceTransformers embedding engine now benefit from proper batch size settings, preventing excessive memory usage from the default batch size of 32. [#20053](https://github.com/open-webui/open-webui/issues/20053), [#20054](https://github.com/open-webui/open-webui/pull/20054), [Commit](https://github.com/open-webui/open-webui/commit/e4a5b06ca68303512678b4d2dc296bc78b9f983f)
- 🔧 Administrators and users in offline mode or restricted environments like uv, poetry, and NixOS no longer experience crashes when Tools and Functions have frontmatter requirements, as pip installation is now skipped when offline mode is enabled. [#20320](https://github.com/open-webui/open-webui/issues/20320), [#20321](https://github.com/open-webui/open-webui/pull/20321), [Commit](https://github.com/open-webui/open-webui/commit/bd07ef8)
- 📄 Administrators can now properly configure the MinerU document parsing service as the MinerU Cloud API key field is now available in the Admin Panel Documents settings. [#20319](https://github.com/open-webui/open-webui/issues/20319), [#20328](https://github.com/open-webui/open-webui/pull/20328)
- ⚠️ Administrators no longer see SyntaxWarnings for invalid escape sequences in password validation regex patterns. [#20298](https://github.com/open-webui/open-webui/pull/20298), [Commit](https://github.com/open-webui/open-webui/commit/e55bf2c2ac391caed871d41f0484820091081908)
- 🎨 Users with ComfyUI workflows now see only the intended final output images in chat instead of duplicate images from intermediate processing nodes like masks, crops, or segmentation previews. [#20158](https://github.com/open-webui/open-webui/issues/20158), [#20182](https://github.com/open-webui/open-webui/pull/20182)
- 🖼️ Users with image generation enabled no longer see false vision capability warnings, allowing them to send follow-up messages after generating images and to send images to non-vision models for image editing. [#20129](https://github.com/open-webui/open-webui/issues/20129), [#20256](https://github.com/open-webui/open-webui/pull/20256)
- 🔌 Administrators no longer experience infinite loading screens when invalid or MCP-style configurations are used with OpenAPI connection types for external tools. [#20207](https://github.com/open-webui/open-webui/issues/20207), [#20257](https://github.com/open-webui/open-webui/pull/20257)
- 📥 Administrators no longer encounter TypeError crashes during SHA256 verification when uploading GGUF models via URL, fixing 500 Internal Server Error crashes. [#20263](https://github.com/open-webui/open-webui/issues/20263)
- 🚦 Users with Brave Search now experience automatic retry with a 1-second delay when hitting rate limits, preventing failures when sequential requests exceed the 1 request per second limit, though this only works reliably when web search concurrency is set to a maximum of 1. [#15134](https://github.com/open-webui/open-webui/issues/15134), [#20255](https://github.com/open-webui/open-webui/pull/20255)
- 🗄️ Administrators with Redis Sentinel deployments no longer experience crashes during websocket disconnections due to improper async-generator handling in the YDocManager. [#20142](https://github.com/open-webui/open-webui/issues/20142), [#20145](https://github.com/open-webui/open-webui/pull/20145)
- 🔐 Administrators using SCIM group management no longer encounter 500 errors when working with groups that have no members. [#20187](https://github.com/open-webui/open-webui/pull/20187)
- 🔗 Users now experience more reliable citations from AI models, especially when using smaller or weaker models that may not format citation references perfectly. [Commit](https://github.com/open-webui/open-webui/commit/c0ec04935b4eea3d334bfdec2fc41278f1085a49)
- 🕸️ Administrators can now successfully save WebSearch settings without encountering validation errors for domain filter lists, YouTube language settings, or timeout values. [#20422](https://github.com/open-webui/open-webui/pull/20422)
- 📦 Administrators installing with the uv package manager now experience successful installation after deprecated dependencies that were causing conflicts were removed. [#20177](https://github.com/open-webui/open-webui/issues/20177), [#20192](https://github.com/open-webui/open-webui/pull/20192)
- ⏱️ Administrators using custom "AIOHTTP_CLIENT_TIMEOUT" settings now see the configured timeout correctly applied to embedding generation, OAuth discovery, webhook calls, and tool/function loading instead of falling back to the default 300-second timeout. [Commit](https://github.com/open-webui/open-webui/commit/e67891a374625d9888ec391da561f0b4ed79ed5d)
### Changed
- ⚠️ This release includes a major overhaul of database connection handling in the backend that requires all instances in multi-worker, multi-server, or load-balanced deployments to be updated simultaneously; running mixed versions will cause failures due to incompatible database connection management between old and new instances.
- 📝 Administrators who previously used the standalone "Markdown (Header)" text splitter must now switch to "character" or "token" mode with the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" toggle enabled, as document chunking now applies markdown header splitting as a preprocessing step before character or token splitting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6)
- 🖼️ Users no longer see the "Generate Image" action button in chat message interfaces; custom function should be used. [Commit](https://github.com/open-webui/open-webui/commit/f0829ba6e6fd200702fb76efc43dd785cf87fec9)
- 🔗 Administrators will find the Admin Evaluations page at the new URL "/admin/evaluations/feedback" instead of "/admin/evaluations/feedbacks" to use the correct uncountable form of the word. [#20296](https://github.com/open-webui/open-webui/pull/20296)
- 🔐 Scripts or integrations that directly called Image Generation, Web Search, or Audio APIs while those features were disabled in the Admin UI will now receive 403 Forbidden errors, as backend permission enforcement has been added to match frontend restrictions. [#20471](https://github.com/open-webui/open-webui/pull/20471)
- 👥 The default group sharing permission changed from "Members" to "Anyone", meaning users can now share content to any group configured with "Anyone" permission regardless of their membership in that group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8)
## [0.6.43] - 2025-12-22
### Fixed
- 🐍 **Python dependency installation issues** were resolved by correcting pip dependency handling, preventing installation failures in certain environments and improving setup reliability. [Commit](https://github.com/open-webui/open-webui/commit/5c5f87a)
- 🎙️ **Speech-to-Text default content type handling** was fixed and refactored to ensure correct MIME type usage, improving compatibility across STT providers and preventing transcription errors caused by incorrect defaults. [Commit](https://github.com/open-webui/open-webui/commit/4ab917c)
- 🖼️ **Temporary chat image handling** was fixed and refactored, ensuring images generated or edited in temporary chats are correctly processed, stored, and displayed without inconsistencies or missing references. [Commit](https://github.com/open-webui/open-webui/commit/423983f)
- 🎨 **Image action button fixed**, restoring the ability to trigger image generation, editing, and related image actions from the chat UI. [Commit](https://github.com/open-webui/open-webui/commit/def8a00)
## [0.6.42] - 2025-12-21
### Added
- 📚 Knowledge base file management was overhauled with server-side pagination loading 30 files at a time instead of loading entire collections at once, dramatically improving performance and responsiveness for large knowledge bases with hundreds or thousands of files, reducing initial load times and memory usage while adding server-side search and filtering, view options for files added by the user versus shared files, customizable sorting by name or date, and file authorship tracking with upload timestamps. [Commit](https://github.com/open-webui/open-webui/commit/94a8439105f30203ea9d729787c9c5978f5c22a2)
- ✨ Knowledge base file management was enhanced with automatic list refresh after file operations ensuring immediate UI updates, improved permission validation at the model layer, and automatic channel-file association for files uploaded with channel metadata. [Commit](https://github.com/open-webui/open-webui/commit/c15201620d03a9b60b800a34d8dc3426722c5b8b)
- 🔎 Knowledge command in chat input now uses server-side search for massive performance increases when selecting knowledge bases and files. [Commit](https://github.com/open-webui/open-webui/commit/0addc1ea461d7b4eee8fe0ca2fedd615b3988b0e)
- 🗂️ Knowledge workspace listing now uses server-side pagination loading 30 collections at a time with new search endpoints supporting query filtering and view options for created versus shared collections. [Commit](https://github.com/open-webui/open-webui/commit/ceae3d48e603f53313d5483abe94099e20e914e8)
- 📖 Knowledge workspace now displays all collections with read access including shared read-only collections, enabling users to discover and explore knowledge bases they don't own while maintaining proper access controls through visual "Read Only" badges and automatically disabled editing controls for name, description, file uploads, content editing, and deletion operations. [Commit](https://github.com/open-webui/open-webui/commit/693636d971d0e8398fa0c9ec3897686750007af5)
- 📁 Bulk website and YouTube video attachment now supports adding multiple URLs at once (newline-separated) with automatic YouTube detection and transcript retrieval, processed sequentially to prevent resource strain, and both websites and videos can now be added directly to knowledge bases through the workspace UI. [Commit](https://github.com/open-webui/open-webui/commit/7746e9f4b831f09953ad2b659b96e0fd52911031), [#6202](https://github.com/open-webui/open-webui/issues/6202), [#19587](https://github.com/open-webui/open-webui/pull/19587), [#8231](https://github.com/open-webui/open-webui/pull/8231)
- 🪟 Sidebar width is now resizable on desktop devices with persistent storage in localStorage, enforcing minimum and maximum width constraints (220px to 480px) while all layout components now reference the dynamic sidebar width via CSS variables for consistent responsive behavior. [Commit](https://github.com/open-webui/open-webui/commit/b364cf43d3e8fd3557f65f17bc285bfaca5ed368)
- 📝 Notes feature now supports server-side search and filtering with view options for notes created by the user versus notes shared with them, customizable sorting by name or date in both list and grid view modes within a redesigned interface featuring consolidated note management controls in a unified header, group-based permission sharing with read, write, and read-only access control displaying note authorship and sharing status for better collaboration, and paginated infinite scroll for improved performance with large note collections. [Commit](https://github.com/open-webui/open-webui/commit/9b24cddef6c4862bd899eb8d6332cafff54e871d)
- 👁️ Notes now support read-only access permissions, allowing users to share notes for viewing without granting edit rights, with the editor automatically becoming non-editable and appropriate UI indicators when read-only access is detected. [Commit](https://github.com/open-webui/open-webui/commit/4363df175d50e0f9729381ac2ba9b37a3c3a966d)
- 📄 Notes can now be created directly from the chat input field, allowing users to save drafted messages or content as notes without navigation or retyping. [Commit](https://github.com/open-webui/open-webui/commit/00c2b6ca405d617e3d7520953a00a36c19c790ec)
- 🪟 Sidebar folders, channels, and pinned models sections now automatically expand when creating new items or pinning models, providing immediate visual feedback for user actions. [Commit](https://github.com/open-webui/open-webui/commit/f826d3ed75213a0a1b31b50d030bfb1d5e91d199), [#19929](https://github.com/open-webui/open-webui/pull/19929)
- 📋 Chat file associations are now properly tracked in the database through a new "chat_file" table, enabling accurate file management across chats and ensuring proper cleanup of files when chats are deleted, while improving database consistency in multi-node deployments. [Commit](https://github.com/open-webui/open-webui/commit/f1bf4f20c53e6493f0eb6fa2f12cb84c2d22da52)
- 🖼️ User-uploaded images are now automatically converted from base64 to actual file storage on the server, eliminating large inline base64 strings from being stored in chat history and reducing message payload sizes while enabling better image management and sharing across multiple chats. [Commit](https://github.com/open-webui/open-webui/commit/f1bf4f20c53e6493f0eb6fa2f12cb84c2d22da52)
- 📸 Shared chats with generated or edited images now correctly display images when accessed by other users by properly linking generated images to their chat and message through the chat_file table, ensuring images remain accessible in shared chat links. [Commit](https://github.com/open-webui/open-webui/commit/446cc0ac6063402a743e949f50612376ed5a8437), [#19393](https://github.com/open-webui/open-webui/issues/19393)
- 📊 File viewer modal was significantly enhanced with native-like viewers for Excel/CSV spreadsheets rendering as interactive scrollable tables with multi-sheet navigation support, Markdown documents displaying with full typography including headers, lists, links, and tables, and source code files showing syntax highlighting, all accessible through a tabbed interface defaulting to raw text view. [#20035](https://github.com/open-webui/open-webui/pull/20035), [#2867](https://github.com/open-webui/open-webui/issues/2867)
- 📏 Chat input now displays an expand button in the top-right corner when messages exceed two lines, providing optional access to a full-screen editor for composing longer messages with enhanced workspace and visibility while temporarily disabling the main input to prevent editing conflicts. [Commit](https://github.com/open-webui/open-webui/commit/205c7111200c22da42e9b5fe1e676aec9cca6daa)
- 💬 Channel message data lazy loading was implemented, deferring attachment and file metadata retrieval until needed to improve initial message list load performance. [Commit](https://github.com/open-webui/open-webui/commit/54b7ec56d6bcd2d79addc1694b757dab18cf18c5)
- 🖼️ Channel image upload handling was optimized to process and store compressed images directly as files rather than inline data, improving memory efficiency and message load times. [Commit](https://github.com/open-webui/open-webui/commit/22f1b764a7ea1add0a896906a9ef00b4b6743adc)
- 🎥 Video file playback support was added to channel messages, enabling inline video viewing with native player controls. [Commit](https://github.com/open-webui/open-webui/commit/7b126b23d50a0bd36a350fe09dc1dbe3df105318)
- 🔐 LDAP authentication now supports user entries with multiple username attributes, correctly handling cases where the username field contains a list of values. [Commit](https://github.com/open-webui/open-webui/commit/379f888c9dc6dce21c3ef7a1fc455258aff993dc), [#19878](https://github.com/open-webui/open-webui/issues/19878)
- 👨‍👩‍👧‍👦 The "ENABLE_PUBLIC_ACTIVE_USERS_COUNT" environment variable now allows restricting active user count visibility to administrators, reducing backend load and addressing privacy concerns in large deployments. [#20027](https://github.com/open-webui/open-webui/pull/20027), [#13026](https://github.com/open-webui/open-webui/issues/13026)
- 🚀 Models page search input performance was optimized with a 300ms debounce to reduce server load and improve responsiveness. [#19832](https://github.com/open-webui/open-webui/pull/19832)
- 💨 Frontend performance was optimized by preventing unnecessary API calls for API Keys and Channels features when they are disabled in admin settings, reducing backend noise and improving overall system efficiency. [#20043](https://github.com/open-webui/open-webui/pull/20043), [#19967](https://github.com/open-webui/open-webui/issues/19967)
- 📎 Channel file association tracking was implemented, automatically linking uploaded files to their respective channels with a dedicated association table enabling better organization and future file management features within channels. [Commit](https://github.com/open-webui/open-webui/commit/2bccf8350d0915f69b8020934bb179c52e81b7b5)
- 👥 User profile previews now display group membership information for easier identification of user roles and permissions. [Commit](https://github.com/open-webui/open-webui/commit/2b1a29d44bde9fbc20ff9f0a5ded1ce8ded9d90d)
- 🌍 The "SEARXNG_LANGUAGE" environment variable now allows configuring search language for SearXNG queries, replacing the hardcoded "en-US" default with a configurable setting that defaults to "all". [#19909](https://github.com/open-webui/open-webui/pull/19909)
- ⏳ The "MINERU_API_TIMEOUT" environment variable now allows configuring request timeouts for MinerU document processing operations. [#20016](https://github.com/open-webui/open-webui/pull/20016), [#18495](https://github.com/open-webui/open-webui/issues/18495)
- 🔧 The "RAG_EXTERNAL_RERANKER_TIMEOUT" environment variable now allows configuring request timeouts for external reranker operations. [#20049](https://github.com/open-webui/open-webui/pull/20049), [#19900](https://github.com/open-webui/open-webui/issues/19900)
- 🎨 OpenAI GPT-IMAGE 1.5 model support was added for image generation and editing with automatic image size capabilities. [Commit](https://github.com/open-webui/open-webui/commit/4c2e5c93e9287479f56f780708656136849ccaee)
- 🔑 The "OAUTH_AUDIENCE" environment variable now allows OAuth providers to specify audience parameters for JWT access token generation. [#19768](https://github.com/open-webui/open-webui/pull/19768)
- ⏰ The "REDIS_SOCKET_CONNECT_TIMEOUT" environment variable now allows configuring socket connection timeouts for Redis and Sentinel connections, addressing potential failover and responsiveness issues in distributed deployments. [#19799](https://github.com/open-webui/open-webui/pull/19799), [Docs:#882](https://github.com/open-webui/docs/pull/882)
- ⏱️ The "WEB_LOADER_TIMEOUT" environment variable now allows configuring request timeouts for SafeWebBaseLoader operations. [#19804](https://github.com/open-webui/open-webui/pull/19804), [#19734](https://github.com/open-webui/open-webui/issues/19734)
- 🚀 Models API endpoint performance was optimized through batched model loading, eliminating N+1 queries and significantly reducing response times when filtering models by user permissions. [Commit](https://github.com/open-webui/open-webui/commit/0dd2cfe1f273fbacdbe90300a97c021f2e678656)
- 🔀 Custom model fallback handling was added, allowing workspace-created custom models to automatically fall back to the default chat model when their configured base model is not found; set "ENABLE_CUSTOM_MODEL_FALLBACK" to true to enable, preventing workflow disruption when base models are removed or renamed, while ensuring other requests remain unaffected. [Commit](https://github.com/open-webui/open-webui/commit/b35aeb8f46e0e278c6f4538382c2b6838e24cc5a), [#19985](https://github.com/open-webui/open-webui/pull/19985)
- 📡 A new /feedbacks/all/ids API endpoint was added to return only feedback IDs without metadata, significantly improving performance for external integrations working with large feedback collections. [Commit](https://github.com/open-webui/open-webui/commit/53c1ca64b7205d85f6de06bd69e3e265d15546b8)
- 📈 An experimental chat usage statistics endpoint (GET /api/v1/chats/stats/usage) was added with pagination support (50 chats per page) and comprehensive per-chat analytics including model usage counts, user and assistant message breakdowns, average response times calculated from message timestamps, average content lengths, and last activity timestamps; this endpoint remains experimental and not suitable for production use as it performs intensive calculations by processing entire message histories for each chat without caching. [Commit](https://github.com/open-webui/open-webui/commit/a7993f6f4e4591cd2aaa4718ece9e5623557d019)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for German, Danish, Finnish, Korean, Portuguese (Brazil), Simplified Chinese, Traditional Chinese, Catalan, and Spanish were enhanced and expanded.
### Fixed
- ⚡ External reranker operations were optimized to prevent event loop blocking by offloading synchronous HTTP requests to a thread pool using asyncio.to_thread(), eliminating application freezes during RAG reranking queries. [#20049](https://github.com/open-webui/open-webui/pull/20049), [#19900](https://github.com/open-webui/open-webui/issues/19900)
- 💭 Text loss in the explanation feature when using the "CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE" environment variable was resolved by correcting newline handling in streaming responses. [#19829](https://github.com/open-webui/open-webui/pull/19829)
- 📚 Knowledge base batch file addition failures caused by Pydantic validation errors are now prevented by making the meta field optional in file metadata responses, allowing files without metadata to be processed correctly. [#20022](https://github.com/open-webui/open-webui/pull/20022), [#14220](https://github.com/open-webui/open-webui/issues/14220)
- 🗄️ PostgreSQL null byte insertion failures when attaching web pages or processing embedded content are now prevented by consolidating text sanitization logic across chat messages, web search results, and knowledge base documents, removing null bytes and invalid UTF-8 surrogates before database insertion. [#20072](https://github.com/open-webui/open-webui/pull/20072), [#19867](https://github.com/open-webui/open-webui/issues/19867), [#18201](https://github.com/open-webui/open-webui/issues/18201), [#15616](https://github.com/open-webui/open-webui/issues/15616)
- 🎫 MCP OAuth 2.1 token exchange failures are now fixed by removing duplicate credential passing that caused "ID1,ID1" concatenation and 401 errors from the token endpoint. [#20076](https://github.com/open-webui/open-webui/pull/20076), [#19823](https://github.com/open-webui/open-webui/issues/19823)
- 📝 Notes "Improve" action now works correctly after the streaming API change in v0.6.41 by ensuring uploaded files are fully retrieved with complete metadata before processing, restoring note improvement and summarization functionality. [Commit](https://github.com/open-webui/open-webui/commit/a3458f492c53a3b00405f59fbe1ea953fe364f18), [#20078](https://github.com/open-webui/open-webui/discussions/20078)
- 🔑 MCP OAuth 2.1 tool servers now work correctly in multi-node deployments through lazy-loading of OAuth clients from Redis-synced configuration, eliminating 404 errors when load balancers route requests to nodes that didn't process the original config update. [#20076](https://github.com/open-webui/open-webui/pull/20076), [#19902](https://github.com/open-webui/open-webui/pull/19902), [#19901](https://github.com/open-webui/open-webui/issues/19901)
- 🧩 Chat loading failures when channels permissions were disabled are now prevented through graceful error handling. [Commit](https://github.com/open-webui/open-webui/commit/5c2df97f04cce5cb7087d288f816f91a739688c1)
- 🔍 Search bar freezing and crashing issues in Models, Chat, and Archived Chat pages caused by excessively long queries exceeding server URL limits were resolved by truncating queries to 500 characters, and knowledge base layout shifting with long names was fixed by adjusting flex container properties. [#19832](https://github.com/open-webui/open-webui/pull/19832)
- 🎛️ Rate limiting errors (HTTP 429) with Brave Search free tier when generating multiple queries are now prevented through asyncio.Semaphore-based concurrency control applied globally to all search engines. [#20070](https://github.com/open-webui/open-webui/pull/20070), [#20003](https://github.com/open-webui/open-webui/issues/20003), [#14107](https://github.com/open-webui/open-webui/issues/14107), [#15134](https://github.com/open-webui/open-webui/issues/15134)
- 💥 UI crashes and white screen errors caused by null chat lists during loading or network failures were prevented by adding null safety checks to chat iteration in folder placeholders and archived chat modals. [#19898](https://github.com/open-webui/open-webui/pull/19898)
- 🧩 Chat overview tab crashes caused by undefined model references were resolved by adding proper null checks when accessing deleted or ejected models. [#19935](https://github.com/open-webui/open-webui/pull/19935)
- 🔄 MultiResponseMessages component crashes when navigating chat history after removing or changing selected models are now prevented through proper component re-initialization. [Commit](https://github.com/open-webui/open-webui/commit/870e29e3738da968c396b70532f365a3c2f71995), [#18599](https://github.com/open-webui/open-webui/issues/18599)
- 🚫 Channel API endpoint access is now correctly blocked when channels are globally disabled, preventing users with channel permissions from accessing channel data via API requests when the feature is turned off in admin settings. [#19957](https://github.com/open-webui/open-webui/pull/19957), [#19914](https://github.com/open-webui/open-webui/issues/19914)
- 👤 User list popup display in the admin panel was fixed to correctly track user identity when sorting or filtering changes the list order, preventing popups from showing incorrect user information. [Commit](https://github.com/open-webui/open-webui/commit/ae47101dc6aef2c7d8ae0d843985341fff820057), [#20046](https://github.com/open-webui/open-webui/issues/20046)
- 👥 User selection in the "Edit User Group" modal now preserves pagination position, allowing administrators to select multiple users across pages without resetting to page 1. [#19959](https://github.com/open-webui/open-webui/pull/19959)
- 📸 Model avatar images now update immediately in the admin models list through proper Cache-Control headers, eliminating the need for manual cache clearing. [#19959](https://github.com/open-webui/open-webui/pull/19959)
- 🔒 Temporary chat permission enforcement now correctly prevents users from enabling the feature through personal settings when disabled in default or group permissions. [#19785](https://github.com/open-webui/open-webui/issues/19785)
- 🎨 Image editing with reference images now correctly uses both previously generated images and newly uploaded reference images. [Commit](https://github.com/open-webui/open-webui/commit/bcd50ed8f1b7387fd700538ae0d74fc72f3c53d0)
- 🧠 Image generation and editing operations are now explicitly injected into system context, improving LLM comprehension even for weaker models so they reliably acknowledge operations instead of incorrectly claiming they cannot generate images. [Commit](https://github.com/open-webui/open-webui/commit/28b2fcab0cd036dbe646a66fe81890f288c77121)
- 📑 Source citation rendering errors when citation syntax appeared in user messages or contexts without source data were resolved. [Commit](https://github.com/open-webui/open-webui/commit/3c8f1cf8e58d52e86375634b0381374298b1b4f3)
- 📄 DOCX file parsing now works correctly in temporary chats through client-side text extraction, preventing raw data from being displayed. [Commit](https://github.com/open-webui/open-webui/commit/6993b0b40b10af8cdbe6626702cc94080fff9e22)
- 🔧 Pipeline settings save failures when valve properties contain null values are now handled correctly. [#19791](https://github.com/open-webui/open-webui/pull/19791)
- ⚙️ Model usage settings are now correctly preserved when switching between models instead of being unexpectedly cleared or reset. [#19868](https://github.com/open-webui/open-webui/pull/19868), [#19549](https://github.com/open-webui/open-webui/issues/19549)
- 🛡️ Invalid PASSWORD_VALIDATION_REGEX_PATTERN configurations no longer cause startup warnings, with automatic fallback to the default pattern when regex compilation fails. [#20058](https://github.com/open-webui/open-webui/pull/20058)
- 🎯 The DefaultFiltersSelector component in model settings now correctly displays when only global toggleable filters are present, enabling per-model default configuration. [#20066](https://github.com/open-webui/open-webui/pull/20066)
- 🎤 Audio file upload failures caused by MIME type matching issues with spacing variations and codec parameters were resolved by implementing proper MIME type parsing. [#17771](https://github.com/open-webui/open-webui/pull/17771), [#17761](https://github.com/open-webui/open-webui/issues/17761)
- ⌨️ Regenerate response keyboard shortcut now only activates when chat input is selected, preventing unintended regeneration when modals are open or other UI elements are focused. [#19875](https://github.com/open-webui/open-webui/pull/19875)
- 📋 Log truncation issues in Docker deployments during application crashes were resolved by disabling Python stdio buffering, ensuring complete diagnostic output is captured. [#19844](https://github.com/open-webui/open-webui/issues/19844)
- 🔴 Redis cluster compatibility issues with disabled KEYS command were resolved by replacing blocking KEYS operations with production-safe SCAN iterations. [#19871](https://github.com/open-webui/open-webui/pull/19871), [#15834](https://github.com/open-webui/open-webui/issues/15834)
- 🔤 File attachment container layout issues when using RTL languages were resolved by applying chat direction settings to file containers across all message types. [#19891](https://github.com/open-webui/open-webui/pull/19891), [#19742](https://github.com/open-webui/open-webui/issues/19742)
- 🔃 Ollama model list now automatically refreshes after model deletion, preventing deleted models from persisting in the UI and being inadvertently re-downloaded during subsequent pull operations. [#19912](https://github.com/open-webui/open-webui/pull/19912)
- 🌐 Ollama Cloud web search now correctly applies domain filtering to search results. [Commit](https://github.com/open-webui/open-webui/commit/d4bd938a77c22409a1643c058b937a06e07baca9)
- 📜 Tool specification serialization now preserves non-ASCII characters including Chinese text, improving LLM comprehension and tool selection accuracy by avoiding Unicode escape sequences. [#19942](https://github.com/open-webui/open-webui/pull/19942)
- 🛟 Model editor stability was improved with null safety checks for tools, functions, and file input operations, preventing crashes when stores are undefined or file objects are invalid. [#19939](https://github.com/open-webui/open-webui/pull/19939)
- 🗣️ MoA completion handling stability was improved with null safety checks for response objects, boolean casting for settings, and proper timeout type definitions. [#19921](https://github.com/open-webui/open-webui/pull/19921)
- 🎛️ Chat functionality failures caused by empty logit_bias parameter values are now prevented by properly handling empty strings in the parameter parsing middleware. [#19982](https://github.com/open-webui/open-webui/issues/19982)
- 🔏 Administrators can now delete read-only knowledge bases from deleted users, resolving permission issues that previously prevented cleanup of orphaned read-only content. [Commit](https://github.com/open-webui/open-webui/commit/59d6eb2badf46f9c2b1e879484ac33432915b575)
- 💾 Cloned prompts and tools now correctly preserve their access control settings instead of being reset to null, preventing unintended visibility changes when duplicating private or restricted items. [#19960](https://github.com/open-webui/open-webui/pull/19960), [#19360](https://github.com/open-webui/open-webui/issues/19360)
- 🎚️ Text scale adjustment buttons in Interface Settings were fixed to correctly increment and decrement the scale value. [#19699](https://github.com/open-webui/open-webui/pull/19699)
- 🎭 Group channel invite button text visibility in light theme was corrected to display properly against dark backgrounds. [#19828](https://github.com/open-webui/open-webui/issues/19828)
- 📁 The move button is now hidden when no folders exist, preventing display of non-functional controls. [#19705](https://github.com/open-webui/open-webui/pull/19705)
- 📦 Qdrant client dependency was updated to resolve startup version incompatibility warnings. [#19757](https://github.com/open-webui/open-webui/pull/19757)
- 🧮 The "ENABLE_ASYNC_EMBEDDING" environment variable is now correctly applied to embedding operations when configured exclusively via environment variables. [#19748](https://github.com/open-webui/open-webui/pull/19748)
- 🌄 The "COMFYUI_WORKFLOW_NODES" and "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES" environment variables are now correctly loaded and parsed as JSON lists, and the configuration key name was corrected from "COMFYUI_WORKFLOW" to "COMFYUI_WORKFLOW_NODES". [#19918](https://github.com/open-webui/open-webui/pull/19918), [#19886](https://github.com/open-webui/open-webui/issues/19886)
- 💫 Channel name length is now limited to 128 characters with validation to prevent display issues caused by excessively long names. [Commit](https://github.com/open-webui/open-webui/commit/f509f5542dde384d34402f6df763f49a06bea109)
- 🔐 Invalid PASSWORD_VALIDATION_REGEX_PATTERN configurations no longer cause startup warnings, with automatic fallback to the default pattern when regex compilation fails. [#20058](https://github.com/open-webui/open-webui/pull/20058)
- 🔎 Bocha search with filter list functionality now works correctly by returning results as a list instead of a dictionary wrapper, ensuring compatibility with result filtering operations. [Commit](https://github.com/open-webui/open-webui/commit/b5bd8704fe1672da839bb3be6210d7cb494797ce), [#19733](https://github.com/open-webui/open-webui/issues/19733)
### Changed
- ⚠️ This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
- 📡 WEB_SEARCH_CONCURRENT_REQUESTS default changed from 10 to 0 (unlimited) — This setting now applies to all search engines instead of only DuckDuckGo; previously users were implicitly limited to 10 concurrent queries, but now have unlimited parallel requests by default; set to 1 for sequential execution if using rate-limited APIs like Brave free tier. [#20070](https://github.com/open-webui/open-webui/pull/20070)
- 💾 SQLCipher absolute path handling was fixed to properly support absolute database paths (e.g., "/app/data.db") instead of incorrectly stripping leading slashes and converting them to relative paths; this restores functionality for Docker volume mounts and explicit absolute path configurations while maintaining backward compatibility with relative paths. [#20074](https://github.com/open-webui/open-webui/pull/20074)
- 🔌 Knowledge base file listing API was redesigned with paginated responses and new filtering parameters; the GET /knowledge/{id}/files endpoint now returns paginated results with user attribution instead of embedding all files in the knowledge object, which may require updates to custom integrations or scripts accessing knowledge base data programmatically. [Commit](https://github.com/open-webui/open-webui/commit/94a8439105f30203ea9d729787c9c5978f5c22a2)
- 🗑️ Legacy knowledge base support for deprecated document collections and tag-based collections was removed; users with pre-knowledge base documents must migrate to the current knowledge base system as legacy items will no longer appear in selectors or command menus. [Commit](https://github.com/open-webui/open-webui/commit/a934dc997ed67a036dd7975e380f8036c447d3ed)
- 🔨 Source-level log environment variables (AUDIO_LOG_LEVEL, CONFIG_LOG_LEVEL, MODELS_LOG_LEVEL, etc.) were removed as they provided limited configuration options and added significant complexity across 100+ files; the GLOBAL_LOG_LEVEL environment variable, which already took precedence over source-level settings, now serves as the exclusive logging configuration method. [#20045](https://github.com/open-webui/open-webui/pull/20045)
- 🐍 LangChain was upgraded to version 1.2.0, representing a major dependency update and significant progress toward Python 3.13 compatibility while improving RAG pipeline functionality for document loading and retrieval operations. [#19991](https://github.com/open-webui/open-webui/pull/19991)
## [0.6.41] - 2025-12-02
### Added
- 🚦 Sign-in rate limiting was implemented to protect against brute force attacks, limiting login attempts to 15 per 3-minute window per email address using Redis with automatic fallback to in-memory storage when Redis is unavailable. [Commit](https://github.com/open-webui/open-webui/commit/7b166370432414ce8f186747fb098e0c70fb2d6b)
- 📂 Administrators can now globally disable the folders feature and control user-level folder permissions through the admin panel, enabling minimalist interface configurations for deployments that don't require workspace organization features. [#19529](https://github.com/open-webui/open-webui/pull/19529), [#19210](https://github.com/open-webui/open-webui/discussions/19210), [#18459](https://github.com/open-webui/open-webui/discussions/18459), [#18299](https://github.com/open-webui/open-webui/discussions/18299)
- 👥 Group channels were introduced as a new channel type enabling membership-based collaboration spaces where users explicitly join as members rather than accessing through permissions, with support for public or private visibility, automatic member inclusion from specified user groups, member role tracking with invitation metadata, and post-creation member management allowing channel managers to add or remove members through the channel info modal. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4), [Commit](https://github.com/open-webui/open-webui/commit/3f1d9ccbf8443a2fa5278f36202bad930a216680)
- 💬 Direct Message channels were introduced with a dedicated channel type selector and multi-user member selection interface, enabling private conversations between specific users without requiring full channel visibility. [Commit](https://github.com/open-webui/open-webui/commit/64b4d5d9c280b926746584aaf92b447d09deb386)
- 📨 Direct Message channels now support a complete user-to-user messaging system with member-based access control, automatic deduplication for one-on-one conversations, optional channel naming, and distinct visual presentation using participant avatars instead of channel icons. [Commit](https://github.com/open-webui/open-webui/commit/acccb9afdd557274d6296c70258bb897bbb6652f)
- 🙈 Users can now hide Direct Message channels from their sidebar while preserving message history, with automatic reactivation when new messages arrive from other participants, providing a cleaner interface for managing active conversations. [Commit](https://github.com/open-webui/open-webui/commit/acccb9afdd557274d6296c70258bb897bbb6652f)
- ☑️ A comprehensive user selection component was added to the channel creation modal, featuring search functionality, sortable user lists, pagination support, and multi-select checkboxes for building Direct Message participant lists. [Commit](https://github.com/open-webui/open-webui/commit/acccb9afdd557274d6296c70258bb897bbb6652f)
- 🔴 Channel unread message count tracking was implemented with visual badge indicators in the sidebar, automatically updating counts in real-time and marking messages as read when users view channels, with join/leave functionality to manage membership status. [Commit](https://github.com/open-webui/open-webui/commit/64b4d5d9c280b926746584aaf92b447d09deb386)
- 📌 Message pinning functionality was added to channels, allowing users to pin important messages for easy reference with visual highlighting, a dedicated pinned messages modal accessible from the navbar, and complete backend support for tracking pinned status, pin timestamp, and the user who pinned each message. [Commit](https://github.com/open-webui/open-webui/commit/64b4d5d9c280b926746584aaf92b447d09deb386), [Commit](https://github.com/open-webui/open-webui/commit/aae2fce17355419d9c29f8100409108037895201)
- 🟢 Direct Message channels now display an active status indicator for one-on-one conversations, showing a green dot when the other participant is currently online or a gray dot when offline. [Commit](https://github.com/open-webui/open-webui/commit/4b6773885cd7527c5a56b963781dac5e95105eec), [Commit](https://github.com/open-webui/open-webui/commit/39645102d14f34e71b34e5ddce0625790be33f6f)
- 🆔 Users can now start Direct Message conversations directly from user profile previews by clicking the "Message" button, enabling quick access to private messaging without navigating away from the current channel. [Commit](https://github.com/open-webui/open-webui/commit/a0826ec9fedb56320532616d568fa59dda831d4e)
- ⚡ Channel messages now appear instantly when sent using optimistic UI rendering, displaying with a pending state while the server confirms delivery, providing a more responsive messaging experience. [Commit](https://github.com/open-webui/open-webui/commit/25994dd3da90600401f53596d4e4fb067c1b8eaa)
- 👍 Channel message reactions now display the names of users who reacted when hovering over the emoji, showing up to three names with a count for additional reactors. [Commit](https://github.com/open-webui/open-webui/commit/05e79bdd0c7af70b631e958924e3656db1013b80)
- 🛠️ Channel creators can now edit and delete their own group and DM channels without requiring administrator privileges, enabling users to manage the channels they create independently. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4)
- 🔌 A new API endpoint was added to directly get or create a Direct Message channel with a specific user by their ID, streamlining programmatic DM channel creation for integrations and frontend workflows. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4)
- 💭 Users can now set a custom status with an emoji and message that displays in profile previews, the sidebar user menu, and Direct Message channel items in the sidebar, with the ability to clear status at any time, providing visibility into availability or current focus similar to team communication platforms. [Commit](https://github.com/open-webui/open-webui/commit/51621ba91a982e52da168ce823abffd11ad3e4fa), [Commit](https://github.com/open-webui/open-webui/commit/f5e8d4d5a004115489c35725408b057e24dfe318)
- 📤 A group export API endpoint was added, enabling administrators to export complete group data including member lists for backup and migration purposes. [Commit](https://github.com/open-webui/open-webui/commit/09b6ea38c579659f8ca43ae5ea3746df3ac561ad)
- 📡 A new API endpoint was added to retrieve all users belonging to a specific group, enabling programmatic access to group membership information for administrative workflows. [Commit](https://github.com/open-webui/open-webui/commit/01868e856a10f474f74fbd1b4425dafdf949222f)
- 👁️ The admin user list now displays an active status indicator next to each user, showing a visual green dot for users who have been active within the last three minutes. [Commit](https://github.com/open-webui/open-webui/commit/1b095d12ff2465b83afa94af89ded9593f8a8655)
- 🔑 The admin user edit modal now displays OAuth identity information with a per-provider breakdown, showing each linked identity provider and its associated subject identifier separately. [#19573](https://github.com/open-webui/open-webui/pull/19573)
- 🧩 OAuth role claim parsing now respects the "OAUTH_ROLES_SEPARATOR" configuration, enabling proper parsing of roles returned as comma-separated strings and providing consistent behavior with group claim handling. [#19514](https://github.com/open-webui/open-webui/pull/19514)
- 🎛️ Channel feature access can now be controlled through both the "USER_PERMISSIONS_FEATURES_CHANNELS" environment variable and group permission toggles in the admin panel, allowing administrators to restrict channel functionality for specific users or groups while defaulting to enabled for all users. [Commit](https://github.com/open-webui/open-webui/commit/f589b7c1895a6a77166c047891acfa21bc0936c4)
- 🎨 The model editor interface was refined with access control settings moved to a dedicated modal, group member counts now displayed when configuring permissions, reorganized layout with improved visual hierarchy, and redesigned prompt suggestions cards with tooltips for field guidance. [Commit](https://github.com/open-webui/open-webui/commit/e65d92fc6f49da5ca059e1c65a729e7973354b99), [Commit](https://github.com/open-webui/open-webui/commit/9d39b9b42c653ee2acf2674b2df343ecbceb4954)
- 🏗️ Knowledge base file management was rebuilt with a dedicated database table replacing the previous JSON array storage, enabling pagination support for large knowledge bases, significantly faster file listing performance, and more reliable file-knowledge base relationship tracking. [Commit](https://github.com/open-webui/open-webui/commit/d19023288e2ca40f86e2dc3fd9f230540f3e70d7)
- ☁️ Azure Document Intelligence model selection was added, allowing administrators to specify which model to use for document processing via the "DOCUMENT_INTELLIGENCE_MODEL" environment variable or admin UI setting, with "prebuilt-layout" as the default. [#19692](https://github.com/open-webui/open-webui/pull/19692), [Docs:#872](https://github.com/open-webui/docs/pull/872)
- 🚀 Milvus multitenancy vector database performance was improved by removing manual flush calls after upsert operations, eliminating rate limit errors and reducing load on etcd and MinIO/S3 storage by allowing Milvus to manage segment persistence automatically via its WAL and auto-flush policies. [#19680](https://github.com/open-webui/open-webui/pull/19680)
- ✨ Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌍 Translations for German, French, Portuguese (Brazil), Catalan, Simplified Chinese, and Traditional Chinese were enhanced and expanded.
### Fixed
- 🔄 Tool call response token duplication was fixed by removing redundant message history additions in non-native function calling mode, resolving an issue where tool results were included twice in the context and causing 2x token consumption. [#19656](https://github.com/open-webui/open-webui/issues/19656), [Commit](https://github.com/open-webui/open-webui/commit/52ccab8)
- 🛡️ Web search domain filtering was corrected to properly block results when any resolved hostname or IP address matches a blocked domain, preventing blocked sites from appearing in search results due to permissive hostname resolution logic that previously allowed results through if any single resolved address passed the filter. [#19670](https://github.com/open-webui/open-webui/pull/19670), [#19669](https://github.com/open-webui/open-webui/issues/19669)
- 🧠 Custom models based on Ollama or OpenAI now properly inherit the connection type from their base model, ensuring they appear correctly in the "Local" or "External" model selection tabs instead of only appearing under "All". [#19183](https://github.com/open-webui/open-webui/issues/19183), [Commit](https://github.com/open-webui/open-webui/commit/39f7575)
- 🐍 SentenceTransformers embedding initialization was fixed by updating the transformers dependency to version 4.57.3, resolving a regression in v0.6.40 where document ingestion failed with "'NoneType' object has no attribute 'encode'" errors due to a bug in transformers 4.57.2. [#19512](https://github.com/open-webui/open-webui/issues/19512), [#19513](https://github.com/open-webui/open-webui/pull/19513)
- 📈 Active user count accuracy was significantly improved by replacing the socket-based USER_POOL tracking with a database-backed heartbeat mechanism, resolving long-standing issues where Redis deployments displayed inflated user counts due to stale sessions never being cleaned up on disconnect. [#16074](https://github.com/open-webui/open-webui/discussions/16074), [Commit](https://github.com/open-webui/open-webui/commit/70948f8803e417459d5203839f8077fdbfbbb213)
- 👥 Default group assignment now applies consistently across all user registration methods including OAuth/SSO, LDAP, and admin-created users, fixing an issue where the "DEFAULT_GROUP_ID" setting was only being applied to users who signed up via the email/password signup form. [#19685](https://github.com/open-webui/open-webui/pull/19685)
- 🔦 Model list filtering in workspaces was corrected to properly include models shared with user groups, ensuring members can view models they have write access to through group permissions. [#19461](https://github.com/open-webui/open-webui/issues/19461), [Commit](https://github.com/open-webui/open-webui/commit/69722ba973768a5f689f2e2351bf583a8db9bba8)
- 🖼️ User profile image display in preview contexts was fixed by resolving a Pydantic validation error that prevented proper rendering. [Commit](https://github.com/open-webui/open-webui/commit/c7eb7136893b0ddfdc5d55ffc7a05bd84a00f5d6)
- 🔒 Redis TLS connection failures were resolved by updating the python-socketio dependency to version 5.15.0, restoring support for the "rediss://" URL schema. [#19480](https://github.com/open-webui/open-webui/issues/19480), [#19488](https://github.com/open-webui/open-webui/pull/19488)
- 📝 MCP tool server configuration was corrected to properly handle the "Function Name Filter List" as both string and list types, preventing AttributeError when the field is empty and ensuring backward compatibility. [#19486](https://github.com/open-webui/open-webui/issues/19486), [Commit](https://github.com/open-webui/open-webui/commit/c5b73d71843edc024325d4a6e625ec939a747279), [Commit](https://github.com/open-webui/open-webui/commit/477097c2e42985c14892301d0127314629d07df1)
- 📎 Web page attachment failures causing TypeError on metadata checks were resolved by correcting async threadpool parameter passing in vector database operations. [#19493](https://github.com/open-webui/open-webui/issues/19493), [Commit](https://github.com/open-webui/open-webui/commit/4370dee79e19d77062c03fba81780cb3b779fca3)
- 💾 Model allowlist persistence in multi-worker deployments was fixed by implementing Redis-based shared state for the internal models dictionary, ensuring configuration changes are consistently visible across all worker processes. [#19395](https://github.com/open-webui/open-webui/issues/19395), [Commit](https://github.com/open-webui/open-webui/commit/b5e5617d7f7ad3e4eec9f15f4cc7f07cb5afc2fa)
- ⏳ Chat history infinite loading was prevented by enhancing message data structure to properly track parent message relationships, resolving issues where missing parentId fields caused perpetual loading states. [#19225](https://github.com/open-webui/open-webui/issues/19225), [Commit](https://github.com/open-webui/open-webui/commit/ff4b1b9862d15adfa15eac17d2ce066c3d8ae38f)
- 🩹 Database migration robustness was improved by automatically detecting and correcting missing primary key constraints on the user table, ensuring successful schema upgrades for databases with non-standard configurations. [#19487](https://github.com/open-webui/open-webui/discussions/19487), [Commit](https://github.com/open-webui/open-webui/commit/453ea9b9a167c0b03d86c46e6efd086bf10056ce)
- 🏷️ OAuth group assignment now updates correctly on first login when users transition from admin to user role, ensuring group memberships reflect immediately when group management is enabled. [#19475](https://github.com/open-webui/open-webui/issues/19475), [#19476](https://github.com/open-webui/open-webui/pull/19476)
- 💡 Knowledge base file tooltips now properly display the parent collection name when referencing files with the hash symbol, preventing confusion between identically-named files in different collections. [#19491](https://github.com/open-webui/open-webui/issues/19491), [Commit](https://github.com/open-webui/open-webui/commit/3fe5a47b0ff84ac97f8e4ff56a19fa2ec065bf66)
- 🔐 Knowledge base file access inconsistencies were resolved where authorized non-admin users received "Not found" or permission errors for certain files due to race conditions during upload causing mismatched collection_name values, with file access validation now properly checking against knowledge base file associations. [#18689](https://github.com/open-webui/open-webui/issues/18689), [#19523](https://github.com/open-webui/open-webui/pull/19523), [Commit](https://github.com/open-webui/open-webui/commit/e301d1962e45900ababd3eabb7e9a2ad275a5761)
- 📦 Knowledge API batch file addition endpoint was corrected to properly handle async operations, resolving 500 Internal Server Error responses when adding multiple files simultaneously. [#19538](https://github.com/open-webui/open-webui/issues/19538), [Commit](https://github.com/open-webui/open-webui/commit/28659f60d94feb4f6a99bb1a5b54d7f45e5ea10f)
- 🤖 Embedding model auto-update functionality was fixed to properly respect the "RAG_EMBEDDING_MODEL_AUTO_UPDATE" setting by correctly passing the flag to the model path resolver, ensuring models update as expected when the auto-update option is enabled. [#19687](https://github.com/open-webui/open-webui/pull/19687)
- 📉 API response payload sizes were dramatically reduced by removing base64-encoded profile images from most endpoints, eliminating multi-megabyte responses caused by high-resolution avatars and enabling better browser caching. [#19519](https://github.com/open-webui/open-webui/issues/19519), [Commit](https://github.com/open-webui/open-webui/commit/384753c4c17f62a68d38af4bbcf55a21ee08e0f2)
- 📞 Redundant API calls on the admin user overview page were eliminated by consolidating reactive statements, reducing four duplicate requests to a single efficient call and significantly improving page load performance. [#19509](https://github.com/open-webui/open-webui/issues/19509), [Commit](https://github.com/open-webui/open-webui/commit/9f89cc5e9f7e1c6c9e2bc91177e08df7c79f66f9)
- 🧹 Duplicate API calls on the workspace models page were eliminated by removing redundant model list fetching, reducing two identical requests to a single call and improving page responsiveness. [#19517](https://github.com/open-webui/open-webui/issues/19517), [Commit](https://github.com/open-webui/open-webui/commit/d1bbf6be7a4d1d53fa8ad46ca4f62fc4b2e6a8cb)
- 🔘 The model valves button was corrected to prevent unintended form submission by adding explicit button type attribute, ensuring it no longer triggers message sending when the input area contains text. [#19534](https://github.com/open-webui/open-webui/pull/19534)
- 🗑️ Ollama model deletion was fixed by correcting the request payload format and ensuring the model selector properly displays the placeholder option. [Commit](https://github.com/open-webui/open-webui/commit/0f3156651c64bc5af188a65fc2908bdcecf30c74)
- 🎨 Image generation in temporary chats was fixed by correctly handling local chat sessions that are not persisted to the database. [Commit](https://github.com/open-webui/open-webui/commit/a7c7993bbf3a21cb7ba416525b89233cf2ad877f)
- 🕵️‍♂️ Audit logging was fixed by correctly awaiting the async user authentication call, resolving failures where coroutine objects were passed instead of user data. [#19658](https://github.com/open-webui/open-webui/pull/19658), [Commit](https://github.com/open-webui/open-webui/commit/dba86bc)
- 🌙 Dark mode select dropdown styling was corrected to use proper background colors, fixing an issue where dropdown borders and hover states appeared white instead of matching the dark theme. [#19693](https://github.com/open-webui/open-webui/pull/19693), [#19442](https://github.com/open-webui/open-webui/issues/19442)
- 🔍 Milvus vector database query filtering was fixed by correcting string quote handling in filter expressions and using the proper parameter name for queries, resolving false "duplicate content detected" errors that prevented uploading multiple files to knowledge bases. [#19602](https://github.com/open-webui/open-webui/pull/19602), [#18119](https://github.com/open-webui/open-webui/issues/18119), [#16345](https://github.com/open-webui/open-webui/issues/16345), [#17088](https://github.com/open-webui/open-webui/issues/17088), [#18485](https://github.com/open-webui/open-webui/issues/18485)
- 🆙 Milvus multitenancy vector database was updated to use query_iterator() for improved robustness and consistency with the standard Milvus implementation, fixing the same false duplicate detection errors and improving handling of large result sets in multi-tenant deployments. [#19695](https://github.com/open-webui/open-webui/pull/19695)
### Changed
- ⚠️ **IMPORTANT for Multi-Instance Deployments** — This release includes database schema changes; multi-worker, multi-server, or load-balanced deployments must update all instances simultaneously rather than performing rolling updates, as running mixed versions will cause application failures due to schema incompatibility between old and new instances.
- 👮 Channel creation is now restricted to administrators only, with the channel add button hidden for regular users to maintain organizational control over communication channels. [Commit](https://github.com/open-webui/open-webui/commit/421aba7cd7cd708168b1f2565026c74525a67905)
- ➖ The active user count indicator was removed from the bottom-left user menu in the sidebar to streamline the interface. [Commit](https://github.com/open-webui/open-webui/commit/848f3fd4d86ca66656e0ff0335773945af8d7d8d)
- 🗂️ The user table was restructured with API keys migrated to a dedicated table supporting future multi-key functionality, OAuth data storage converted to a JSON structure enabling multiple identity providers per user account, and internal column types optimized from TEXT to JSON for the "info" and "settings" fields, with automatic migration preserving all existing data and associations. [#19573](https://github.com/open-webui/open-webui/pull/19573)
- 🔄 The knowledge base API was restructured to support the new file relationship model.
## [0.6.40] - 2025-11-25
### Fixed
- 🗄️ A critical PostgreSQL user listing performance issue was resolved by removing a redundant count operation that caused severe database slowdowns and potential timeouts when viewing user lists in admin panels.
## [0.6.39] - 2025-11-25
### Added
+17 -5
View File
@@ -13,6 +13,7 @@ ARG USE_CUDA_VER=cu128
# IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them.
ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
ARG USE_RERANKING_MODEL=""
ARG USE_AUXILIARY_EMBEDDING_MODEL=TaylorAI/bge-micro-v2
# Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken
ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base"
@@ -42,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
RUN npm run build
######## WebUI backend ########
FROM python:3.11-slim-bookworm AS base
FROM python:3.11.14-slim-bookworm AS base
# Use args
ARG USE_CUDA
@@ -52,9 +53,13 @@ ARG USE_SLIM
ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL
ARG USE_AUXILIARY_EMBEDDING_MODEL
ARG UID
ARG GID
# Python settings
ENV PYTHONUNBUFFERED=1
## Basis ##
ENV ENV=prod \
PORT=8080 \
@@ -64,7 +69,8 @@ ENV ENV=prod \
USE_SLIM_DOCKER=${USE_SLIM} \
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} \
USE_AUXILIARY_EMBEDDING_MODEL_DOCKER=${USE_AUXILIARY_EMBEDDING_MODEL}
## Basis URL Config ##
ENV OLLAMA_BASE_URL="/ollama" \
@@ -85,6 +91,7 @@ ENV WHISPER_MODEL="base" \
## RAG Embedding model settings ##
ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \
RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \
AUXILIARY_EMBEDDING_MODEL="$USE_AUXILIARY_EMBEDDING_MODEL_DOCKER" \
SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
## Tiktoken model settings ##
@@ -121,7 +128,7 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \
git build-essential pandoc gcc netcat-openbsd curl jq \
python3-dev \
ffmpeg libsm6 libxext6 \
ffmpeg libsm6 libxext6 zstd \
&& rm -rf /var/lib/apt/lists/*
# install python dependencies
@@ -130,18 +137,23 @@ COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install --no-cache-dir uv && \
if [ "$USE_CUDA" = "true" ]; then \
# If you use CUDA the whisper and embedding model will be downloaded on first use
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
# fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
else \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
if [ "$USE_SLIM" != "true" ]; then \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
python -c "import nltk; nltk.download('punkt_tab')"; \
fi; \
fi; \
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
-35
View File
@@ -1,35 +0,0 @@
### Installing Both Ollama and Open WebUI Using Kustomize
For cpu-only pod
```bash
kubectl apply -f ./kubernetes/manifest/base
```
For gpu-enabled pod
```bash
kubectl apply -k ./kubernetes/manifest
```
### Installing Both Ollama and Open WebUI Using Helm
Package Helm file first
```bash
helm package ./kubernetes/helm/
```
For cpu-only pod
```bash
helm install ollama-webui ./ollama-webui-*.tgz
```
For gpu-enabled pod
```bash
helm install ollama-webui ./ollama-webui-*.tgz --set ollama.resources.limits.nvidia.com/gpu="1"
```
Check the `kubernetes/helm/values.yaml` file to know which parameters are available for customization
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
All rights reserved.
Redistribution and use in source and binary forms, with or without
+3 -9
View File
@@ -10,11 +10,13 @@
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
![Open WebUI Banner](./banner.png)
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
![Open WebUI Demo](./demo.gif)
![Open WebUI Demo](./demo.png)
> [!TIP]
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** – **[Speak with Our Sales Team Today!](https://docs.openwebui.com/enterprise)**
@@ -188,14 +190,6 @@ docker run -d --network=host -v open-webui:/app/backend/data -e OLLAMA_BASE_URL=
### Keeping Your Docker Installation Up-to-Date
In case you want to update your local Docker installation to the latest version, you can do it with [Watchtower](https://containrrr.dev/watchtower/):
```bash
docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --run-once open-webui
```
In the last part of the command, replace `open-webui` with your container name if it is different.
Check our Updating Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/updating).
### Using the Dev Branch 🌙
+292 -20
View File
@@ -2,7 +2,9 @@ import json
import logging
import os
import shutil
import socket
import base64
from concurrent.futures import ThreadPoolExecutor
import redis
from datetime import datetime
@@ -19,6 +21,7 @@ from authlib.integrations.starlette_client import OAuth
from open_webui.env import (
DATA_DIR,
DATABASE_URL,
ENABLE_DB_MIGRATIONS,
ENV,
REDIS_URL,
REDIS_KEY_PREFIX,
@@ -67,7 +70,8 @@ def run_migrations():
log.exception(f"Error running migrations: {e}")
run_migrations()
if ENABLE_DB_MIGRATIONS:
run_migrations()
class Config(Base):
@@ -255,7 +259,7 @@ class AppConfig:
self._state[key].value = value
self._state[key].save()
if self._redis:
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f"{self._redis_key_prefix}:config:{key}"
self._redis.set(redis_key, json.dumps(self._state[key].value))
@@ -263,8 +267,8 @@ class AppConfig:
if key not in self._state:
raise AttributeError(f"Config key '{key}' not found")
# If Redis is available, check for an updated value
if self._redis:
# If Redis is available and persistent config is enabled, check for an updated value
if self._redis and ENABLE_PERSISTENT_CONFIG:
redis_key = f"{self._redis_key_prefix}:config:{key}"
redis_value = self._redis.get(redis_key)
@@ -583,14 +587,16 @@ OAUTH_ROLES_CLAIM = PersistentConfig(
os.environ.get("OAUTH_ROLES_CLAIM", "roles"),
)
SEP = os.environ.get("OAUTH_ROLES_SEPARATOR", ",")
OAUTH_ROLES_SEPARATOR = os.environ.get("OAUTH_ROLES_SEPARATOR", ",")
OAUTH_ALLOWED_ROLES = PersistentConfig(
"OAUTH_ALLOWED_ROLES",
"oauth.allowed_roles",
[
role.strip()
for role in os.environ.get("OAUTH_ALLOWED_ROLES", f"user{SEP}admin").split(SEP)
for role in os.environ.get(
"OAUTH_ALLOWED_ROLES", f"user{OAUTH_ROLES_SEPARATOR}admin"
).split(OAUTH_ROLES_SEPARATOR)
if role
],
)
@@ -600,7 +606,9 @@ OAUTH_ADMIN_ROLES = PersistentConfig(
"oauth.admin_roles",
[
role.strip()
for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(SEP)
for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split(
OAUTH_ROLES_SEPARATOR
)
if role
],
)
@@ -625,6 +633,12 @@ OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
== "true"
)
OAUTH_AUDIENCE = PersistentConfig(
"OAUTH_AUDIENCE",
"oauth.audience",
os.environ.get("OAUTH_AUDIENCE", ""),
)
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
@@ -1003,6 +1017,39 @@ if ENV == "prod":
OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434"
def _resolve_ollama_base_url(url: str) -> str:
"""If the default Ollama port (11434) is unreachable, try the fallback port (12434)."""
def reachable(host: str, port: int) -> bool:
try:
with socket.create_connection((host, port), timeout=1.0):
return True
except (OSError, TimeoutError):
return False
host = urlparse(url).hostname or "localhost"
with ThreadPoolExecutor(max_workers=2) as pool:
default = pool.submit(reachable, host, 11434)
fallback = pool.submit(reachable, host, 12434)
if not default.result() and fallback.result():
url = url.replace(":11434", ":12434")
log.info(f"Ollama port 11434 unreachable on {host}, falling back to 12434")
elif not default.result():
log.info(f"Ollama ports 11434 and 12434 both unreachable on {host}")
return url
# Auto-resolve Ollama port when no explicit URL was provided by the user.
# The Dockerfile default is "/ollama" which the block above rewrites to :11434.
if os.environ.get("OLLAMA_BASE_URL", "") in ("", "/ollama") and not os.environ.get(
"OLLAMA_BASE_URLS", ""
):
OLLAMA_BASE_URL = _resolve_ollama_base_url(OLLAMA_BASE_URL)
OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "")
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL
@@ -1253,6 +1300,11 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT", "False").lower()
== "true"
@@ -1296,7 +1348,7 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False"
).lower()
== "true"
)
@@ -1333,10 +1385,21 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
== "true"
)
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING", "False"
).lower()
== "true"
)
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
== "true"
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true"
)
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
@@ -1443,14 +1506,31 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = (
== "true"
)
USER_PERMISSIONS_FEATURES_FOLDERS = (
os.environ.get("USER_PERMISSIONS_FEATURES_FOLDERS", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_NOTES = (
os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_CHANNELS = (
os.environ.get("USER_PERMISSIONS_FEATURES_CHANNELS", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_API_KEYS = (
os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true"
)
USER_PERMISSIONS_FEATURES_MEMORIES = (
os.environ.get("USER_PERMISSIONS_FEATURES_MEMORIES", "True").lower() == "true"
)
USER_PERMISSIONS_SETTINGS_INTERFACE = (
os.environ.get("USER_PERMISSIONS_SETTINGS_INTERFACE", "True").lower() == "true"
)
DEFAULT_USER_PERMISSIONS = {
"workspace": {
@@ -1458,6 +1538,7 @@ DEFAULT_USER_PERMISSIONS = {
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS,
"models_import": USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT,
"models_export": USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT,
"prompts_import": USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT,
@@ -1474,6 +1555,8 @@ DEFAULT_USER_PERMISSIONS = {
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING,
"public_skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING,
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
},
@@ -1499,12 +1582,20 @@ DEFAULT_USER_PERMISSIONS = {
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
},
"features": {
# General features
"api_keys": USER_PERMISSIONS_FEATURES_API_KEYS,
"notes": USER_PERMISSIONS_FEATURES_NOTES,
"folders": USER_PERMISSIONS_FEATURES_FOLDERS,
"channels": USER_PERMISSIONS_FEATURES_CHANNELS,
"direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS,
# Chat features
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
"notes": USER_PERMISSIONS_FEATURES_NOTES,
"memories": USER_PERMISSIONS_FEATURES_MEMORIES,
},
"settings": {
"interface": USER_PERMISSIONS_SETTINGS_INTERFACE,
},
}
@@ -1514,6 +1605,18 @@ USER_PERMISSIONS = PersistentConfig(
DEFAULT_USER_PERMISSIONS,
)
ENABLE_FOLDERS = PersistentConfig(
"ENABLE_FOLDERS",
"folders.enable",
os.environ.get("ENABLE_FOLDERS", "True").lower() == "true",
)
FOLDER_MAX_FILE_COUNT = PersistentConfig(
"FOLDER_MAX_FILE_COUNT",
"folders.max_file_count",
os.environ.get("FOLDER_MAX_FILE_COUNT", ""),
)
ENABLE_CHANNELS = PersistentConfig(
"ENABLE_CHANNELS",
"channels.enable",
@@ -1526,6 +1629,12 @@ ENABLE_NOTES = PersistentConfig(
os.environ.get("ENABLE_NOTES", "True").lower() == "true",
)
ENABLE_USER_STATUS = PersistentConfig(
"ENABLE_USER_STATUS",
"users.enable_status",
os.environ.get("ENABLE_USER_STATUS", "True").lower() == "true",
)
ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
"ENABLE_EVALUATION_ARENA_MODELS",
"evaluation.arena.enable",
@@ -2040,6 +2149,12 @@ ENABLE_CODE_INTERPRETER = PersistentConfig(
os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true",
)
ENABLE_MEMORIES = PersistentConfig(
"ENABLE_MEMORIES",
"memories.enable",
os.environ.get("ENABLE_MEMORIES", "True").lower() == "true",
)
CODE_INTERPRETER_ENGINE = PersistentConfig(
"CODE_INTERPRETER_ENGINE",
"code_interpreter.engine",
@@ -2186,9 +2301,15 @@ ENABLE_QDRANT_MULTITENANCY_MODE = (
QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui")
WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "")
WEAVIATE_GRPC_HOST = os.environ.get("WEAVIATE_GRPC_HOST", "")
WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080"))
WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051"))
WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY")
WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true"
WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true"
WEAVIATE_SKIP_INIT_CHECKS = (
os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true"
)
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@@ -2313,6 +2434,51 @@ else:
except Exception:
PGVECTOR_IVFFLAT_LISTS = 100
# openGauss
OPENGAUSS_DB_URL = os.environ.get("OPENGAUSS_DB_URL", DATABASE_URL)
OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
OPENGAUSS_POOL_SIZE = os.environ.get("OPENGAUSS_POOL_SIZE", None)
if OPENGAUSS_POOL_SIZE != None:
try:
OPENGAUSS_POOL_SIZE = int(OPENGAUSS_POOL_SIZE)
except Exception:
OPENGAUSS_POOL_SIZE = None
OPENGAUSS_POOL_MAX_OVERFLOW = os.environ.get("OPENGAUSS_POOL_MAX_OVERFLOW", 0)
if OPENGAUSS_POOL_MAX_OVERFLOW == "":
OPENGAUSS_POOL_MAX_OVERFLOW = 0
else:
try:
OPENGAUSS_POOL_MAX_OVERFLOW = int(OPENGAUSS_POOL_MAX_OVERFLOW)
except Exception:
OPENGAUSS_POOL_MAX_OVERFLOW = 0
OPENGAUSS_POOL_TIMEOUT = os.environ.get("OPENGAUSS_POOL_TIMEOUT", 30)
if OPENGAUSS_POOL_TIMEOUT == "":
OPENGAUSS_POOL_TIMEOUT = 30
else:
try:
OPENGAUSS_POOL_TIMEOUT = int(OPENGAUSS_POOL_TIMEOUT)
except Exception:
OPENGAUSS_POOL_TIMEOUT = 30
OPENGAUSS_POOL_RECYCLE = os.environ.get("OPENGAUSS_POOL_RECYCLE", 3600)
if OPENGAUSS_POOL_RECYCLE == "":
OPENGAUSS_POOL_RECYCLE = 3600
else:
try:
OPENGAUSS_POOL_RECYCLE = int(OPENGAUSS_POOL_RECYCLE)
except Exception:
OPENGAUSS_POOL_RECYCLE = 3600
# Pinecone
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
@@ -2496,6 +2662,12 @@ MINERU_API_URL = PersistentConfig(
os.environ.get("MINERU_API_URL", "http://localhost:8000"),
)
MINERU_API_TIMEOUT = PersistentConfig(
"MINERU_API_TIMEOUT",
"rag.mineru_api_timeout",
os.environ.get("MINERU_API_TIMEOUT", "300"),
)
MINERU_API_KEY = PersistentConfig(
"MINERU_API_KEY",
"rag.mineru_api_key",
@@ -2568,6 +2740,12 @@ DOCUMENT_INTELLIGENCE_KEY = PersistentConfig(
os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""),
)
DOCUMENT_INTELLIGENCE_MODEL = PersistentConfig(
"DOCUMENT_INTELLIGENCE_MODEL",
"rag.document_intelligence_model",
os.getenv("DOCUMENT_INTELLIGENCE_MODEL", "prebuilt-layout"),
)
MISTRAL_OCR_API_BASE_URL = PersistentConfig(
"MISTRAL_OCR_API_BASE_URL",
"rag.MISTRAL_OCR_API_BASE_URL",
@@ -2688,6 +2866,12 @@ PDF_EXTRACT_IMAGES = PersistentConfig(
os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true",
)
PDF_LOADER_MODE = PersistentConfig(
"PDF_LOADER_MODE",
"rag.pdf_loader_mode",
os.environ.get("PDF_LOADER_MODE", "page"),
)
RAG_EMBEDDING_MODEL = PersistentConfig(
"RAG_EMBEDDING_MODEL",
"rag.embedding_model",
@@ -2763,6 +2947,12 @@ RAG_EXTERNAL_RERANKER_API_KEY = PersistentConfig(
os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""),
)
RAG_EXTERNAL_RERANKER_TIMEOUT = PersistentConfig(
"RAG_EXTERNAL_RERANKER_TIMEOUT",
"rag.external_reranker_timeout",
os.environ.get("RAG_EXTERNAL_RERANKER_TIMEOUT", ""),
)
RAG_TEXT_SPLITTER = PersistentConfig(
"RAG_TEXT_SPLITTER",
@@ -2770,6 +2960,12 @@ RAG_TEXT_SPLITTER = PersistentConfig(
os.environ.get("RAG_TEXT_SPLITTER", ""),
)
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = PersistentConfig(
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER",
"rag.enable_markdown_header_text_splitter",
os.environ.get("ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", "True").lower() == "true",
)
TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken")
TIKTOKEN_ENCODING_NAME = PersistentConfig(
@@ -2782,6 +2978,13 @@ TIKTOKEN_ENCODING_NAME = PersistentConfig(
CHUNK_SIZE = PersistentConfig(
"CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000"))
)
CHUNK_MIN_SIZE_TARGET = PersistentConfig(
"CHUNK_MIN_SIZE_TARGET",
"rag.chunk_min_size_target",
int(os.environ.get("CHUNK_MIN_SIZE_TARGET", "0")),
)
CHUNK_OVERLAP = PersistentConfig(
"CHUNK_OVERLAP",
"rag.chunk_overlap",
@@ -2949,7 +3152,7 @@ WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
"WEB_SEARCH_CONCURRENT_REQUESTS",
"rag.web.search.concurrent_requests",
int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "10")),
int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "0")),
)
@@ -2966,6 +3169,12 @@ WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
)
WEB_LOADER_TIMEOUT = PersistentConfig(
"WEB_LOADER_TIMEOUT",
"rag.web.loader.timeout",
os.getenv("WEB_LOADER_TIMEOUT", ""),
)
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
"ENABLE_WEB_LOADER_SSL_VERIFICATION",
@@ -2992,6 +3201,12 @@ SEARXNG_QUERY_URL = PersistentConfig(
os.getenv("SEARXNG_QUERY_URL", ""),
)
SEARXNG_LANGUAGE = PersistentConfig(
"SEARXNG_LANGUAGE",
"rag.web.search.searxng_language",
os.getenv("SEARXNG_LANGUAGE", "all"),
)
YACY_QUERY_URL = PersistentConfig(
"YACY_QUERY_URL",
"rag.web.search.yacy_query_url",
@@ -3070,12 +3285,24 @@ SERPLY_API_KEY = PersistentConfig(
os.getenv("SERPLY_API_KEY", ""),
)
DDGS_BACKEND = PersistentConfig(
"DDGS_BACKEND",
"rag.web.search.ddgs_backend",
os.getenv("DDGS_BACKEND", "auto"),
)
JINA_API_KEY = PersistentConfig(
"JINA_API_KEY",
"rag.web.search.jina_api_key",
os.getenv("JINA_API_KEY", ""),
)
JINA_API_BASE_URL = PersistentConfig(
"JINA_API_BASE_URL",
"rag.web.search.jina_api_base_url",
os.getenv("JINA_API_BASE_URL", ""),
)
SEARCHAPI_API_KEY = PersistentConfig(
"SEARCHAPI_API_KEY",
"rag.web.search.searchapi_api_key",
@@ -3210,6 +3437,12 @@ FIRECRAWL_API_BASE_URL = PersistentConfig(
os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"),
)
FIRECRAWL_TIMEOUT = PersistentConfig(
"FIRECRAWL_TIMEOUT",
"rag.web.loader.firecrawl_timeout",
os.environ.get("FIRECRAWL_TIMEOUT", ""),
)
EXTERNAL_WEB_SEARCH_URL = PersistentConfig(
"EXTERNAL_WEB_SEARCH_URL",
"rag.web.search.external_web_search_url",
@@ -3234,6 +3467,24 @@ EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
)
YANDEX_WEB_SEARCH_URL = PersistentConfig(
"YANDEX_WEB_SEARCH_URL",
"rag.web.search.yandex_web_search_url",
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
)
YANDEX_WEB_SEARCH_API_KEY = PersistentConfig(
"YANDEX_WEB_SEARCH_API_KEY",
"rag.web.search.yandex_web_search_api_key",
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
)
YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
"YANDEX_WEB_SEARCH_CONFIG",
"rag.web.search.yandex_web_search_config",
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
)
####################################
# Images
####################################
@@ -3256,6 +3507,16 @@ IMAGE_GENERATION_MODEL = PersistentConfig(
os.getenv("IMAGE_GENERATION_MODEL", ""),
)
# Regex pattern for models that support IMAGE_SIZE = "auto".
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv(
"IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image"
)
# Regex pattern for models that return URLs instead of base64 data.
IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv(
"IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image"
)
IMAGE_SIZE = PersistentConfig(
"IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512")
)
@@ -3422,10 +3683,16 @@ COMFYUI_WORKFLOW = PersistentConfig(
os.getenv("COMFYUI_WORKFLOW", COMFYUI_DEFAULT_WORKFLOW),
)
comfyui_workflow_nodes = os.getenv("COMFYUI_WORKFLOW_NODES", "")
try:
comfyui_workflow_nodes = json.loads(comfyui_workflow_nodes)
except json.JSONDecodeError:
comfyui_workflow_nodes = []
COMFYUI_WORKFLOW_NODES = PersistentConfig(
"COMFYUI_WORKFLOW",
"COMFYUI_WORKFLOW_NODES",
"image_generation.comfyui.nodes",
[],
comfyui_workflow_nodes,
)
IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
@@ -3542,10 +3809,16 @@ IMAGES_EDIT_COMFYUI_WORKFLOW = PersistentConfig(
os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW", ""),
)
images_edit_comfyui_workflow_nodes = os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", "")
try:
images_edit_comfyui_workflow_nodes = json.loads(images_edit_comfyui_workflow_nodes)
except json.JSONDecodeError:
images_edit_comfyui_workflow_nodes = []
IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = PersistentConfig(
"IMAGES_EDIT_COMFYUI_WORKFLOW_NODES",
"images.edit.comfyui.nodes",
[],
images_edit_comfyui_workflow_nodes,
)
####################################
@@ -3559,17 +3832,16 @@ WHISPER_MODEL = PersistentConfig(
os.getenv("WHISPER_MODEL", "base"),
)
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
WHISPER_MODEL_AUTO_UPDATE = (
not OFFLINE_MODE
and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
)
WHISPER_VAD_FILTER = PersistentConfig(
"WHISPER_VAD_FILTER",
"audio.stt.whisper_vad_filter",
os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true",
)
WHISPER_VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true"
WHISPER_MULTILINGUAL = os.getenv("WHISPER_MULTILINGUAL", "False").lower() == "true"
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None
+142 -37
View File
@@ -85,32 +85,7 @@ if "cuda_error" in locals():
log.exception(cuda_error)
del cuda_error
log_sources = [
"AUDIO",
"COMFYUI",
"CONFIG",
"DB",
"IMAGES",
"MAIN",
"MODELS",
"OLLAMA",
"OPENAI",
"RAG",
"WEBHOOK",
"SOCKET",
"OAUTH",
]
SRC_LOG_LEVELS = {}
for source in log_sources:
log_env_var = source + "_LOG_LEVEL"
SRC_LOG_LEVELS[source] = os.environ.get(log_env_var, "").upper()
if SRC_LOG_LEVELS[source] not in logging.getLevelNamesMapping():
SRC_LOG_LEVELS[source] = GLOBAL_LOG_LEVEL
log.info(f"{log_env_var}: {SRC_LOG_LEVELS[source]}")
log.setLevel(SRC_LOG_LEVELS["CONFIG"])
SRC_LOG_LEVELS = {} # Legacy variable, do not remove
WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
if WEBUI_NAME != "Open WebUI":
@@ -142,6 +117,8 @@ VERSION = PACKAGE_DATA["version"]
DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
ENABLE_DB_MIGRATIONS = os.environ.get("ENABLE_DB_MIGRATIONS", "True").lower() == "true"
# Function to parse each section
def parse_section(section):
@@ -217,11 +194,35 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
)
# Header names for user info forwarding (customizable via environment variables)
FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name"
)
FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id"
)
FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email"
)
FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get(
"FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role"
)
# Header name for chat ID forwarding (customizable via environment variable)
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID = os.environ.get(
"FORWARD_SESSION_INFO_HEADER_MESSAGE_ID", "X-OpenWebUI-Message-Id"
)
FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get(
"FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id"
)
# Experimental feature, may be removed in future
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
)
ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true"
####################################
# WEBUI_BUILD_HASH
####################################
@@ -364,6 +365,16 @@ if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
except Exception:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
# When enabled, get_db_context reuses existing sessions; set to False to always create new sessions
DATABASE_ENABLE_SESSION_SHARING = (
os.environ.get("DATABASE_ENABLE_SESSION_SHARING", "False").lower() == "true"
)
# Enable public visibility of active user count (when disabled, only admins can see it)
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = (
os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true"
)
RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
@@ -374,6 +385,8 @@ ENABLE_REALTIME_CHAT_SAVE = (
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
RAG_SYSTEM_CONTEXT = os.environ.get("RAG_SYSTEM_CONTEXT", "False").lower() == "true"
####################################
# REDIS
####################################
@@ -395,6 +408,25 @@ try:
except ValueError:
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "")
try:
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
REDIS_RECONNECT_DELAY = os.environ.get("REDIS_RECONNECT_DELAY", "")
if REDIS_RECONNECT_DELAY == "":
REDIS_RECONNECT_DELAY = None
else:
try:
REDIS_RECONNECT_DELAY = float(REDIS_RECONNECT_DELAY)
if REDIS_RECONNECT_DELAY < 0:
REDIS_RECONNECT_DELAY = None
except Exception:
REDIS_RECONNECT_DELAY = None
####################################
# UVICORN WORKERS
####################################
@@ -422,6 +454,16 @@ ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
)
####################################
# Admin Account Runtime Creation
####################################
# Optional env vars for creating an admin account on startup
# Useful for headless/automated deployments
WEBUI_ADMIN_EMAIL = os.environ.get("WEBUI_ADMIN_EMAIL", "")
WEBUI_ADMIN_PASSWORD = os.environ.get("WEBUI_ADMIN_PASSWORD", "")
WEBUI_ADMIN_NAME = os.environ.get("WEBUI_ADMIN_NAME", "Admin")
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
)
@@ -436,10 +478,20 @@ ENABLE_PASSWORD_VALIDATION = (
)
PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"PASSWORD_VALIDATION_REGEX_PATTERN",
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
)
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
try:
PASSWORD_VALIDATION_REGEX_PATTERN = rf"{PASSWORD_VALIDATION_REGEX_PATTERN}"
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
except Exception as e:
log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}")
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
)
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
BYPASS_MODEL_ACCESS_CONTROL = (
@@ -505,6 +557,12 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = (
os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true"
)
####################################
# SCIM Configuration
####################################
@@ -514,6 +572,14 @@ ENABLE_SCIM = (
== "true"
)
SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "")
SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "")
if ENABLE_SCIM and not SCIM_AUTH_PROVIDER:
log.warning(
"SCIM is enabled but SCIM_AUTH_PROVIDER is not set. "
"Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') "
"to enable externalId storage."
)
####################################
# LICENSE_KEY
@@ -531,21 +597,21 @@ LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "")
pk = None
if LICENSE_PUBLIC_KEY:
pk = serialization.load_pem_public_key(
f"""
pk = serialization.load_pem_public_key(f"""
-----BEGIN PUBLIC KEY-----
{LICENSE_PUBLIC_KEY}
-----END PUBLIC KEY-----
""".encode(
"utf-8"
)
)
""".encode("utf-8"))
####################################
# MODELS
####################################
ENABLE_CUSTOM_MODEL_FALLBACK = (
os.environ.get("ENABLE_CUSTOM_MODEL_FALLBACK", "False").lower() == "true"
)
MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1")
if MODELS_CACHE_TTL == "":
MODELS_CACHE_TTL = None
@@ -620,9 +686,16 @@ ENABLE_WEBSOCKET_SUPPORT = (
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
if WEBSOCKET_REDIS_OPTIONS == "":
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
WEBSOCKET_REDIS_OPTIONS = None
if REDIS_SOCKET_CONNECT_TIMEOUT:
WEBSOCKET_REDIS_OPTIONS = {
"socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT
}
else:
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
WEBSOCKET_REDIS_OPTIONS = None
else:
try:
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)
@@ -648,7 +721,11 @@ WEBSOCKET_SERVER_LOGGING = (
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
)
WEBSOCKET_SERVER_ENGINEIO_LOGGING = (
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true"
os.environ.get(
"WEBSOCKET_SERVER_ENGINEIO_LOGGING",
os.environ.get("WEBSOCKET_SERVER_LOGGING", "False"),
).lower()
== "true"
)
WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20")
try:
@@ -663,6 +740,8 @@ except ValueError:
WEBSOCKET_SERVER_PING_INTERVAL = 25
REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true"
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "":
@@ -712,6 +791,17 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
)
RAG_EMBEDDING_TIMEOUT = os.environ.get("RAG_EMBEDDING_TIMEOUT", "")
if RAG_EMBEDDING_TIMEOUT == "":
RAG_EMBEDDING_TIMEOUT = None
else:
try:
RAG_EMBEDDING_TIMEOUT = int(RAG_EMBEDDING_TIMEOUT)
except Exception:
RAG_EMBEDDING_TIMEOUT = None
####################################
# SENTENCE TRANSFORMERS
####################################
@@ -756,6 +846,16 @@ else:
except Exception:
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
# Whether to apply sigmoid normalization to CrossEncoder reranking scores.
# When enabled (default), scores are normalized to 0-1 range for proper
# relevance threshold behavior with MS MARCO models.
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
os.environ.get(
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION", "True"
).lower()
== "true"
)
####################################
# OFFLINE_MODE
####################################
@@ -772,6 +872,11 @@ if OFFLINE_MODE:
####################################
# AUDIT LOGGING
####################################
ENABLE_AUDIT_STDOUT = os.getenv("ENABLE_AUDIT_STDOUT", "False").lower() == "true"
ENABLE_AUDIT_LOGS_FILE = os.getenv("ENABLE_AUDIT_LOGS_FILE", "True").lower() == "true"
# Where to store log file
# Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to
# provide the whole path, like: /app/audit.log
+1 -4
View File
@@ -35,9 +35,8 @@ from open_webui.utils.plugin import (
get_function_module_from_cache,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.access_control import has_access
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.utils.misc import (
add_or_update_system_message,
@@ -51,10 +50,8 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def get_function_module_by_id(request: Request, pipe_id: str):
+15 -7
View File
@@ -9,23 +9,23 @@ from open_webui.env import (
OPEN_WEBUI_DIR,
DATABASE_URL,
DATABASE_SCHEMA,
SRC_LOG_LEVELS,
DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
ENABLE_DB_MIGRATIONS,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool
from sqlalchemy.sql.type_api import _T
from typing_extensions import Self
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["DB"])
class JSONField(types.TypeDecorator):
@@ -77,7 +77,8 @@ def handle_peewee_migration(DATABASE_URL):
assert db.is_closed(), "Database connection is still open."
handle_peewee_migration(DATABASE_URL)
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
@@ -92,8 +93,6 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
# Extract database path from SQLCipher URL
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
@@ -150,7 +149,7 @@ SessionLocal = sessionmaker(
)
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj)
Session = scoped_session(SessionLocal)
ScopedSession = scoped_session(SessionLocal)
def get_session():
@@ -162,3 +161,12 @@ def get_session():
get_db = contextmanager(get_session)
@contextmanager
def get_db_context(db: Optional[Session] = None):
if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING:
yield db
else:
with get_db() as session:
yield session
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -25,7 +25,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
@@ -29,7 +29,6 @@ from contextlib import suppress
import peewee as pw
from peewee_migrate import Migrator
with suppress(ImportError):
import playhouse.postgres_ext as pw_pext
-4
View File
@@ -2,7 +2,6 @@ import logging
import os
from contextvars import ContextVar
from open_webui.env import SRC_LOG_LEVELS
from peewee import *
from peewee import InterfaceError as PeeWeeInterfaceError
from peewee import PostgresqlDatabase
@@ -10,7 +9,6 @@ from playhouse.db_url import connect, parse
from playhouse.shortcuts import ReconnectMixin
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["DB"])
db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None}
db_state = ContextVar("db_state", default=db_state_default.copy())
@@ -56,8 +54,6 @@ def register_connection(db_url):
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = db_path[1:] # Remove leading slash for relative paths
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
+243 -37
View File
@@ -61,13 +61,15 @@ from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
from open_webui.utils.logger import start_logger
from open_webui.socket.main import (
MODELS,
app as socket_app,
periodic_usage_pool_cleanup,
periodic_session_pool_cleanup,
get_event_emitter,
get_models_in_use,
get_active_user_ids,
)
from open_webui.routers import (
analytics,
audio,
images,
ollama,
@@ -89,6 +91,7 @@ from open_webui.routers import (
knowledge,
prompts,
evaluations,
skills,
tools,
users,
utils,
@@ -102,7 +105,9 @@ from open_webui.routers.retrieval import (
get_rf,
)
from open_webui.internal.db import Session, engine
from sqlalchemy.orm import Session
from open_webui.internal.db import ScopedSession, engine, get_session
from open_webui.models.functions import Functions
from open_webui.models.models import Models
@@ -143,6 +148,7 @@ from open_webui.config import (
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
CODE_INTERPRETER_JUPYTER_TIMEOUT,
ENABLE_MEMORIES,
# Image
AUTOMATIC1111_API_AUTH,
AUTOMATIC1111_BASE_URL,
@@ -206,8 +212,10 @@ from open_webui.config import (
PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
FIRECRAWL_TIMEOUT,
WEB_LOADER_ENGINE,
WEB_LOADER_CONCURRENT_REQUESTS,
WEB_LOADER_TIMEOUT,
WHISPER_MODEL,
WHISPER_VAD_FILTER,
WHISPER_LANGUAGE,
@@ -226,6 +234,7 @@ from open_webui.config import (
RAG_RERANKING_MODEL,
RAG_EXTERNAL_RERANKER_URL,
RAG_EXTERNAL_RERANKER_API_KEY,
RAG_EXTERNAL_RERANKER_TIMEOUT,
RAG_RERANKING_MODEL_AUTO_UPDATE,
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE,
@@ -248,6 +257,7 @@ from open_webui.config import (
RAG_OLLAMA_BASE_URL,
RAG_OLLAMA_API_KEY,
CHUNK_OVERLAP,
CHUNK_MIN_SIZE_TARGET,
CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY,
@@ -263,6 +273,7 @@ from open_webui.config import (
MINERU_API_MODE,
MINERU_API_URL,
MINERU_API_KEY,
MINERU_API_TIMEOUT,
MINERU_PARAMS,
DATALAB_MARKER_USE_LLM,
EXTERNAL_DOCUMENT_LOADER_URL,
@@ -273,11 +284,14 @@ from open_webui.config import (
DOCLING_PARAMS,
DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY,
DOCUMENT_INTELLIGENCE_MODEL,
MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY,
RAG_TEXT_SPLITTER,
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE,
YOUTUBE_LOADER_LANGUAGE,
YOUTUBE_LOADER_PROXY_URL,
# Retrieval (Web Search)
@@ -291,16 +305,19 @@ from open_webui.config import (
WEB_SEARCH_DOMAIN_FILTER_LIST,
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY,
JINA_API_BASE_URL,
SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE,
SERPAPI_API_KEY,
SERPAPI_ENGINE,
SEARXNG_QUERY_URL,
SEARXNG_LANGUAGE,
YACY_QUERY_URL,
YACY_USERNAME,
YACY_PASSWORD,
SERPER_API_KEY,
SERPLY_API_KEY,
DDGS_BACKEND,
SERPSTACK_API_KEY,
SERPSTACK_HTTPS,
TAVILY_API_KEY,
@@ -339,6 +356,9 @@ from open_webui.config import (
EXTERNAL_WEB_SEARCH_API_KEY,
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
YANDEX_WEB_SEARCH_URL,
YANDEX_WEB_SEARCH_API_KEY,
YANDEX_WEB_SEARCH_CONFIG,
# WebUI
WEBUI_AUTH,
WEBUI_NAME,
@@ -352,8 +372,11 @@ from open_webui.config import (
ENABLE_API_KEYS,
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
API_KEYS_ALLOWED_ENDPOINTS,
ENABLE_FOLDERS,
FOLDER_MAX_FILE_COUNT,
ENABLE_CHANNELS,
ENABLE_NOTES,
ENABLE_USER_STATUS,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
@@ -433,6 +456,7 @@ from open_webui.config import (
reset_config,
)
from open_webui.env import (
ENABLE_CUSTOM_MODEL_FALLBACK,
LICENSE_KEY,
AUDIT_EXCLUDED_PATHS,
AUDIT_LOG_LEVEL,
@@ -445,7 +469,6 @@ from open_webui.env import (
GLOBAL_LOG_LEVEL,
MAX_BODY_LOG_SIZE,
SAFE_MODE,
SRC_LOG_LEVELS,
VERSION,
DEPLOYMENT_ID,
INSTANCE_ID,
@@ -469,6 +492,12 @@ from open_webui.env import (
EXTERNAL_PWA_MANIFEST_URL,
AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_STAR_SESSIONS_MIDDLEWARE,
ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
# Admin Account Runtime Creation
WEBUI_ADMIN_EMAIL,
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
)
@@ -481,11 +510,15 @@ from open_webui.utils.models import (
from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler,
chat_completed as chat_completed_handler,
chat_action as chat_action_handler,
)
from open_webui.utils.actions import chat_action as chat_action_handler
from open_webui.utils.embeddings import generate_embeddings
from open_webui.utils.middleware import process_chat_payload, process_chat_response
from open_webui.utils.access_control import has_access
from open_webui.utils.middleware import (
build_chat_response_context,
process_chat_payload,
process_chat_response,
)
from open_webui.utils.tools import set_tool_servers
from open_webui.utils.auth import (
get_license_data,
@@ -493,6 +526,7 @@ from open_webui.utils.auth import (
decode_token,
get_admin_user,
get_verified_user,
create_admin_user,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import (
@@ -519,14 +553,12 @@ from open_webui.utils.redis import get_sentinels_from_env
from open_webui.constants import ERROR_MESSAGES
if SAFE_MODE:
print("SAFE MODE ENABLED")
Functions.deactivate_all_functions()
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
class SPAStaticFiles(StaticFiles):
@@ -544,8 +576,7 @@ class SPAStaticFiles(StaticFiles):
raise ex
print(
rf"""
print(rf"""
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
@@ -557,12 +588,15 @@ print(
v{VERSION} - building the best AI user interface.
{f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""}
https://github.com/open-webui/open-webui
"""
)
""")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Store reference to main event loop for sync->async calls (e.g., embedding generation)
# This allows sync functions to schedule work on the main loop without blocking health checks
app.state.main_loop = asyncio.get_running_loop()
app.state.instance_id = INSTANCE_ID
start_logger()
@@ -572,6 +606,12 @@ async def lifespan(app: FastAPI):
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
# Create admin account from env vars if specified and no users exist
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
if create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME):
# Disable signup since we now have an admin
app.state.config.ENABLE_SIGNUP = False
# This should be blocking (sync) so functions are not deactivated on first /get_models calls
# when the first user lands on the / route.
log.info("Installing external dependencies of functions and tools...")
@@ -596,11 +636,37 @@ async def lifespan(app: FastAPI):
limiter.total_tokens = THREAD_POOL_SIZE
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
if app.state.config.ENABLE_BASE_MODELS_CACHE:
await get_all_models(
Request(
# Creating a mock request object to pass to get_all_models
try:
await get_all_models(
Request(
# Creating a mock request object to pass to get_all_models
{
"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,
}
),
None,
)
except Exception as e:
log.warning(f"Failed to pre-fetch models at startup: {e}")
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
log.info("Initializing tool servers...")
try:
mock_request = Request(
{
"type": "http",
"asgi.version": "3.0",
@@ -614,9 +680,11 @@ async def lifespan(app: FastAPI):
"scheme": "http",
"app": app,
}
),
None,
)
)
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}")
yield
@@ -767,15 +835,33 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL
app.state.config.BANNERS = WEBUI_BANNERS
app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS
app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_NOTES = ENABLE_NOTES
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
# Migrate legacy access_control → access_grants on boot
from open_webui.utils.access_control import migrate_access_control
connections = app.state.config.TOOL_SERVER_CONNECTIONS
if any("access_control" in c.get("config", {}) for c in connections):
for connection in connections:
migrate_access_control(connection.get("config", {}))
app.state.config.TOOL_SERVER_CONNECTIONS = connections
arena_models = app.state.config.EVALUATION_ARENA_MODELS
if any("access_control" in m.get("meta", {}) for m in arena_models):
for model in arena_models:
migrate_access_control(model.get("meta", {}))
app.state.config.EVALUATION_ARENA_MODELS = arena_models
app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
@@ -869,19 +955,27 @@ app.state.config.DOCLING_API_KEY = DOCLING_API_KEY
app.state.config.DOCLING_PARAMS = DOCLING_PARAMS
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
app.state.config.DOCUMENT_INTELLIGENCE_MODEL = DOCUMENT_INTELLIGENCE_MODEL
app.state.config.MISTRAL_OCR_API_BASE_URL = MISTRAL_OCR_API_BASE_URL
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
app.state.config.MINERU_API_MODE = MINERU_API_MODE
app.state.config.MINERU_API_URL = MINERU_API_URL
app.state.config.MINERU_API_KEY = MINERU_API_KEY
app.state.config.MINERU_API_TIMEOUT = MINERU_API_TIMEOUT
app.state.config.MINERU_PARAMS = MINERU_PARAMS
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
)
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
app.state.config.CHUNK_SIZE = CHUNK_SIZE
app.state.config.CHUNK_MIN_SIZE_TARGET = CHUNK_MIN_SIZE_TARGET
app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP
app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
@@ -891,6 +985,7 @@ app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT
app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
@@ -905,6 +1000,7 @@ app.state.config.RAG_OLLAMA_BASE_URL = RAG_OLLAMA_BASE_URL
app.state.config.RAG_OLLAMA_API_KEY = RAG_OLLAMA_API_KEY
app.state.config.PDF_EXTRACT_IMAGES = PDF_EXTRACT_IMAGES
app.state.config.PDF_LOADER_MODE = PDF_LOADER_MODE
app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE
app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
@@ -918,6 +1014,7 @@ app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
@@ -930,6 +1027,7 @@ app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.SEARXNG_LANGUAGE = SEARXNG_LANGUAGE
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
app.state.config.YACY_USERNAME = YACY_USERNAME
app.state.config.YACY_PASSWORD = YACY_PASSWORD
@@ -943,12 +1041,14 @@ app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY
app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS
app.state.config.SERPER_API_KEY = SERPER_API_KEY
app.state.config.SERPLY_API_KEY = SERPLY_API_KEY
app.state.config.DDGS_BACKEND = DDGS_BACKEND
app.state.config.TAVILY_API_KEY = TAVILY_API_KEY
app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY
app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE
app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY
app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE
app.state.config.JINA_API_KEY = JINA_API_KEY
app.state.config.JINA_API_BASE_URL = JINA_API_BASE_URL
app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
app.state.config.EXA_API_KEY = EXA_API_KEY
@@ -962,12 +1062,16 @@ app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.config.FIRECRAWL_TIMEOUT = FIRECRAWL_TIMEOUT
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
app.state.EMBEDDING_FUNCTION = None
@@ -980,9 +1084,7 @@ app.state.YOUTUBE_LOADER_TRANSLATION = None
try:
app.state.ef = get_ef(
app.state.config.RAG_EMBEDDING_ENGINE,
app.state.config.RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
app.state.config.RAG_EMBEDDING_ENGINE, app.state.config.RAG_EMBEDDING_MODEL
)
if (
app.state.config.ENABLE_RAG_HYBRID_SEARCH
@@ -993,7 +1095,7 @@ try:
app.state.config.RAG_RERANKING_MODEL,
app.state.config.RAG_EXTERNAL_RERANKER_URL,
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
RAG_RERANKING_MODEL_AUTO_UPDATE,
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
)
else:
app.state.rf = None
@@ -1030,6 +1132,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
else None
),
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
)
app.state.RERANKING_FUNCTION = get_reranking_function(
@@ -1077,6 +1180,7 @@ app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIM
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.ENABLE_MEMORIES = ENABLE_MEMORIES
app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL
app.state.config.IMAGE_SIZE = IMAGE_SIZE
@@ -1130,7 +1234,6 @@ 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
app.state.config.WHISPER_MODEL = WHISPER_MODEL
app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER
app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY
app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY
@@ -1215,7 +1318,7 @@ app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE
#
########################################
app.state.MODELS = {}
app.state.MODELS = MODELS
# Add the middleware to the app
if ENABLE_COMPRESSION_MIDDLEWARE:
@@ -1272,7 +1375,9 @@ class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
token = None
if auth_header:
scheme, token = auth_header.split(" ")
parts = auth_header.split(" ", 1)
if len(parts) == 2:
token = parts[1]
# Only apply restrictions if an sk- API key is used
if token and token.startswith("sk-"):
@@ -1313,7 +1418,13 @@ app.add_middleware(APIKeyRestrictionMiddleware)
async def commit_session_after_request(request: Request, call_next):
response = await call_next(request)
# log.debug("Commit session after request")
Session.commit()
try:
ScopedSession.commit()
finally:
# CRITICAL: remove() returns the connection to the pool.
# Without this, connections remain "checked out" and accumulate
# as "idle in transaction" in PostgreSQL.
ScopedSession.remove()
return response
@@ -1323,6 +1434,13 @@ async def check_url(request: Request, call_next):
request.state.token = get_http_authorization_cred(
request.headers.get("Authorization")
)
# Fallback to cookie token for browser sessions
if request.state.token is None and request.cookies.get("token"):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(
scheme="Bearer", credentials=request.cookies.get("token")
)
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
@@ -1387,6 +1505,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"])
app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"])
app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"])
app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"])
app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"])
@@ -1396,6 +1515,7 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
app.include_router(
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
)
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
# SCIM 2.0 API for identity management
@@ -1529,6 +1649,7 @@ async def chat_completion(
metadata = {}
try:
model_info = None
if not model_item.get("direct", False):
if model_id not in request.app.state.MODELS:
raise Exception("Model not found")
@@ -1546,7 +1667,6 @@ async def chat_completion(
raise e
else:
model = model_item
model_info = None
request.state.direct = True
request.state.model = model
@@ -1555,6 +1675,26 @@ async def chat_completion(
model_info.params.model_dump() if model_info and model_info.params else {}
)
# Check base model existence for custom models
if model_info_params.get("base_model_id"):
base_model_id = model_info_params.get("base_model_id")
if base_model_id not in request.app.state.MODELS:
if ENABLE_CUSTOM_MODEL_FALLBACK:
default_models = (
request.app.state.config.DEFAULT_MODELS or ""
).split(",")
fallback_model_id = (
default_models[0].strip() if default_models[0] else None
)
if fallback_model_id:
request.base_model_id = fallback_model_id
else:
raise Exception("Model not found")
else:
raise Exception("Model not found")
# Chat Params
stream_delta_chunk_size = form_data.get("params", {}).get(
"stream_delta_chunk_size"
@@ -1575,6 +1715,8 @@ async def chat_completion(
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
"message_id": form_data.pop("id", None),
"parent_message": form_data.pop("parent_message", None),
"parent_message_id": form_data.pop("parent_id", None),
"session_id": form_data.pop("session_id", None),
"filter_ids": form_data.pop("filter_ids", []),
"tool_ids": form_data.get("tool_ids", None),
@@ -1598,15 +1740,38 @@ async def chat_completion(
},
}
if metadata.get("chat_id") and (user and user.role != "admin"):
if not metadata["chat_id"].startswith("local:"):
if metadata.get("chat_id") and user:
if not metadata["chat_id"].startswith(
"local:"
): # temporary chats are not stored
# Verify chat ownership
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None:
if chat is None and user.role != "admin": # admins can access any chat
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
)
# Insert chat files from parent message if any
parent_message = metadata.get("parent_message") or {}
parent_message_files = parent_message.get("files", [])
if parent_message_files:
try:
Chats.insert_chat_files(
metadata["chat_id"],
parent_message.get("id"),
[
file_item.get("id")
for file_item in parent_message_files
if file_item.get("type") == "file"
],
user.id,
)
except Exception as e:
log.debug(f"Error inserting chat files: {e}")
pass
request.state.metadata = metadata
form_data["metadata"] = metadata
@@ -1631,15 +1796,18 @@ async def chat_completion(
metadata["chat_id"],
metadata["message_id"],
{
"parentId": metadata.get("parent_message_id", None),
"model": model_id,
},
)
except:
pass
return await process_chat_response(
request, response, form_data, user, metadata, model, events, tasks
ctx = build_chat_response_context(
request, form_data, user, model, metadata, tasks, events
)
return await process_chat_response(response, ctx)
except asyncio.CancelledError:
log.info("Chat processing was cancelled")
try:
@@ -1663,6 +1831,7 @@ async def chat_completion(
metadata["chat_id"],
metadata["message_id"],
{
"parentId": metadata.get("parent_message_id", None),
"error": {"content": str(e)},
},
)
@@ -1688,6 +1857,16 @@ async def chat_completion(
except Exception as e:
log.debug(f"Error cleaning up: {e}")
pass
# Emit chat:active=false when task completes
try:
if metadata.get("chat_id"):
event_emitter = get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter(
{"type": "chat:active", "data": {"active": False}}
)
except Exception as e:
log.debug(f"Error emitting chat:active: {e}")
if (
metadata.get("session_id")
@@ -1700,6 +1879,10 @@ async def chat_completion(
process_chat(request, form_data, user, metadata, model),
id=metadata["chat_id"],
)
# Emit chat:active=true when task starts
event_emitter = get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({"type": "chat:active", "data": {"active": True}})
return {"status": True, "task_id": task_id}
else:
return await process_chat(request, form_data, user, metadata, model)
@@ -1839,9 +2022,13 @@ async def get_app_config(request: Request):
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
"enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
"enable_easter_eggs": ENABLE_EASTER_EGGS,
**(
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_folders": app.state.config.ENABLE_FOLDERS,
"folder_max_file_count": app.state.config.FOLDER_MAX_FILE_COUNT,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_notes": app.state.config.ENABLE_NOTES,
"enable_web_search": app.state.config.ENABLE_WEB_SEARCH,
@@ -1852,10 +2039,12 @@ async def get_app_config(request: Request):
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS,
"enable_user_status": app.state.config.ENABLE_USER_STATUS,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"enable_memories": app.state.config.ENABLE_MEMORIES,
**(
{
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
@@ -2014,7 +2203,19 @@ async def get_current_usage(user=Depends(get_verified_user)):
This is an experimental endpoint and subject to change.
"""
try:
return {"model_ids": get_models_in_use(), "user_ids": get_active_user_ids()}
# If public visibility is disabled, only allow admins to access this endpoint
if not ENABLE_PUBLIC_ACTIVE_USERS_COUNT and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Only administrators can view usage statistics.",
)
return {
"model_ids": get_models_in_use(),
"user_count": Users.get_active_user_count(),
}
except HTTPException:
raise
except Exception as e:
log.error(f"Error getting usage statistics: {e}")
raise HTTPException(status_code=500, detail="Internal Server Error")
@@ -2077,7 +2278,7 @@ except Exception as e:
)
async def register_client(self, request, client_id: str) -> bool:
async def register_client(request, client_id: str) -> bool:
server_type, server_id = client_id.split(":", 1)
connection = None
@@ -2208,8 +2409,13 @@ async def oauth_login(provider: str, request: Request):
# - Email addresses are considered unique, so we fail registration if the email address is already taken
@app.get("/oauth/{provider}/login/callback")
@app.get("/oauth/{provider}/callback") # Legacy endpoint
async def oauth_login_callback(provider: str, request: Request, response: Response):
return await oauth_manager.handle_callback(request, provider, response)
async def oauth_login_callback(
provider: str,
request: Request,
response: Response,
db: Session = Depends(get_session),
):
return await oauth_manager.handle_callback(request, provider, response, db=db)
@app.get("/manifest.json")
@@ -2268,7 +2474,7 @@ async def healthcheck():
@app.get("/health/db")
async def healthcheck_with_db():
Session.execute(text("SELECT 1;")).all()
ScopedSession.execute(text("SELECT 1;")).all()
return {"status": True}
@@ -0,0 +1,102 @@
"""Update messages and channel member table
Revision ID: 2f1211949ecc
Revises: 37f288994c47
Create Date: 2025-11-27 03:07:56.200231
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "2f1211949ecc"
down_revision: Union[str, None] = "37f288994c47"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# New columns to be added to channel_member table
op.add_column("channel_member", sa.Column("status", sa.Text(), nullable=True))
op.add_column(
"channel_member",
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
default=True,
server_default=sa.sql.expression.true(),
),
)
op.add_column(
"channel_member",
sa.Column(
"is_channel_muted",
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column(
"channel_member",
sa.Column(
"is_channel_pinned",
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column("channel_member", sa.Column("data", sa.JSON(), nullable=True))
op.add_column("channel_member", sa.Column("meta", sa.JSON(), nullable=True))
op.add_column(
"channel_member", sa.Column("joined_at", sa.BigInteger(), nullable=False)
)
op.add_column(
"channel_member", sa.Column("left_at", sa.BigInteger(), nullable=True)
)
op.add_column(
"channel_member", sa.Column("last_read_at", sa.BigInteger(), nullable=True)
)
op.add_column(
"channel_member", sa.Column("updated_at", sa.BigInteger(), nullable=True)
)
# New columns to be added to message table
op.add_column(
"message",
sa.Column(
"is_pinned",
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column("message", sa.Column("pinned_at", sa.BigInteger(), nullable=True))
op.add_column("message", sa.Column("pinned_by", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("channel_member", "updated_at")
op.drop_column("channel_member", "last_read_at")
op.drop_column("channel_member", "meta")
op.drop_column("channel_member", "data")
op.drop_column("channel_member", "is_channel_pinned")
op.drop_column("channel_member", "is_channel_muted")
op.drop_column("message", "pinned_by")
op.drop_column("message", "pinned_at")
op.drop_column("message", "is_pinned")
@@ -0,0 +1,247 @@
"""Add prompt history table
Revision ID: 374d2f66af06
Revises: c440947495f3
Create Date: 2026-01-23 17:15:00.000000
"""
from typing import Sequence, Union
import uuid
from alembic import op
import sqlalchemy as sa
revision: str = "374d2f66af06"
down_revision: Union[str, None] = "c440947495f3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Step 1: Read existing data from OLD table (schema likely command as PK)
# We use batch_alter previously, but we want to move to new table.
# We need to assume the OLD structure.
old_prompt_table = sa.table(
"prompt",
sa.column("command", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("title", sa.Text()),
sa.column("content", sa.Text()),
sa.column("timestamp", sa.BigInteger()),
sa.column("access_control", sa.JSON()),
)
# Check if table exists/read data
try:
existing_prompts = conn.execute(
sa.select(
old_prompt_table.c.command,
old_prompt_table.c.user_id,
old_prompt_table.c.title,
old_prompt_table.c.content,
old_prompt_table.c.timestamp,
old_prompt_table.c.access_control,
)
).fetchall()
except Exception:
# Fallback if table doesn't exist (new install)
existing_prompts = []
# Step 2: Create new prompt table with 'id' as PRIMARY KEY
op.create_table(
"prompt_new",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("command", sa.String(), unique=True, index=True),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("version_id", sa.Text(), nullable=True),
sa.Column("tags", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
# Step 3: Create prompt_history table
op.create_table(
"prompt_history",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("prompt_id", sa.Text(), nullable=False, index=True),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("commit_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
)
# Step 4: Migrate data
prompt_new_table = sa.table(
"prompt_new",
sa.column("id", sa.Text()),
sa.column("command", sa.String()),
sa.column("user_id", sa.String()),
sa.column("name", sa.Text()),
sa.column("content", sa.Text()),
sa.column("data", sa.JSON()),
sa.column("meta", sa.JSON()),
sa.column("access_control", sa.JSON()),
sa.column("is_active", sa.Boolean()),
sa.column("version_id", sa.Text()),
sa.column("tags", sa.JSON()),
sa.column("created_at", sa.BigInteger()),
sa.column("updated_at", sa.BigInteger()),
)
prompt_history_table = sa.table(
"prompt_history",
sa.column("id", sa.Text()),
sa.column("prompt_id", sa.Text()),
sa.column("parent_id", sa.Text()),
sa.column("snapshot", sa.JSON()),
sa.column("user_id", sa.Text()),
sa.column("commit_message", sa.Text()),
sa.column("created_at", sa.BigInteger()),
)
for row in existing_prompts:
command = row[0]
user_id = row[1]
title = row[2]
content = row[3]
timestamp = row[4]
access_control = row[5]
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith("/") else command
# Insert into prompt_new
conn.execute(
sa.insert(prompt_new_table).values(
id=new_uuid,
command=clean_command,
user_id=user_id,
name=title,
content=content,
data={},
meta={},
access_control=access_control,
is_active=True,
version_id=history_uuid,
tags=[],
created_at=timestamp,
updated_at=timestamp,
)
)
# Create initial history entry
conn.execute(
sa.insert(prompt_history_table).values(
id=history_uuid,
prompt_id=new_uuid,
parent_id=None,
snapshot={
"name": title,
"content": content,
"command": clean_command,
"data": {},
"meta": {},
"access_control": access_control,
},
user_id=user_id,
commit_message=None,
created_at=timestamp,
)
)
# Step 5: Replace old table with new one
op.drop_table("prompt")
op.rename_table("prompt_new", "prompt")
def downgrade() -> None:
conn = op.get_bind()
# Step 1: Read new data
prompt_table = sa.table(
"prompt",
sa.column("command", sa.String()),
sa.column("name", sa.Text()),
sa.column("created_at", sa.BigInteger()),
sa.column("user_id", sa.Text()),
sa.column("content", sa.Text()),
sa.column("access_control", sa.JSON()),
)
try:
current_data = conn.execute(
sa.select(
prompt_table.c.command,
prompt_table.c.name,
prompt_table.c.created_at,
prompt_table.c.user_id,
prompt_table.c.content,
prompt_table.c.access_control,
)
).fetchall()
except Exception:
current_data = []
# Step 2: Drop history and table
op.drop_table("prompt_history")
op.drop_table("prompt")
# Step 3: Recreate old table (command as PK?)
# Assuming old schema:
op.create_table(
"prompt",
sa.Column("command", sa.String(), primary_key=True),
sa.Column("user_id", sa.String()),
sa.Column("title", sa.Text()),
sa.Column("content", sa.Text()),
sa.Column("timestamp", sa.BigInteger()),
sa.Column("access_control", sa.JSON()),
sa.Column("id", sa.Integer(), nullable=True),
)
# Step 4: Restore data
old_prompt_table = sa.table(
"prompt",
sa.column("command", sa.String()),
sa.column("user_id", sa.String()),
sa.column("title", sa.Text()),
sa.column("content", sa.Text()),
sa.column("timestamp", sa.BigInteger()),
sa.column("access_control", sa.JSON()),
)
for row in current_data:
command = row[0]
name = row[1]
created_at = row[2]
user_id = row[3]
content = row[4]
access_control = row[5]
# Restore leading /
old_command = (
"/" + command if command and not command.startswith("/") else command
)
conn.execute(
sa.insert(old_prompt_table).values(
command=old_command,
user_id=user_id,
title=name,
content=content,
timestamp=created_at,
access_control=access_control,
)
)
@@ -14,7 +14,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "37f288994c47"
down_revision: Union[str, None] = "a5c220713937"
@@ -11,7 +11,6 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "38d63c18f30f"
down_revision: Union[str, None] = "3af16a1c9fb6"
@@ -20,18 +19,46 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
inspector = sa.inspect(op.get_bind())
columns = inspector.get_columns("user")
pk_columns = inspector.get_pk_constraint("user")["constrained_columns"]
id_column = next((col for col in columns if col["name"] == "id"), None)
if id_column and not id_column.get("unique", False):
unique_constraints = inspector.get_unique_constraints("user")
unique_columns = {tuple(u["column_names"]) for u in unique_constraints}
with op.batch_alter_table("user") as batch_op:
# If primary key is wrong, drop it
if pk_columns and pk_columns != ["id"]:
batch_op.drop_constraint(
inspector.get_pk_constraint("user")["name"], type_="primary"
)
# Add unique constraint if missing
if ("id",) not in unique_columns:
batch_op.create_unique_constraint("uq_user_id", ["id"])
# Re-create correct primary key
batch_op.create_primary_key("pk_user_id", ["id"])
# Create oauth_session table
op.create_table(
"oauth_session",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
"user_id",
sa.Text(),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("expires_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
)
# Create indexes for better performance
@@ -0,0 +1,169 @@
"""Add knowledge_file table
Revision ID: 3e0e00844bb0
Revises: 90ef40d4714e
Create Date: 2025-12-02 06:54:19.401334
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
import open_webui.internal.db
import time
import json
import uuid
# revision identifiers, used by Alembic.
revision: str = "3e0e00844bb0"
down_revision: Union[str, None] = "90ef40d4714e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"knowledge_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"knowledge_id",
sa.Text(),
sa.ForeignKey("knowledge.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"file_id",
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_knowledge_file_knowledge_id", "knowledge_id"),
sa.Index("ix_knowledge_file_file_id", "file_id"),
sa.Index("ix_knowledge_file_user_id", "user_id"),
# unique constraints
sa.UniqueConstraint(
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
), # prevent duplicate entries
)
connection = op.get_bind()
# 2. Read existing group with user_ids JSON column
knowledge_table = sa.Table(
"knowledge",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("data", sa.JSON()), # JSON stored as text in SQLite + PG
)
results = connection.execute(
sa.select(
knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data
)
).fetchall()
# 3. Insert members into group_member table
kf_table = sa.Table(
"knowledge_file",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("knowledge_id", sa.Text()),
sa.Column("file_id", sa.Text()),
sa.Column("created_at", sa.BigInteger()),
sa.Column("updated_at", sa.BigInteger()),
)
file_table = sa.Table(
"file",
sa.MetaData(),
sa.Column("id", sa.Text()),
)
now = int(time.time())
for knowledge_id, user_id, data in results:
if not data:
continue
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
continue # skip invalid JSON
if not isinstance(data, dict):
continue
file_ids = data.get("file_ids", [])
for file_id in file_ids:
file_exists = connection.execute(
sa.select(file_table.c.id).where(file_table.c.id == file_id)
).fetchone()
if not file_exists:
continue # skip non-existing files
row = {
"id": str(uuid.uuid4()),
"user_id": user_id,
"knowledge_id": knowledge_id,
"file_id": file_id,
"created_at": now,
"updated_at": now,
}
connection.execute(kf_table.insert().values(**row))
with op.batch_alter_table("knowledge") as batch:
batch.drop_column("data")
def downgrade() -> None:
# 1. Add back the old data column
op.add_column("knowledge", sa.Column("data", sa.JSON(), nullable=True))
connection = op.get_bind()
# 2. Read knowledge_file entries and reconstruct data JSON
knowledge_table = sa.Table(
"knowledge",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("data", sa.JSON()),
)
kf_table = sa.Table(
"knowledge_file",
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("knowledge_id", sa.Text()),
sa.Column("file_id", sa.Text()),
)
results = connection.execute(sa.select(knowledge_table.c.id)).fetchall()
for (knowledge_id,) in results:
file_ids = connection.execute(
sa.select(kf_table.c.file_id).where(kf_table.c.knowledge_id == knowledge_id)
).fetchall()
file_ids_list = [fid for (fid,) in file_ids]
data_json = {"file_ids": file_ids_list}
connection.execute(
knowledge_table.update()
.where(knowledge_table.c.id == knowledge_id)
.values(data=data_json)
)
# 3. Drop the knowledge_file table
op.drop_table("knowledge_file")
@@ -0,0 +1,53 @@
"""Add channel file table
Revision ID: 6283dc0e4d8d
Revises: 3e0e00844bb0
Create Date: 2025-12-10 15:11:39.424601
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "6283dc0e4d8d"
down_revision: Union[str, None] = "3e0e00844bb0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"channel_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"channel_id",
sa.Text(),
sa.ForeignKey("channel.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"file_id",
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_channel_file_channel_id", "channel_id"),
sa.Index("ix_channel_file_file_id", "file_id"),
sa.Index("ix_channel_file_user_id", "user_id"),
# unique constraints
sa.UniqueConstraint(
"channel_id", "file_id", name="uq_channel_file_channel_file"
), # prevent duplicate entries
)
def downgrade() -> None:
op.drop_table("channel_file")
@@ -11,7 +11,6 @@ import sqlalchemy as sa
from sqlalchemy.sql import table, column, select
import json
revision = "6a39f3d8e55c"
down_revision = "c0fbf31ca0db"
branch_labels = None
@@ -0,0 +1,48 @@
"""Update channel file and knowledge table
Revision ID: 81cc2ce44d79
Revises: 6283dc0e4d8d
Create Date: 2025-12-10 16:07:58.001282
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "81cc2ce44d79"
down_revision: Union[str, None] = "6283dc0e4d8d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add message_id column to channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"message_id",
sa.Text(),
sa.ForeignKey(
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
),
nullable=True,
)
)
# Add data column to knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove message_id column from channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.drop_column("message_id")
# Remove data column from knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.drop_column("data")
@@ -0,0 +1,177 @@
"""Add chat_message table
Revision ID: 8452d01d26d7
Revises: 374d2f66af06
Create Date: 2026-02-01 04:00:00.000000
"""
import time
import json
import logging
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
log = logging.getLogger(__name__)
revision: str = "8452d01d26d7"
down_revision: Union[str, None] = "374d2f66af06"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Step 1: Create table
op.create_table(
"chat_message",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("chat_id", sa.Text(), nullable=False, index=True),
sa.Column("user_id", sa.Text(), index=True),
sa.Column("role", sa.Text(), nullable=False),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("content", sa.JSON(), nullable=True),
sa.Column("output", sa.JSON(), nullable=True),
sa.Column("model_id", sa.Text(), nullable=True, index=True),
sa.Column("files", sa.JSON(), nullable=True),
sa.Column("sources", sa.JSON(), nullable=True),
sa.Column("embeds", sa.JSON(), nullable=True),
sa.Column("done", sa.Boolean(), default=True),
sa.Column("status_history", sa.JSON(), nullable=True),
sa.Column("error", sa.JSON(), nullable=True),
sa.Column("usage", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), index=True),
sa.Column("updated_at", sa.BigInteger()),
sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"),
)
# Create composite indexes
op.create_index(
"chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"]
)
op.create_index(
"chat_message_model_created_idx", "chat_message", ["model_id", "created_at"]
)
op.create_index(
"chat_message_user_created_idx", "chat_message", ["user_id", "created_at"]
)
# Step 2: Backfill from existing chats
conn = op.get_bind()
chat_table = sa.table(
"chat",
sa.column("id", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("chat", sa.JSON()),
)
chat_message_table = sa.table(
"chat_message",
sa.column("id", sa.Text()),
sa.column("chat_id", sa.Text()),
sa.column("user_id", sa.Text()),
sa.column("role", sa.Text()),
sa.column("parent_id", sa.Text()),
sa.column("content", sa.JSON()),
sa.column("output", sa.JSON()),
sa.column("model_id", sa.Text()),
sa.column("files", sa.JSON()),
sa.column("sources", sa.JSON()),
sa.column("embeds", sa.JSON()),
sa.column("done", sa.Boolean()),
sa.column("status_history", sa.JSON()),
sa.column("error", sa.JSON()),
sa.column("usage", sa.JSON()),
sa.column("created_at", sa.BigInteger()),
sa.column("updated_at", sa.BigInteger()),
)
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
chats = conn.execute(
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
~chat_table.c.user_id.like("shared-%")
)
).fetchall()
now = int(time.time())
messages_inserted = 0
messages_failed = 0
for chat_row in chats:
chat_id = chat_row[0]
user_id = chat_row[1]
chat_data = chat_row[2]
if not chat_data:
continue
# Handle both string and dict chat data
if isinstance(chat_data, str):
try:
chat_data = json.loads(chat_data)
except Exception:
continue
history = chat_data.get("history", {})
messages = history.get("messages", {})
for message_id, message in messages.items():
if not isinstance(message, dict):
continue
role = message.get("role")
if not role:
continue
timestamp = message.get("timestamp", now)
# Normalize timestamp: convert ms to seconds, validate range
if timestamp > 10_000_000_000:
timestamp = timestamp // 1000
# Must be after 2020 and not too far in the future
if timestamp < 1577836800 or timestamp > now + 86400:
timestamp = now
# Use savepoint to allow individual insert failures without aborting transaction
savepoint = conn.begin_nested()
try:
conn.execute(
sa.insert(chat_message_table).values(
id=f"{chat_id}-{message_id}",
chat_id=chat_id,
user_id=user_id,
role=role,
parent_id=message.get("parentId"),
content=message.get("content"),
output=message.get("output"),
model_id=message.get("model"),
files=message.get("files"),
sources=message.get("sources"),
embeds=message.get("embeds"),
done=message.get("done", True),
status_history=message.get("statusHistory"),
error=message.get("error"),
created_at=timestamp,
updated_at=timestamp,
)
)
savepoint.commit()
messages_inserted += 1
except Exception as e:
savepoint.rollback()
messages_failed += 1
log.warning(f"Failed to insert message {message_id}: {e}")
continue
log.info(
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
)
def downgrade() -> None:
op.drop_index("chat_message_user_created_idx", table_name="chat_message")
op.drop_index("chat_message_model_created_idx", table_name="chat_message")
op.drop_index("chat_message_chat_parent_idx", table_name="chat_message")
op.drop_table("chat_message")
@@ -0,0 +1,80 @@
"""Update channel and channel members table
Revision ID: 90ef40d4714e
Revises: b10670c03dd5
Create Date: 2025-11-30 06:33:38.790341
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "90ef40d4714e"
down_revision: Union[str, None] = "b10670c03dd5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Update 'channel' table
op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True))
op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True))
op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True))
op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True))
op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True))
op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True))
# Update 'channel_member' table
op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True))
op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True))
op.add_column(
"channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True)
)
# Create 'channel_webhook' table
op.create_table(
"channel_webhook",
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"channel_id",
sa.Text(),
sa.ForeignKey("channel.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("profile_image_url", sa.Text(), nullable=True),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
pass
def downgrade() -> None:
# Downgrade 'channel' table
op.drop_column("channel", "is_private")
op.drop_column("channel", "archived_at")
op.drop_column("channel", "archived_by")
op.drop_column("channel", "deleted_at")
op.drop_column("channel", "deleted_by")
op.drop_column("channel", "updated_by")
# Downgrade 'channel_member' table
op.drop_column("channel_member", "role")
op.drop_column("channel_member", "invited_by")
op.drop_column("channel_member", "invited_at")
# Drop 'channel_webhook' table
op.drop_table("channel_webhook")
pass
@@ -0,0 +1,45 @@
"""Add skill table
Revision ID: a1b2c3d4e5f6
Revises: f1e2d3c4b5a6
Create Date: 2026-02-11 09:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "f1e2d3c4b5a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
if "skill" not in existing_tables:
op.create_table(
"skill",
sa.Column("id", sa.String(), nullable=False, primary_key=True),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("name", sa.Text(), nullable=False, unique=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
)
op.create_index("idx_skill_user_id", "skill", ["user_id"])
op.create_index("idx_skill_updated_at", "skill", ["updated_at"])
def downgrade() -> None:
op.drop_index("idx_skill_updated_at", table_name="skill")
op.drop_index("idx_skill_user_id", table_name="skill")
op.drop_table("skill")
@@ -0,0 +1,249 @@
"""Update user table
Revision ID: b10670c03dd5
Revises: 2f1211949ecc
Create Date: 2025-11-28 04:55:31.737538
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
import json
import time
# revision identifiers, used by Alembic.
revision: str = "b10670c03dd5"
down_revision: Union[str, None] = "2f1211949ecc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
"""
SQLite requires manual removal of any indexes referencing a column
before ALTER TABLE ... DROP COLUMN can succeed.
"""
indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall()
for idx in indexes:
index_name = idx[1] # index name
# Get indexed columns
idx_info = conn.execute(
sa.text(f"PRAGMA index_info('{index_name}')")
).fetchall()
indexed_cols = [row[2] for row in idx_info] # col names
if column_name in indexed_cols:
conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}"))
def _convert_column_to_json(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == "sqlite":
# 1. Add temporary column
op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True))
# 2. Load old data
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for row in rows:
uid, raw = row
if raw is None:
parsed = None
else:
try:
parsed = json.loads(raw)
except Exception:
parsed = None # fallback safe behavior
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
{"val": json.dumps(parsed) if parsed else None, "id": uid},
)
# 3. Drop old TEXT column
op.drop_column(table, column)
# 4. Rename new JSON column → original name
op.alter_column(table, f"{column}_json", new_column_name=column)
else:
# PostgreSQL supports direct CAST
op.alter_column(
table,
column,
type_=sa.JSON(),
postgresql_using=f"{column}::json",
)
def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
if dialect == "sqlite":
op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True))
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for uid, raw in rows:
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
{"val": json.dumps(raw) if raw else None, "id": uid},
)
op.drop_column(table, column)
op.alter_column(table, f"{column}_text", new_column_name=column)
else:
op.alter_column(
table,
column,
type_=sa.Text(),
postgresql_using=f"to_json({column})::text",
)
def upgrade() -> None:
op.add_column(
"user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True)
)
op.add_column("user", sa.Column("timezone", sa.String(), nullable=True))
op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True))
op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True))
op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True))
op.add_column(
"user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True)
)
op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True))
# Convert info (TEXT/JSONField) → JSON
_convert_column_to_json("user", "info")
# Convert settings (TEXT/JSONField) → JSON
_convert_column_to_json("user", "settings")
op.create_table(
"api_key",
sa.Column("id", sa.Text(), primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")),
sa.Column("key", sa.Text(), unique=True, nullable=False),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("expires_at", sa.BigInteger(), nullable=True),
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
)
conn = op.get_bind()
users = conn.execute(
sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')
).fetchall()
for uid, oauth_sub in users:
if oauth_sub:
# Example formats supported:
# provider@sub
# plain sub (stored as {"oidc": {"sub": sub}})
if "@" in oauth_sub:
provider, sub = oauth_sub.split("@", 1)
else:
provider, sub = "oidc", oauth_sub
oauth_json = json.dumps({provider: {"sub": sub}})
conn.execute(
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
{"oauth": oauth_json, "id": uid},
)
users_with_keys = conn.execute(
sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')
).fetchall()
now = int(time.time())
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text("""
INSERT INTO api_key (id, user_id, key, created_at, updated_at)
VALUES (:id, :user_id, :key, :created_at, :updated_at)
"""),
{
"id": f"key_{uid}",
"user_id": uid,
"key": api_key,
"created_at": now,
"updated_at": now,
},
)
if conn.dialect.name == "sqlite":
_drop_sqlite_indexes_for_column("user", "api_key", conn)
_drop_sqlite_indexes_for_column("user", "oauth_sub", conn)
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("api_key")
batch_op.drop_column("oauth_sub")
def downgrade() -> None:
# --- 1. Restore old oauth_sub column ---
op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True))
conn = op.get_bind()
users = conn.execute(
sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')
).fetchall()
for uid, oauth in users:
try:
data = json.loads(oauth)
provider = list(data.keys())[0]
sub = data[provider].get("sub")
oauth_sub = f"{provider}@{sub}"
except Exception:
oauth_sub = None
conn.execute(
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
{"oauth_sub": oauth_sub, "id": uid},
)
op.drop_column("user", "oauth")
# --- 2. Restore api_key field ---
op.add_column("user", sa.Column("api_key", sa.String(), nullable=True))
# Restore values from api_key
keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall()
for uid, key in keys:
conn.execute(
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
{"key": key, "id": uid},
)
# Drop new table
op.drop_table("api_key")
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("profile_banner_image_url")
batch_op.drop_column("timezone")
batch_op.drop_column("presence_state")
batch_op.drop_column("status_emoji")
batch_op.drop_column("status_message")
batch_op.drop_column("status_expires_at")
# Convert info (JSON) → TEXT
_convert_column_to_text("user", "info")
# Convert settings (JSON) → TEXT
_convert_column_to_text("user", "settings")
@@ -0,0 +1,26 @@
"""add scim column to user table
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-02-13 14:19:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b2c3d4e5f6a7"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("user", "scim")
@@ -12,7 +12,6 @@ import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
revision = "c29facfe716b"
down_revision = "c69f45358db4"
branch_labels = None
@@ -0,0 +1,56 @@
"""Add chat_file table
Revision ID: c440947495f3
Revises: 81cc2ce44d79
Create Date: 2025-12-21 20:27:41.694897
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c440947495f3"
down_revision: Union[str, None] = "81cc2ce44d79"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"chat_id",
sa.Text(),
sa.ForeignKey("chat.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"file_id",
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("message_id", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_chat_file_chat_id", "chat_id"),
sa.Index("ix_chat_file_file_id", "file_id"),
sa.Index("ix_chat_file_message_id", "message_id"),
sa.Index("ix_chat_file_user_id", "user_id"),
# unique constraints
sa.UniqueConstraint(
"chat_id", "file_id", name="uq_chat_file_chat_file"
), # prevent duplicate entries
)
pass
def downgrade() -> None:
op.drop_table("chat_file")
pass
@@ -0,0 +1,369 @@
"""Add access_grant table
Revision ID: f1e2d3c4b5a6
Revises: 8452d01d26d7
Create Date: 2026-02-05 10:00:00.000000
Migrates from JSON access_control columns to normalized access_grant table.
Access control semantics:
- NULL: Public access (all users can read) -> insert user:* for read
- {}: Private/owner-only (no grants) -> insert nothing
- {read: {...}, write: {...}}: Custom permissions -> insert specific grants
"""
from typing import Sequence, Union
import time
import uuid
from alembic import op
import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "f1e2d3c4b5a6"
down_revision: Union[str, None] = "8452d01d26d7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
# Create access_grant table
if "access_grant" not in existing_tables:
op.create_table(
"access_grant",
sa.Column("id", sa.Text(), nullable=False, primary_key=True),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("resource_id", sa.Text(), nullable=False),
sa.Column("principal_type", sa.Text(), nullable=False),
sa.Column("principal_id", sa.Text(), nullable=False),
sa.Column("permission", sa.Text(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.UniqueConstraint(
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission",
name="uq_access_grant_grant",
),
)
op.create_index(
"idx_access_grant_resource",
"access_grant",
["resource_type", "resource_id"],
)
op.create_index(
"idx_access_grant_principal",
"access_grant",
["principal_type", "principal_id"],
)
# Backfill existing access_control JSON data
conn = op.get_bind()
# Tables with access_control JSON columns: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
]
now = int(time.time())
inserted = set()
for table_name, resource_type in resource_tables:
if table_name not in existing_tables:
continue
# Query all rows
try:
result = conn.execute(
sa.text(f'SELECT id, access_control FROM "{table_name}"')
)
rows = result.fetchall()
except Exception:
continue
for row in rows:
resource_id = row[0]
access_control_json = row[1]
# Handle NULL or JSON "null" = public access (user:* for read)
# Could be Python None (SQL NULL) or string "null" (JSON null)
# EXCEPTION: files with NULL are PRIVATE (owner-only), not public
is_null = (
access_control_json is None
or access_control_json == "null"
or (
isinstance(access_control_json, str)
and access_control_json.strip().lower() == "null"
)
)
if is_null:
# Files: NULL = private (no entry needed, owner has implicit access)
# Other resources: NULL = public (insert user:* for read)
if resource_type == "file":
continue # Private - no entry needed
key = (resource_type, resource_id, "user", "*", "read")
if key not in inserted:
try:
conn.execute(
sa.text("""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
continue
# Handle JSON parsing
if isinstance(access_control_json, str):
import json
try:
access_control_json = json.loads(access_control_json)
except Exception:
continue
# Handle {} = private/owner-only - NO entries needed
# Owner access is implicit, no grants to store
if not access_control_json or not isinstance(access_control_json, dict):
continue
# Check if it's effectively empty (no read/write keys with content)
read_data = access_control_json.get("read", {})
write_data = access_control_json.get("write", {})
has_read_grants = read_data.get("group_ids", []) or read_data.get(
"user_ids", []
)
has_write_grants = write_data.get("group_ids", []) or write_data.get(
"user_ids", []
)
if not has_read_grants and not has_write_grants:
# Empty permissions = private, no grants needed
continue
# Extract permissions and insert into access_grant table
for permission in ["read", "write"]:
perm_data = access_control_json.get(permission, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
key = (resource_type, resource_id, "group", group_id, permission)
if key in inserted:
continue
try:
conn.execute(
sa.text("""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "group",
"principal_id": group_id,
"permission": permission,
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
for user_id in perm_data.get("user_ids", []):
key = (resource_type, resource_id, "user", user_id, permission)
if key in inserted:
continue
try:
conn.execute(
sa.text("""
INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at)
VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at)
"""),
{
"id": str(uuid.uuid4()),
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": user_id,
"permission": permission,
"created_at": now,
},
)
inserted.add(key)
except Exception:
pass
# Drop access_control columns from resource tables
for table_name, _ in resource_tables:
if table_name not in existing_tables:
continue
try:
with op.batch_alter_table(table_name) as batch:
batch.drop_column("access_control")
except Exception:
pass
def downgrade() -> None:
import json
conn = op.get_bind()
# Resource tables mapping: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
]
# Step 1: Re-add access_control columns to resource tables
for table_name, _ in resource_tables:
try:
with op.batch_alter_table(table_name) as batch:
batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True))
except Exception:
pass
# Step 2: Query access_grant table and reconstruct JSON for each resource
for table_name, resource_type in resource_tables:
try:
# Get all grants for this resource type
result = conn.execute(
sa.text("""
SELECT resource_id, principal_type, principal_id, permission
FROM access_grant
WHERE resource_type = :resource_type
"""),
{"resource_type": resource_type},
)
rows = result.fetchall()
except Exception:
continue
# Group by resource_id and reconstruct JSON structure
resource_grants = {}
for row in rows:
resource_id = row[0]
principal_type = row[1]
principal_id = row[2]
permission = row[3]
if resource_id not in resource_grants:
resource_grants[resource_id] = {
"is_public": False,
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
}
# Handle public access (user:* for read)
if (
principal_type == "user"
and principal_id == "*"
and permission == "read"
):
resource_grants[resource_id]["is_public"] = True
continue
# Add to appropriate list
if permission in ["read", "write"]:
if principal_type == "group":
if (
principal_id
not in resource_grants[resource_id][permission]["group_ids"]
):
resource_grants[resource_id][permission]["group_ids"].append(
principal_id
)
elif principal_type == "user":
if (
principal_id
not in resource_grants[resource_id][permission]["user_ids"]
):
resource_grants[resource_id][permission]["user_ids"].append(
principal_id
)
# Step 3: Update each resource with reconstructed JSON
for resource_id, grants in resource_grants.items():
if grants["is_public"]:
# Public = NULL
access_control_value = None
elif (
not grants["read"]["group_ids"]
and not grants["read"]["user_ids"]
and not grants["write"]["group_ids"]
and not grants["write"]["user_ids"]
):
# No grants = should not happen (would mean no entries), default to {}
access_control_value = json.dumps({})
else:
# Custom permissions
access_control_value = json.dumps(
{
"read": grants["read"],
"write": grants["write"],
}
)
try:
conn.execute(
sa.text(
f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'
),
{"access_control": access_control_value, "id": resource_id},
)
except Exception:
pass
# Step 4: Set all resources WITHOUT entries to private
# For files: NULL means private (owner-only), so leave as NULL
# For other resources: {} means private, so update to {}
if resource_type != "file":
try:
conn.execute(
sa.text(f"""
UPDATE "{table_name}"
SET access_control = :private_value
WHERE id NOT IN (
SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type
)
AND access_control IS NULL
"""),
{"private_value": json.dumps({}), "resource_type": resource_type},
)
except Exception:
pass
# For files, NULL stays NULL - no action needed
# Step 5: Drop the access_grant table
op.drop_index("idx_access_grant_principal", table_name="access_grant")
op.drop_index("idx_access_grant_resource", table_name="access_grant")
op.drop_table("access_grant")
+838
View File
@@ -0,0 +1,838 @@
import logging
import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
from sqlalchemy.dialects.postgresql import JSONB
log = logging.getLogger(__name__)
####################
# AccessGrant DB Schema
####################
class AccessGrant(Base):
__tablename__ = "access_grant"
id = Column(Text, primary_key=True)
resource_type = Column(
Text, nullable=False
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
resource_id = Column(Text, nullable=False)
principal_type = Column(Text, nullable=False) # "user" or "group"
principal_id = Column(
Text, nullable=False
) # user_id, group_id, or "*" (wildcard for public)
permission = Column(Text, nullable=False) # "read" or "write"
created_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint(
"resource_type",
"resource_id",
"principal_type",
"principal_id",
"permission",
name="uq_access_grant_grant",
),
)
class AccessGrantModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
resource_type: str
resource_id: str
principal_type: str
principal_id: str
permission: str
created_at: int
class AccessGrantResponse(BaseModel):
"""Slim grant model for API responses — resource context is implicit from the parent."""
id: str
principal_type: str
principal_id: str
permission: str
@classmethod
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
return cls(
id=grant.id,
principal_type=grant.principal_type,
principal_id=grant.principal_id,
permission=grant.permission,
)
####################
# Conversion utilities
####################
def access_control_to_grants(
resource_type: str,
resource_id: str,
access_control: Optional[dict],
) -> list[dict]:
"""
Convert an old-style access_control JSON dict to a flat list of grant dicts.
Semantics:
- None → public read (user:* read) — except files which are private
- {} → private/owner-only (no grants)
- {read: {group_ids, user_ids}, write: {group_ids, user_ids}} → specific grants
Returns a list of dicts with keys: resource_type, resource_id, principal_type, principal_id, permission
"""
grants = []
if access_control is None:
# NULL → public read (user:* for read)
# Exception: files with NULL are private (owner-only), no grants needed
if resource_type != "file":
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
}
)
return grants
# {} → private/owner-only, no grants
if not access_control:
return grants
# Parse structured permissions
for permission in ["read", "write"]:
perm_data = access_control.get(permission, {})
if not perm_data:
continue
for group_id in perm_data.get("group_ids", []):
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "group",
"principal_id": group_id,
"permission": permission,
}
)
for user_id in perm_data.get("user_ids", []):
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": user_id,
"permission": permission,
}
)
return grants
def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
"""
Normalize direct access_grants payloads from API forms.
Keeps only valid grants and removes duplicates by
(principal_type, principal_id, permission).
"""
if not access_grants:
return []
deduped = {}
for grant in access_grants:
if isinstance(grant, BaseModel):
grant = grant.model_dump()
if not isinstance(grant, dict):
continue
principal_type = grant.get("principal_type")
principal_id = grant.get("principal_id")
permission = grant.get("permission")
if principal_type not in ("user", "group"):
continue
if permission not in ("read", "write"):
continue
if not isinstance(principal_id, str) or not principal_id:
continue
key = (principal_type, principal_id, permission)
deduped[key] = {
"id": (
grant.get("id")
if isinstance(grant.get("id"), str) and grant.get("id")
else str(uuid.uuid4())
),
"principal_type": principal_type,
"principal_id": principal_id,
"permission": permission,
}
return list(deduped.values())
def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes wildcard public-read.
"""
for grant in normalize_access_grants(access_grants):
if (
grant["principal_type"] == "user"
and grant["principal_id"] == "*"
and grant["permission"] == "read"
):
return True
return False
def grants_to_access_control(grants: list) -> Optional[dict]:
"""
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
back to the old-style access_control JSON dict for backward compatibility.
Semantics:
- [] (empty) → {} (private/owner-only)
- Contains user:*:read → None (public), but write grants are preserved
- Otherwise → {read: {group_ids, user_ids}, write: {group_ids, user_ids}}
Note: "public" (user:*:read) still allows additional write permissions
to coexist. When the wildcard read is present the function returns None
for the legacy dict, so callers that need write info should inspect the
grants list directly.
"""
if not grants:
return {} # No grants = private/owner-only
result = {
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
}
is_public = False
for grant in grants:
if (
grant.principal_type == "user"
and grant.principal_id == "*"
and grant.permission == "read"
):
is_public = True
continue # Don't add wildcard to user_ids list
if grant.permission not in ("read", "write"):
continue
if grant.principal_type == "group":
if grant.principal_id not in result[grant.permission]["group_ids"]:
result[grant.permission]["group_ids"].append(grant.principal_id)
elif grant.principal_type == "user":
if grant.principal_id not in result[grant.permission]["user_ids"]:
result[grant.permission]["user_ids"].append(grant.principal_id)
if is_public:
return None # Public read access
return result
####################
# Table Operations
####################
class AccessGrantsTable:
def grant_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
) -> Optional[AccessGrantModel]:
"""Add a single access grant. Idempotent (ignores duplicates)."""
with get_db_context(db) as db:
# Check for existing grant
existing = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.first()
)
if existing:
return AccessGrantModel.model_validate(existing)
grant = AccessGrant(
id=str(uuid.uuid4()),
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
created_at=int(time.time()),
)
db.add(grant)
db.commit()
db.refresh(grant)
return AccessGrantModel.model_validate(grant)
def revoke_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
) -> bool:
"""Remove a single access grant."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.delete()
)
db.commit()
return deleted > 0
def revoke_all_access(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> int:
"""Remove all access grants for a resource."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.delete()
)
db.commit()
return deleted
def set_access_control(
self,
resource_type: str,
resource_id: str,
access_control: Optional[dict],
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""
Replace all grants for a resource from an access_control JSON dict.
This is the primary bridge for backward compat with the frontend.
"""
with get_db_context(db) as db:
# Delete all existing grants for this resource
db.query(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
).delete()
# Convert JSON to grant dicts
grant_dicts = access_control_to_grants(
resource_type, resource_id, access_control
)
# Insert new grants
results = []
for grant_dict in grant_dicts:
grant = AccessGrant(
id=str(uuid.uuid4()),
**grant_dict,
created_at=int(time.time()),
)
db.add(grant)
results.append(grant)
db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def set_access_grants(
self,
resource_type: str,
resource_id: str,
access_grants: Optional[list],
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""
Replace all grants for a resource from a direct access_grants list.
"""
with get_db_context(db) as db:
db.query(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
).delete()
normalized_grants = normalize_access_grants(access_grants)
results = []
for grant_dict in normalized_grants:
grant = AccessGrant(
id=grant_dict["id"],
resource_type=resource_type,
resource_id=resource_id,
principal_type=grant_dict["principal_type"],
principal_id=grant_dict["principal_id"],
permission=grant_dict["permission"],
created_at=int(time.time()),
)
db.add(grant)
results.append(grant)
db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def get_access_control(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> Optional[dict]:
"""
Reconstruct the old-style access_control JSON dict from grants.
For backward compat with the frontend.
"""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
return grants_to_access_control(grant_models)
def get_grants_by_resource(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
) -> list[AccessGrantModel]:
"""Get all grants for a specific resource."""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
return [AccessGrantModel.model_validate(g) for g in grants]
def has_access(
self,
user_id: str,
resource_type: str,
resource_id: str,
permission: str = "read",
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
) -> bool:
"""
Check if a user has the specified permission on a resource.
Access is granted if any of the following is true:
- There's a grant for user:* (public) with the requested permission
- There's a grant for the specific user with the requested permission
- There's a grant for any of the user's groups with the requested permission
"""
with get_db_context(db) as db:
# Build conditions for matching grants
conditions = [
# Public access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
# Direct user access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
),
]
# Group access
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(user_group_ids),
)
)
exists = (
db.query(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.permission == permission,
or_(*conditions),
)
.first()
)
return exists is not None
def get_accessible_resource_ids(
self,
user_id: str,
resource_type: str,
resource_ids: list[str],
permission: str = "read",
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
) -> set[str]:
"""
Batch check: return the subset of resource_ids that the user can access.
This replaces calling has_access() in a loop (N+1) with a single query.
"""
if not resource_ids:
return set()
with get_db_context(db) as db:
conditions = [
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
),
]
if user_group_ids is None:
from open_webui.models.groups import Groups
user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if user_group_ids:
conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(user_group_ids),
)
)
rows = (
db.query(AccessGrant.resource_id)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id.in_(resource_ids),
AccessGrant.permission == permission,
or_(*conditions),
)
.distinct()
.all()
)
return {row[0] for row in rows}
def get_users_with_access(
self,
resource_type: str,
resource_id: str,
permission: str = "read",
db: Optional[Session] = None,
) -> list:
"""
Get all users who have the specified permission on a resource.
Returns a list of UserModel instances.
"""
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
resource_type=resource_type,
resource_id=resource_id,
permission=permission,
)
.all()
)
# Check for public access
for grant in grants:
if grant.principal_type == "user" and grant.principal_id == "*":
result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
return result.get("users", [])
user_ids_with_access = set()
for grant in grants:
if grant.principal_type == "user":
user_ids_with_access.add(grant.principal_id)
elif grant.principal_type == "group":
group_user_ids = Groups.get_group_user_ids_by_id(
grant.principal_id, db=db
)
if group_user_ids:
user_ids_with_access.update(group_user_ids)
if not user_ids_with_access:
return []
return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
def has_permission_filter(
self,
db,
query,
DocumentModel,
filter: dict,
resource_type: str,
permission: str = "read",
):
"""
Apply access control filtering to a SQLAlchemy query by JOINing with access_grant.
This replaces the old JSON-column-based filtering with a proper relational JOIN.
"""
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
if permission == "read_only":
return self._has_read_only_permission_filter(
db, query, DocumentModel, filter, resource_type
)
# Build principal conditions
principal_conditions = []
if group_ids or user_id:
# Public access: user:* read
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
)
)
if user_id:
# Owner always has access
principal_conditions.append(DocumentModel.user_id == user_id)
# Direct user grant
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
)
if group_ids:
# Group grants
principal_conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
)
if not principal_conditions:
return query
# LEFT JOIN access_grant and filter
# We use a subquery approach to avoid duplicates from multiple matching grants
from sqlalchemy import exists as sa_exists, select
grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == permission,
or_(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
),
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Owner OR has a matching grant
owner_or_grant = [grant_exists]
if user_id:
owner_or_grant.append(DocumentModel.user_id == user_id)
query = query.filter(or_(*owner_or_grant))
return query
def _has_read_only_permission_filter(
self,
db,
query,
DocumentModel,
filter: dict,
resource_type: str,
):
"""
Filter for items where user has read BUT NOT write access.
Public items are NOT considered read_only.
"""
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
from sqlalchemy import exists as sa_exists, select
# Has read grant (not public)
read_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Does NOT have write grant
write_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "write",
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == user_id,
)
]
if user_id
else []
),
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_id.in_(group_ids),
)
]
if group_ids
else []
),
),
)
.correlate(DocumentModel)
.exists()
)
# Is NOT public
public_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
)
.correlate(DocumentModel)
.exists()
)
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
# Not owner
if user_id:
conditions.append(DocumentModel.user_id != user_id)
query = query.filter(and_(*conditions))
return query
AccessGrants = AccessGrantsTable()
+50 -35
View File
@@ -2,14 +2,14 @@ import logging
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.users import UserModel, Users
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, field_validator
from sqlalchemy import Boolean, Column, String, Text
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# DB MODEL
@@ -46,15 +46,7 @@ class ApiKey(BaseModel):
api_key: Optional[str] = None
class UserResponse(BaseModel):
id: str
email: str
name: str
role: str
profile_image_url: str
class SigninResponse(Token, UserResponse):
class SigninResponse(Token, UserProfileImageResponse):
pass
@@ -83,6 +75,13 @@ class SignupForm(BaseModel):
password: str
profile_image_url: Optional[str] = "/user.png"
@field_validator("profile_image_url")
@classmethod
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
if v is not None:
return validate_profile_image_url(v)
return v
class AddUserForm(SignupForm):
role: Optional[str] = "pending"
@@ -96,9 +95,10 @@ class AuthsTable:
name: str,
profile_image_url: str = "/user.png",
role: str = "pending",
oauth_sub: Optional[str] = None,
oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]:
with get_db() as db:
with get_db_context(db) as db:
log.info("insert_new_auth")
id = str(uuid.uuid4())
@@ -110,7 +110,7 @@ class AuthsTable:
db.add(result)
user = Users.insert_new_user(
id, name, email, profile_image_url, role, oauth_sub
id, name, email, profile_image_url, role, oauth=oauth, db=db
)
db.commit()
@@ -122,16 +122,16 @@ class AuthsTable:
return None
def authenticate_user(
self, email: str, verify_password: callable
self, email: str, verify_password: callable, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}")
user = Users.get_user_by_email(email)
user = Users.get_user_by_email(email, db=db)
if not user:
return None
try:
with get_db() as db:
with get_db_context(db) as db:
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
if auth:
if verify_password(auth.password):
@@ -143,32 +143,45 @@ class AuthsTable:
except Exception:
return None
def authenticate_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
def authenticate_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_api_key: {api_key}")
# if no api_key, return None
if not api_key:
return None
try:
user = Users.get_user_by_api_key(api_key)
user = Users.get_user_by_api_key(api_key, db=db)
return user if user else None
except Exception:
return False
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]:
def authenticate_user_by_email(
self, email: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_email: {email}")
try:
with get_db() as db:
auth = db.query(Auth).filter_by(email=email, active=True).first()
if auth:
user = Users.get_user_by_id(auth.id)
return user
with get_db_context(db) as db:
# Single JOIN query instead of two separate queries
result = (
db.query(Auth, User)
.join(User, Auth.id == User.id)
.filter(Auth.email == email, Auth.active == True)
.first()
)
if result:
_, user = result
return UserModel.model_validate(user)
return None
except Exception:
return None
def update_user_password_by_id(self, id: str, new_password: str) -> bool:
def update_user_password_by_id(
self, id: str, new_password: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
result = (
db.query(Auth).filter_by(id=id).update({"password": new_password})
)
@@ -177,20 +190,22 @@ class AuthsTable:
except Exception:
return False
def update_email_by_id(self, id: str, email: str) -> bool:
def update_email_by_id(
self, id: str, email: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
result = db.query(Auth).filter_by(id=id).update({"email": email})
db.commit()
return True if result == 1 else False
except Exception:
return False
def delete_auth_by_id(self, id: str) -> bool:
def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
# Delete User
result = Users.delete_user_by_id(id)
result = Users.delete_user_by_id(id, db=db)
if result:
db.query(Auth).filter_by(id=id).delete()
File diff suppressed because it is too large Load Diff
+644
View File
@@ -0,0 +1,644 @@
import json
import time
import uuid
from typing import Any, Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
BigInteger,
Boolean,
Column,
ForeignKey,
Text,
JSON,
Index,
func,
)
####################
# Helpers
####################
def _normalize_timestamp(timestamp: int) -> float:
"""Normalize and validate timestamp. Returns current time if invalid."""
now = time.time()
# Convert milliseconds to seconds if needed
if timestamp > 10_000_000_000:
timestamp = timestamp / 1000
# Validate: must be after 2020 and not in the future (with 1 day tolerance)
min_valid = 1577836800 # 2020-01-01 00:00:00 UTC
max_valid = now + 86400 # 1 day in the future (clock skew tolerance)
if timestamp < min_valid or timestamp > max_valid:
return now
return timestamp
####################
# ChatMessage DB Schema
####################
class ChatMessage(Base):
__tablename__ = "chat_message"
# Identity
id = Column(Text, primary_key=True)
chat_id = Column(
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id = Column(Text, index=True)
# Structure
role = Column(Text, nullable=False) # user, assistant, system
parent_id = Column(Text, nullable=True)
# Content
content = Column(JSON, nullable=True) # Can be str or list of blocks
output = Column(JSON, nullable=True)
# Model (for assistant messages)
model_id = Column(Text, nullable=True, index=True)
# Attachments
files = Column(JSON, nullable=True)
sources = Column(JSON, nullable=True)
embeds = Column(JSON, nullable=True)
# Status
done = Column(Boolean, default=True)
status_history = Column(JSON, nullable=True)
error = Column(JSON, nullable=True)
# Usage (tokens, timing, etc.)
usage = Column(JSON, nullable=True)
# Timestamps
created_at = Column(BigInteger, index=True)
updated_at = Column(BigInteger)
__table_args__ = (
Index("chat_message_chat_parent_idx", "chat_id", "parent_id"),
Index("chat_message_model_created_idx", "model_id", "created_at"),
Index("chat_message_user_created_idx", "user_id", "created_at"),
)
####################
# Pydantic Models
####################
class ChatMessageModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
chat_id: str
user_id: str
role: str
parent_id: Optional[str] = None
content: Optional[Any] = None # str or list of blocks
output: Optional[list] = None
model_id: Optional[str] = None
files: Optional[list] = None
sources: Optional[list] = None
embeds: Optional[list] = None
done: bool = True
status_history: Optional[list] = None
error: Optional[dict | str] = None
usage: Optional[dict] = None
created_at: int
updated_at: int
####################
# Table Operations
####################
class ChatMessageTable:
def upsert_message(
self,
message_id: str,
chat_id: str,
user_id: str,
data: dict,
db: Optional[Session] = None,
) -> Optional[ChatMessageModel]:
"""Insert or update a chat message."""
with get_db_context(db) as db:
now = int(time.time())
timestamp = data.get("timestamp", now)
# Use composite ID: {chat_id}-{message_id}
composite_id = f"{chat_id}-{message_id}"
existing = db.get(ChatMessage, composite_id)
if existing:
# Update existing
if "role" in data:
existing.role = data["role"]
if "parent_id" in data:
existing.parent_id = data.get("parent_id") or data.get("parentId")
if "content" in data:
existing.content = data.get("content")
if "output" in data:
existing.output = data.get("output")
if "model_id" in data or "model" in data:
existing.model_id = data.get("model_id") or data.get("model")
if "files" in data:
existing.files = data.get("files")
if "sources" in data:
existing.sources = data.get("sources")
if "embeds" in data:
existing.embeds = data.get("embeds")
if "done" in data:
existing.done = data.get("done", True)
if "status_history" in data or "statusHistory" in data:
existing.status_history = data.get("status_history") or data.get(
"statusHistory"
)
if "error" in data:
existing.error = data.get("error")
# Extract usage - check direct field first, then info.usage
usage = data.get("usage")
if not usage:
info = data.get("info", {})
usage = info.get("usage") if info else None
if usage:
existing.usage = usage
existing.updated_at = now
db.commit()
db.refresh(existing)
return ChatMessageModel.model_validate(existing)
else:
# Insert new
# Extract usage - check direct field first, then info.usage
usage = data.get("usage")
if not usage:
info = data.get("info", {})
usage = info.get("usage") if info else None
message = ChatMessage(
id=composite_id,
chat_id=chat_id,
user_id=user_id,
role=data.get("role", "user"),
parent_id=data.get("parent_id") or data.get("parentId"),
content=data.get("content"),
output=data.get("output"),
model_id=data.get("model_id") or data.get("model"),
files=data.get("files"),
sources=data.get("sources"),
embeds=data.get("embeds"),
done=data.get("done", True),
status_history=data.get("status_history")
or data.get("statusHistory"),
error=data.get("error"),
usage=usage,
created_at=timestamp,
updated_at=now,
)
db.add(message)
db.commit()
db.refresh(message)
return ChatMessageModel.model_validate(message)
def get_message_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatMessageModel]:
with get_db_context(db) as db:
message = db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
def get_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
.filter_by(chat_id=chat_id)
.order_by(ChatMessage.created_at.asc())
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_user_id(
self,
user_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
.filter_by(user_id=user_id)
.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_model_id(
self,
model_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
query = db.query(ChatMessage).filter_by(model_id=model_id)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
messages = (
query.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [ChatMessageModel.model_validate(message) for message in messages]
def get_chat_ids_by_model_id(
self,
model_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[str]:
"""Get distinct chat_ids that used a specific model."""
with get_db_context(db) as db:
query = db.query(
ChatMessage.chat_id,
func.max(ChatMessage.created_at).label("last_message_at"),
).filter(ChatMessage.model_id == model_id)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
# Group by chat_id and order by most recent message in each chat
chat_ids = (
query.group_by(ChatMessage.chat_id)
.order_by(func.max(ChatMessage.created_at).desc())
.offset(skip)
.limit(limit)
.all()
)
return [chat_id for chat_id, _ in chat_ids]
def delete_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
db.query(ChatMessage).filter_by(chat_id=chat_id).delete()
db.commit()
return True
# Analytics methods
def get_message_count_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
return {row.model_id: row.count for row in results}
def get_token_usage_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by model using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
from open_webui.models.groups import GroupMember
dialect = db.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":
# Use json_extract_path_text for PostgreSQL JSON columns
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}")
query = db.query(
ChatMessage.model_id,
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
func.count(ChatMessage.id).label("message_count"),
).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
return {
row.model_id: {
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"total_tokens": row.input_tokens + row.output_tokens,
"message_count": row.message_count,
}
for row in results
}
def get_token_usage_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict]:
"""Aggregate token usage by user using database-level aggregation."""
with get_db_context(db) as db:
from sqlalchemy import func, cast, Integer
dialect = db.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":
# Use json_extract_path_text for PostgreSQL JSON columns
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}")
query = db.query(
ChatMessage.user_id,
func.coalesce(func.sum(input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(output_tokens), 0).label("output_tokens"),
func.count(ChatMessage.id).label("message_count"),
).filter(
ChatMessage.role == "assistant",
ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.group_by(ChatMessage.user_id).all()
return {
row.user_id: {
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"total_tokens": row.input_tokens + row.output_tokens,
"message_count": row.message_count,
}
for row in results
}
def get_message_count_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.user_id, func.count(ChatMessage.id).label("count")
).filter(~ChatMessage.user_id.like("shared-%"))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.user_id).all()
return {row.user_id: row.count for row in results}
def get_message_count_by_chat(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
).filter(~ChatMessage.user_id.like("shared-%"))
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.chat_id).all()
return {row.chat_id: row.count for row in results}
def get_daily_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by day and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = (
db.query(GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.subquery()
)
query = query.filter(ChatMessage.user_id.in_(group_users))
results = query.all()
# Group by date -> model -> count
daily_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
date_str = datetime.fromtimestamp(
_normalize_timestamp(timestamp)
).strftime("%Y-%m-%d")
if date_str not in daily_counts:
daily_counts[date_str] = {}
daily_counts[date_str][model_id] = (
daily_counts[date_str].get(model_id, 0) + 1
)
# Fill in missing days
if start_date and end_date:
current = datetime.fromtimestamp(_normalize_timestamp(start_date))
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
if date_str not in daily_counts:
daily_counts[date_str] = {}
current += timedelta(days=1)
return daily_counts
def get_hourly_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by hour and model."""
with get_db_context(db) as db:
from datetime import datetime, timedelta
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
results = query.all()
# Group by hour -> model -> count
hourly_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
hour_str = datetime.fromtimestamp(
_normalize_timestamp(timestamp)
).strftime("%Y-%m-%d %H:00")
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
hourly_counts[hour_str][model_id] = (
hourly_counts[hour_str].get(model_id, 0) + 1
)
# Fill in missing hours
if start_date and end_date:
current = datetime.fromtimestamp(
_normalize_timestamp(start_date)
).replace(minute=0, second=0, microsecond=0)
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date))
while current <= end_dt:
hour_str = current.strftime("%Y-%m-%d %H:00")
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
current += timedelta(hours=1)
return hourly_counts
ChatMessages = ChatMessageTable()
File diff suppressed because it is too large Load Diff
+192 -41
View File
@@ -3,15 +3,14 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import User
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
@@ -62,6 +61,20 @@ class FeedbackResponse(BaseModel):
updated_at: int
class FeedbackIdResponse(BaseModel):
id: str
user_id: str
created_at: int
updated_at: int
class LeaderboardFeedbackData(BaseModel):
"""Minimal feedback data for leaderboard computation (excludes snapshot/meta)."""
id: str
data: Optional[dict] = None
class RatingData(BaseModel):
rating: Optional[str | int] = None
model_id: Optional[str] = None
@@ -114,11 +127,22 @@ class FeedbackListResponse(BaseModel):
total: int
class ModelHistoryEntry(BaseModel):
date: str
won: int
lost: int
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
class FeedbackTable:
def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm
self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
with get_db() as db:
with get_db_context(db) as db:
id = str(uuid.uuid4())
feedback = FeedbackModel(
**{
@@ -143,9 +167,11 @@ class FeedbackTable:
log.exception(f"Error creating a new feedback: {e}")
return None
def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]:
def get_feedback_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback:
return None
@@ -154,10 +180,10 @@ class FeedbackTable:
return None
def get_feedback_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback:
return None
@@ -165,10 +191,31 @@ class FeedbackTable:
except Exception:
return None
def get_feedbacks_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
"""Get all feedbacks for a specific chat."""
try:
with get_db_context(db) as db:
# meta.chat_id stores the chat reference
feedbacks = (
db.query(Feedback)
.filter(Feedback.meta["chat_id"].as_string() == chat_id)
.order_by(Feedback.created_at.desc())
.all()
)
return [FeedbackModel.model_validate(fb) for fb in feedbacks]
except Exception:
return []
def get_feedback_items(
self, filter: dict = {}, skip: int = 0, limit: int = 30
self,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> FeedbackListResponse:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
if filter:
@@ -229,8 +276,8 @@ class FeedbackTable:
return FeedbackListResponse(items=feedbacks, total=total)
def get_all_feedbacks(self) -> list[FeedbackModel]:
with get_db() as db:
def get_all_feedbacks(self, db: Optional[Session] = None) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [
FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback)
@@ -238,8 +285,110 @@ class FeedbackTable:
.all()
]
def get_feedbacks_by_type(self, type: str) -> list[FeedbackModel]:
with get_db() as db:
def get_all_feedback_ids(
self, db: Optional[Session] = None
) -> list[FeedbackIdResponse]:
with get_db_context(db) as db:
return [
FeedbackIdResponse(
id=row.id,
user_id=row.user_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in db.query(
Feedback.id,
Feedback.user_id,
Feedback.created_at,
Feedback.updated_at,
)
.order_by(Feedback.updated_at.desc())
.all()
]
def get_feedbacks_for_leaderboard(
self, db: Optional[Session] = None
) -> list[LeaderboardFeedbackData]:
"""Fetch only id and data for leaderboard computation (excludes snapshot/meta)."""
with get_db_context(db) as db:
return [
LeaderboardFeedbackData(id=row.id, data=row.data)
for row in db.query(Feedback.id, Feedback.data).all()
]
def get_model_evaluation_history(
self, model_id: str, days: int = 30, db: Optional[Session] = None
) -> list[ModelHistoryEntry]:
"""
Get daily wins/losses for a specific model over the past N days.
If days=0, returns all time data starting from first feedback.
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
"""
from datetime import datetime, timedelta
from collections import defaultdict
with get_db_context(db) as db:
if days == 0:
# All time - no cutoff
rows = db.query(Feedback.created_at, Feedback.data).all()
else:
cutoff = int(time.time()) - (days * 86400)
rows = (
db.query(Feedback.created_at, Feedback.data)
.filter(Feedback.created_at >= cutoff)
.all()
)
daily_counts = defaultdict(lambda: {"won": 0, "lost": 0})
first_date = None
for created_at, data in rows:
if not data:
continue
if data.get("model_id") != model_id:
continue
rating_str = str(data.get("rating", ""))
if rating_str not in ("1", "-1"):
continue
date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d")
if rating_str == "1":
daily_counts[date_str]["won"] += 1
else:
daily_counts[date_str]["lost"] += 1
# Track first date for this model
if first_date is None or date_str < first_date:
first_date = date_str
# Generate date range
result = []
today = datetime.now().date()
if days == 0 and first_date:
# All time: start from first feedback date
start_date = datetime.strptime(first_date, "%Y-%m-%d").date()
num_days = (today - start_date).days + 1
else:
# Fixed range
num_days = days
start_date = today - timedelta(days=days - 1)
for i in range(num_days):
d = start_date + timedelta(days=i)
date_str = d.strftime("%Y-%m-%d")
counts = daily_counts.get(date_str, {"won": 0, "lost": 0})
result.append(
ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"])
)
return result
def get_feedbacks_by_type(
self, type: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [
FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback)
@@ -248,8 +397,10 @@ class FeedbackTable:
.all()
]
def get_feedbacks_by_user_id(self, user_id: str) -> list[FeedbackModel]:
with get_db() as db:
def get_feedbacks_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [
FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback)
@@ -259,9 +410,9 @@ class FeedbackTable:
]
def update_feedback_by_id(
self, id: str, form_data: FeedbackForm
self, id: str, form_data: FeedbackForm, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback:
return None
@@ -279,9 +430,13 @@ class FeedbackTable:
return FeedbackModel.model_validate(feedback)
def update_feedback_by_id_and_user_id(
self, id: str, user_id: str, form_data: FeedbackForm
self,
id: str,
user_id: str,
form_data: FeedbackForm,
db: Optional[Session] = None,
) -> Optional[FeedbackModel]:
with get_db() as db:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback:
return None
@@ -298,8 +453,8 @@ class FeedbackTable:
db.commit()
return FeedbackModel.model_validate(feedback)
def delete_feedback_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback:
return False
@@ -307,8 +462,10 @@ class FeedbackTable:
db.commit()
return True
def delete_feedback_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
def delete_feedback_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback:
return False
@@ -316,25 +473,19 @@ class FeedbackTable:
db.commit()
return True
def delete_feedbacks_by_user_id(self, user_id: str) -> bool:
with get_db() as db:
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
if not feedbacks:
return False
for feedback in feedbacks:
db.delete(feedback)
def delete_feedbacks_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
result = db.query(Feedback).filter_by(user_id=user_id).delete()
db.commit()
return True
return result > 0
def delete_all_feedbacks(self) -> bool:
with get_db() as db:
feedbacks = db.query(Feedback).all()
if not feedbacks:
return False
for feedback in feedbacks:
db.delete(feedback)
def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
result = db.query(Feedback).delete()
db.commit()
return True
return result > 0
Feedbacks = FeedbackTable()
+156 -46
View File
@@ -2,13 +2,12 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import BigInteger, Column, String, Text, JSON
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# Files DB Schema
@@ -27,8 +26,6 @@ class File(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -46,8 +43,6 @@ class FileModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: Optional[int] # timestamp in epoch
updated_at: Optional[int] # timestamp in epoch
@@ -64,6 +59,25 @@ class FileMeta(BaseModel):
model_config = ConfigDict(extra="allow")
@model_validator(mode="before")
@classmethod
def sanitize_meta(cls, data):
"""Sanitize metadata fields to handle malformed legacy data."""
if not isinstance(data, dict):
return data
# Handle content_type that may be a list like ['application/pdf', None]
content_type = data.get("content_type")
if isinstance(content_type, list):
# Extract first non-None string value
data["content_type"] = next(
(item for item in content_type if isinstance(item, str)), None
)
elif content_type is not None and not isinstance(content_type, str):
data["content_type"] = None
return data
class FileModelResponse(BaseModel):
id: str
@@ -75,7 +89,7 @@ class FileModelResponse(BaseModel):
meta: FileMeta
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
model_config = ConfigDict(extra="allow")
@@ -83,7 +97,7 @@ class FileModelResponse(BaseModel):
class FileMetadataResponse(BaseModel):
id: str
hash: Optional[str] = None
meta: dict
meta: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -95,7 +109,6 @@ class FileForm(BaseModel):
path: str
data: dict = {}
meta: dict = {}
access_control: Optional[dict] = None
class FileUpdateForm(BaseModel):
@@ -104,9 +117,16 @@ class FileUpdateForm(BaseModel):
meta: Optional[dict] = None
class FileListResponse(BaseModel):
items: list[FileModel]
total: int
class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
with get_db() as db:
def insert_new_file(
self, user_id: str, form_data: FileForm, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
file = FileModel(
**{
**form_data.model_dump(),
@@ -129,16 +149,23 @@ class FilesTable:
log.exception(f"Error inserting a new file: {e}")
return None
def get_file_by_id(self, id: str) -> Optional[FileModel]:
with get_db() as db:
try:
file = db.get(File, id)
return FileModel.model_validate(file)
except Exception:
return None
def get_file_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FileModel]:
try:
with get_db_context(db) as db:
try:
file = db.get(File, id)
return FileModel.model_validate(file)
except Exception:
return None
except Exception:
return None
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
with get_db() as db:
def get_file_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id, user_id=user_id).first()
if file:
@@ -148,8 +175,10 @@ class FilesTable:
except Exception:
return None
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
with get_db() as db:
def get_file_metadata_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FileMetadataResponse]:
with get_db_context(db) as db:
try:
file = db.get(File, id)
return FileMetadataResponse(
@@ -162,12 +191,14 @@ class FilesTable:
except Exception:
return None
def get_files(self) -> list[FileModel]:
with get_db() as db:
def get_files(self, db: Optional[Session] = None) -> list[FileModel]:
with get_db_context(db) as db:
return [FileModel.model_validate(file) for file in db.query(File).all()]
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
file = self.get_file_by_id(id)
def check_access_by_user_id(
self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
file = self.get_file_by_id(id, db=db)
if not file:
return False
if file.user_id == user_id:
@@ -175,8 +206,10 @@ class FilesTable:
# Implement additional access control logic here as needed
return False
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
with get_db() as db:
def get_files_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FileModel]:
with get_db_context(db) as db:
return [
FileModel.model_validate(file)
for file in db.query(File)
@@ -185,8 +218,10 @@ class FilesTable:
.all()
]
def get_file_metadatas_by_ids(self, ids: list[str]) -> list[FileMetadataResponse]:
with get_db() as db:
def get_file_metadatas_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FileMetadataResponse]:
with get_db_context(db) as db:
return [
FileMetadataResponse(
id=file.id,
@@ -203,17 +238,81 @@ class FilesTable:
.all()
]
def get_files_by_user_id(self, user_id: str) -> list[FileModel]:
with get_db() as db:
def get_files_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[FileModel]:
with get_db_context(db) as db:
return [
FileModel.model_validate(file)
for file in db.query(File).filter_by(user_id=user_id).all()
]
@staticmethod
def _glob_to_like_pattern(glob: str) -> str:
"""
Convert a glob/fnmatch pattern to a SQL LIKE pattern.
Escapes SQL special characters and converts glob wildcards:
- `*` becomes `%` (match any sequence of characters)
- `?` becomes `_` (match exactly one character)
Args:
glob: A glob pattern (e.g., "*.txt", "file?.doc")
Returns:
A SQL LIKE compatible pattern with proper escaping.
"""
# Escape SQL special characters first, then convert glob wildcards
pattern = glob.replace("\\", "\\\\")
pattern = pattern.replace("%", "\\%")
pattern = pattern.replace("_", "\\_")
pattern = pattern.replace("*", "%")
pattern = pattern.replace("?", "_")
return pattern
def search_files(
self,
user_id: Optional[str] = None,
filename: str = "*",
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
) -> list[FileModel]:
"""
Search files with glob pattern matching, optional user filter, and pagination.
Args:
user_id: Filter by user ID. If None, returns files for all users.
filename: Glob pattern to match filenames (e.g., "*.txt"). Default "*" matches all.
skip: Number of results to skip for pagination.
limit: Maximum number of results to return.
db: Optional database session.
Returns:
List of matching FileModel objects, ordered by updated_at descending.
"""
with get_db_context(db) as db:
query = db.query(File)
if user_id:
query = query.filter_by(user_id=user_id)
pattern = self._glob_to_like_pattern(filename)
if pattern != "%":
query = query.filter(File.filename.ilike(pattern, escape="\\"))
return [
FileModel.model_validate(file)
for file in query.order_by(File.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
]
def update_file_by_id(
self, id: str, form_data: FileUpdateForm
self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db() as db:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
@@ -233,40 +332,51 @@ class FilesTable:
log.exception(f"Error updating file completely by id: {e}")
return None
def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]:
with get_db() as db:
def update_file_hash_by_id(
self, id: str, hash: Optional[str], db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
file.hash = hash
file.updated_at = int(time.time())
db.commit()
return FileModel.model_validate(file)
except Exception:
return None
def update_file_data_by_id(self, id: str, data: dict) -> Optional[FileModel]:
with get_db() as db:
def update_file_data_by_id(
self, id: str, data: dict, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data}
file.updated_at = int(time.time())
db.commit()
return FileModel.model_validate(file)
except Exception as e:
return None
def update_file_metadata_by_id(self, id: str, meta: dict) -> Optional[FileModel]:
with get_db() as db:
def update_file_metadata_by_id(
self, id: str, meta: dict, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta}
file.updated_at = int(time.time())
db.commit()
return FileModel.model_validate(file)
except Exception:
return None
def delete_file_by_id(self, id: str) -> bool:
with get_db() as db:
return False
def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(File).filter_by(id=id).delete()
db.commit()
@@ -275,8 +385,8 @@ class FilesTable:
except Exception:
return False
def delete_all_files(self) -> bool:
with get_db() as db:
def delete_all_files(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(File).delete()
db.commit()
+45 -30
View File
@@ -7,13 +7,11 @@ import re
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
@@ -85,9 +83,13 @@ class FolderUpdateForm(BaseModel):
class FolderTable:
def insert_new_folder(
self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None
self,
user_id: str,
form_data: FolderForm,
parent_id: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
with get_db() as db:
with get_db_context(db) as db:
id = str(uuid.uuid4())
folder = FolderModel(
**{
@@ -113,10 +115,10 @@ class FolderTable:
return None
def get_folder_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@@ -127,15 +129,15 @@ class FolderTable:
return None
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[list[FolderModel]]:
try:
with get_db() as db:
with get_db_context(db) as db:
folders = []
def get_children(folder):
children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id
folder.id, user_id, db=db
)
for child in children:
get_children(child)
@@ -150,18 +152,24 @@ class FolderTable:
except Exception:
return None
def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]:
with get_db() as db:
def get_folders_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[FolderModel]:
with get_db_context(db) as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder).filter_by(user_id=user_id).all()
]
def get_folder_by_parent_id_and_user_id_and_name(
self, parent_id: Optional[str], user_id: str, name: str
self,
parent_id: Optional[str],
user_id: str,
name: str,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# Check if folder exists
folder = (
db.query(Folder)
@@ -179,9 +187,9 @@ class FolderTable:
return None
def get_folders_by_parent_id_and_user_id(
self, parent_id: Optional[str], user_id: str
self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None
) -> list[FolderModel]:
with get_db() as db:
with get_db_context(db) as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder)
@@ -194,9 +202,10 @@ class FolderTable:
id: str,
user_id: str,
parent_id: str,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@@ -213,10 +222,14 @@ class FolderTable:
return
def update_folder_by_id_and_user_id(
self, id: str, user_id: str, form_data: FolderUpdateForm
self,
id: str,
user_id: str,
form_data: FolderUpdateForm,
db: Optional[Session] = None,
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@@ -259,10 +272,10 @@ class FolderTable:
return
def update_folder_is_expanded_by_id_and_user_id(
self, id: str, user_id: str, is_expanded: bool
self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None
) -> Optional[FolderModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
@@ -278,10 +291,12 @@ class FolderTable:
log.error(f"update_folder: {e}")
return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]:
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> list[str]:
try:
folder_ids = []
with get_db() as db:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder:
return folder_ids
@@ -291,7 +306,7 @@ class FolderTable:
# Delete all children folders
def delete_children(folder):
folder_children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id
folder.id, user_id, db=db
)
for folder_child in folder_children:
@@ -316,7 +331,7 @@ class FolderTable:
return name.strip().lower()
def search_folders_by_names(
self, user_id: str, queries: list[str]
self, user_id: str, queries: list[str], db: Optional[Session] = None
) -> list[FolderModel]:
"""
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
@@ -326,7 +341,7 @@ class FolderTable:
return []
results = {}
with get_db() as db:
with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries:
@@ -334,7 +349,7 @@ class FolderTable:
# get children folders
children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id
folder.id, user_id, db=db
)
for child in children:
results[child.id] = child
@@ -347,14 +362,14 @@ class FolderTable:
return results
def search_folders_by_name_contains(
self, user_id: str, query: str
self, user_id: str, query: str, db: Optional[Session] = None
) -> list[FolderModel]:
"""
Partial match: normalized name contains (as substring) the normalized query.
"""
normalized_query = self.normalize_folder_name(query)
results = []
with get_db() as db:
with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders:
norm_name = self.normalize_folder_name(folder.name)
+78 -40
View File
@@ -2,14 +2,13 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserModel
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# Functions DB Schema
@@ -105,7 +104,11 @@ class FunctionValves(BaseModel):
class FunctionsTable:
def insert_new_function(
self, user_id: str, type: str, form_data: FunctionForm
self,
user_id: str,
type: str,
form_data: FunctionForm,
db: Optional[Session] = None,
) -> Optional[FunctionModel]:
function = FunctionModel(
**{
@@ -118,7 +121,7 @@ class FunctionsTable:
)
try:
with get_db() as db:
with get_db_context(db) as db:
result = Function(**function.model_dump())
db.add(result)
db.commit()
@@ -132,11 +135,14 @@ class FunctionsTable:
return None
def sync_functions(
self, user_id: str, functions: list[FunctionWithValvesModel]
self,
user_id: str,
functions: list[FunctionWithValvesModel],
db: Optional[Session] = None,
) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
with get_db_context(db) as db:
# Get existing functions
existing_functions = db.query(Function).all()
existing_ids = {func.id for func in existing_functions}
@@ -179,18 +185,39 @@ class FunctionsTable:
log.exception(f"Error syncing functions for user {user_id}: {e}")
return []
def get_function_by_id(self, id: str) -> Optional[FunctionModel]:
def get_function_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FunctionModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
function = db.get(Function, id)
return FunctionModel.model_validate(function)
except Exception:
return None
def get_functions_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FunctionModel]:
"""
Batch fetch multiple functions by their IDs in a single query.
Returns functions in the same order as the input IDs (None entries filtered out).
"""
if not ids:
return []
try:
with get_db_context(db) as db:
functions = db.query(Function).filter(Function.id.in_(ids)).all()
# Create a dict for O(1) lookup
func_dict = {f.id: FunctionModel.model_validate(f) for f in functions}
# Return in original order, filtering out any not found
return [func_dict[id] for id in ids if id in func_dict]
except Exception:
return []
def get_functions(
self, active_only=False, include_valves=False
self, active_only=False, include_valves=False, db: Optional[Session] = None
) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db() as db:
with get_db_context(db) as db:
if active_only:
functions = db.query(Function).filter_by(is_active=True).all()
@@ -207,12 +234,14 @@ class FunctionsTable:
FunctionModel.model_validate(function) for function in functions
]
def get_function_list(self) -> list[FunctionUserResponse]:
with get_db() as db:
def get_function_list(
self, db: Optional[Session] = None
) -> list[FunctionUserResponse]:
with get_db_context(db) as db:
functions = db.query(Function).order_by(Function.updated_at.desc()).all()
user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
@@ -230,9 +259,9 @@ class FunctionsTable:
]
def get_functions_by_type(
self, type: str, active_only=False
self, type: str, active_only=False, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db() as db:
with get_db_context(db) as db:
if active_only:
return [
FunctionModel.model_validate(function)
@@ -246,8 +275,10 @@ class FunctionsTable:
for function in db.query(Function).filter_by(type=type).all()
]
def get_global_filter_functions(self) -> list[FunctionModel]:
with get_db() as db:
def get_global_filter_functions(
self, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
return [
FunctionModel.model_validate(function)
for function in db.query(Function)
@@ -255,8 +286,10 @@ class FunctionsTable:
.all()
]
def get_global_action_functions(self) -> list[FunctionModel]:
with get_db() as db:
def get_global_action_functions(
self, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
return [
FunctionModel.model_validate(function)
for function in db.query(Function)
@@ -264,8 +297,10 @@ class FunctionsTable:
.all()
]
def get_function_valves_by_id(self, id: str) -> Optional[dict]:
with get_db() as db:
def get_function_valves_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
with get_db_context(db) as db:
try:
function = db.get(Function, id)
return function.valves if function.valves else {}
@@ -274,23 +309,23 @@ class FunctionsTable:
return None
def update_function_valves_by_id(
self, id: str, valves: dict
self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[FunctionValves]:
with get_db() as db:
with get_db_context(db) as db:
try:
function = db.get(Function, id)
function.valves = valves
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id)
return FunctionModel.model_validate(function)
except Exception:
return None
def update_function_metadata_by_id(
self, id: str, metadata: dict
self, id: str, metadata: dict, db: Optional[Session] = None
) -> Optional[FunctionModel]:
with get_db() as db:
with get_db_context(db) as db:
try:
function = db.get(Function, id)
@@ -303,7 +338,7 @@ class FunctionsTable:
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
return self.get_function_by_id(id)
return FunctionModel.model_validate(function)
else:
return None
except Exception as e:
@@ -311,10 +346,10 @@ class FunctionsTable:
return None
def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings
@@ -329,10 +364,10 @@ class FunctionsTable:
return None
def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings
@@ -344,7 +379,7 @@ class FunctionsTable:
user_settings["functions"]["valves"][id] = valves
# Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings})
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
return user_settings["functions"]["valves"][id]
except Exception as e:
@@ -353,8 +388,10 @@ class FunctionsTable:
)
return None
def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]:
with get_db() as db:
def update_function_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[FunctionModel]:
with get_db_context(db) as db:
try:
db.query(Function).filter_by(id=id).update(
{
@@ -363,12 +400,13 @@ class FunctionsTable:
}
)
db.commit()
return self.get_function_by_id(id)
function = db.get(Function, id)
return FunctionModel.model_validate(function) if function else None
except Exception:
return None
def deactivate_all_functions(self) -> Optional[bool]:
with get_db() as db:
def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]:
with get_db_context(db) as db:
try:
db.query(Function).update(
{
@@ -381,8 +419,8 @@ class FunctionsTable:
except Exception:
return None
def delete_function_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Function).filter_by(id=id).delete()
db.commit()
+259 -54
View File
@@ -4,18 +4,28 @@ import time
from typing import Optional
import uuid
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.files import FileMetadataResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON, func, ForeignKey
from sqlalchemy import (
BigInteger,
Column,
String,
Text,
JSON,
and_,
func,
ForeignKey,
cast,
or_,
select,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# UserGroup DB Schema
@@ -41,7 +51,6 @@ class Group(Base):
class GroupModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
@@ -56,6 +65,8 @@ class GroupModel(BaseModel):
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
class GroupMember(Base):
__tablename__ = "group_member"
@@ -84,17 +95,18 @@ class GroupMemberModel(BaseModel):
####################
class GroupResponse(BaseModel):
class GroupResponse(GroupModel):
member_count: Optional[int] = None
class GroupInfoResponse(BaseModel):
id: str
user_id: str
name: str
description: str
permissions: Optional[dict] = None
data: Optional[dict] = None
meta: Optional[dict] = None
member_count: Optional[int] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int
updated_at: int
class GroupForm(BaseModel):
@@ -112,11 +124,16 @@ class GroupUpdateForm(GroupForm):
pass
class GroupListResponse(BaseModel):
items: list[GroupResponse] = []
total: int = 0
class GroupTable:
def insert_new_group(
self, user_id: str, form_data: GroupForm
self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
) -> Optional[GroupModel]:
with get_db() as db:
with get_db_context(db) as db:
group = GroupModel(
**{
**form_data.model_dump(exclude_none=True),
@@ -140,15 +157,145 @@ class GroupTable:
except Exception:
return None
def get_groups(self) -> list[GroupModel]:
with get_db() as db:
def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]:
with get_db_context(db) as db:
groups = db.query(Group).order_by(Group.updated_at.desc()).all()
return [GroupModel.model_validate(group) for group in groups]
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
with get_db_context(db) as db:
member_count = (
select(func.count(GroupMember.user_id))
.where(GroupMember.group_id == Group.id)
.correlate(Group)
.scalar_subquery()
.label("member_count")
)
query = db.query(Group, member_count)
if filter:
if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
# When share filter is present, member check is handled in the share logic
if "share" in filter:
share_value = filter["share"]
member_id = filter.get("member_id")
json_share = Group.data["config"]["share"]
json_share_str = json_share.as_string()
json_share_lower = func.lower(json_share_str)
if share_value:
anyone_can_share = or_(
Group.data.is_(None),
json_share_str.is_(None),
json_share_lower == "true",
json_share_lower == "1", # Handle SQLite boolean true
)
if member_id:
member_groups_select = select(GroupMember.group_id).where(
GroupMember.user_id == member_id
)
members_only_and_is_member = and_(
json_share_lower == "members",
Group.id.in_(member_groups_select),
)
query = query.filter(
or_(anyone_can_share, members_only_and_is_member)
)
else:
query = query.filter(anyone_can_share)
else:
query = query.filter(
and_(Group.data.isnot(None), json_share_lower == "false")
)
else:
# Only apply member_id filter when share filter is NOT present
if "member_id" in filter:
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
)
results = query.order_by(Group.updated_at.desc()).all()
return [
GroupModel.model_validate(group)
for group in db.query(Group).order_by(Group.updated_at.desc()).all()
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": count or 0,
}
)
for group, count in results
]
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
with get_db() as db:
def search_groups(
self,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> GroupListResponse:
with get_db_context(db) as db:
query = db.query(Group)
if filter:
if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
if "member_id" in filter:
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
)
if "share" in filter:
share_value = filter["share"]
query = query.filter(
Group.data.op("->>")("share") == str(share_value)
)
total = query.count()
member_count = (
select(func.count(GroupMember.user_id))
.where(GroupMember.group_id == Group.id)
.correlate(Group)
.scalar_subquery()
.label("member_count")
)
results = (
query.add_columns(member_count)
.order_by(Group.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return {
"items": [
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": count or 0,
}
)
for group, count in results
],
"total": total,
}
def get_groups_by_member_id(
self, user_id: str, db: Optional[Session] = None
) -> list[GroupModel]:
with get_db_context(db) as db:
return [
GroupModel.model_validate(group)
for group in db.query(Group)
@@ -158,27 +305,54 @@ class GroupTable:
.all()
]
def get_group_by_id(self, id: str) -> Optional[GroupModel]:
def get_groups_by_member_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> dict[str, list[GroupModel]]:
"""Fetch groups for multiple users in a single query to avoid N+1."""
with get_db_context(db) as db:
# Query GroupMember joined with Group, filtering by user_ids
results = (
db.query(GroupMember.user_id, Group)
.join(Group, Group.id == GroupMember.group_id)
.filter(GroupMember.user_id.in_(user_ids))
.order_by(Group.updated_at.desc())
.all()
)
# Group groups by user_id
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
for user_id, group in results:
user_groups[user_id].append(GroupModel.model_validate(group))
return user_groups
def get_group_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
return GroupModel.model_validate(group) if group else None
except Exception:
return None
def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]:
with get_db() as db:
def get_group_user_ids_by_id(
self, id: str, db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
members = (
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
)
if not members:
return None
return []
return [m[0] for m in members]
def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str]]:
with get_db() as db:
def get_group_user_ids_by_ids(
self, group_ids: list[str], db: Optional[Session] = None
) -> dict[str, list[str]]:
with get_db_context(db) as db:
members = (
db.query(GroupMember.group_id, GroupMember.user_id)
.filter(GroupMember.group_id.in_(group_ids))
@@ -194,8 +368,10 @@ class GroupTable:
return group_user_ids
def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None:
with get_db() as db:
def set_group_user_ids_by_id(
self, group_id: str, user_ids: list[str], db: Optional[Session] = None
) -> None:
with get_db_context(db) as db:
# Delete existing members
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
@@ -215,8 +391,10 @@ class GroupTable:
db.add_all(new_members)
db.commit()
def get_group_member_count_by_id(self, id: str) -> int:
with get_db() as db:
def get_group_member_count_by_id(
self, id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db:
count = (
db.query(func.count(GroupMember.user_id))
.filter(GroupMember.group_id == id)
@@ -224,11 +402,29 @@ class GroupTable:
)
return count if count else 0
def get_group_member_counts_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> dict[str, int]:
if not ids:
return {}
with get_db_context(db) as db:
rows = (
db.query(GroupMember.group_id, func.count(GroupMember.user_id))
.filter(GroupMember.group_id.in_(ids))
.group_by(GroupMember.group_id)
.all()
)
return {group_id: count for group_id, count in rows}
def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
self,
id: str,
form_data: GroupUpdateForm,
overwrite: bool = False,
db: Optional[Session] = None,
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Group).filter_by(id=id).update(
{
**form_data.model_dump(exclude_none=True),
@@ -236,22 +432,22 @@ class GroupTable:
}
)
db.commit()
return self.get_group_by_id(id=id)
return self.get_group_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def delete_group_by_id(self, id: str) -> bool:
def delete_group_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Group).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
def delete_all_groups(self) -> bool:
with get_db() as db:
def delete_all_groups(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Group).delete()
db.commit()
@@ -260,8 +456,10 @@ class GroupTable:
except Exception:
return False
def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db:
def remove_user_from_all_groups(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
# Find all groups the user belongs to
groups = (
@@ -289,16 +487,16 @@ class GroupTable:
return False
def create_groups_by_group_names(
self, user_id: str, group_names: list[str]
self, user_id: str, group_names: list[str], db: Optional[Session] = None
) -> list[GroupModel]:
# check for existing groups
existing_groups = self.get_groups()
existing_groups = self.get_all_groups(db=db)
existing_group_names = {group.name for group in existing_groups}
new_groups = []
with get_db() as db:
with get_db_context(db) as db:
for group_name in group_names:
if group_name not in existing_group_names:
new_group = GroupModel(
@@ -320,8 +518,10 @@ class GroupTable:
continue
return new_groups
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool:
with get_db() as db:
def sync_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
now = int(time.time())
@@ -381,10 +581,13 @@ class GroupTable:
return False
def add_users_to_group(
self, id: str, user_ids: Optional[list[str]] = None
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
if not group:
return None
@@ -419,10 +622,13 @@ class GroupTable:
return None
def remove_users_from_group(
self, id: str, user_ids: Optional[list[str]] = None
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[GroupModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
if not group:
return None
@@ -430,11 +636,10 @@ class GroupTable:
if not user_ids:
return GroupModel.model_validate(group)
# Remove each user from group_member
for user_id in user_ids:
db.query(GroupMember).filter(
GroupMember.group_id == id, GroupMember.user_id == user_id
).delete()
# Remove users from group_member in batch
db.query(GroupMember).filter(
GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)
).delete(synchronize_session=False)
# Update group timestamp
group.updated_at = int(time.time())
+520 -70
View File
@@ -4,21 +4,33 @@ import time
from typing import Optional
import uuid
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.files import FileMetadataResponse
from open_webui.models.files import (
File,
FileModel,
FileMetadataResponse,
FileModelResponse,
)
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
BigInteger,
Column,
ForeignKey,
String,
Text,
JSON,
UniqueConstraint,
or_,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# Knowledge DB Schema
@@ -34,26 +46,8 @@ class Knowledge(Base):
name = Column(Text)
description = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -67,20 +61,50 @@ class KnowledgeModel(BaseModel):
name: str
description: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
class KnowledgeFile(Base):
__tablename__ = "knowledge_file"
id = Column(Text, unique=True, primary_key=True)
knowledge_id = Column(
Text, ForeignKey("knowledge.id", ondelete="CASCADE"), nullable=False
)
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint(
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
),
)
class KnowledgeFileModel(BaseModel):
id: str
knowledge_id: str
file_id: str
user_id: str
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class KnowledgeUserModel(KnowledgeModel):
user: Optional[UserResponse] = None
@@ -90,52 +114,86 @@ class KnowledgeResponse(KnowledgeModel):
class KnowledgeUserResponse(KnowledgeUserModel):
files: Optional[list[FileMetadataResponse | dict]] = None
pass
class KnowledgeForm(BaseModel):
name: str
description: str
data: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None
class KnowledgeListResponse(BaseModel):
items: list[KnowledgeUserModel]
total: int
class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse]
total: int
class KnowledgeTable:
def _get_access_grants(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
def _to_knowledge_model(
self, knowledge: Knowledge, db: Optional[Session] = None
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
exclude={"access_grants"}
)
knowledge_data["access_grants"] = self._get_access_grants(
knowledge_data["id"], db=db
)
return KnowledgeModel.model_validate(knowledge_data)
def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm
self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
with get_db() as db:
with get_db_context(db) as db:
knowledge = KnowledgeModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
"access_grants": [],
}
)
try:
result = Knowledge(**knowledge.model_dump())
result = Knowledge(**knowledge.model_dump(exclude={"access_grants"}))
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"knowledge", result.id, form_data.access_grants, db=db
)
if result:
return KnowledgeModel.model_validate(result)
return self._to_knowledge_model(result, db=db)
else:
return None
except Exception:
return None
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
with get_db() as db:
def get_knowledge_bases(
self, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> list[KnowledgeUserModel]:
with get_db_context(db) as db:
all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
knowledge_bases = []
@@ -144,68 +202,456 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(knowledge).model_dump(),
**self._to_knowledge_model(knowledge, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
)
return knowledge_bases
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id)
def search_knowledge_bases(
self,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeListResponse:
try:
with get_db_context(db) as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Knowledge.user_id == user_id)
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(
knowledge_base, db=db
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
if user
else None
),
}
)
)
return KnowledgeListResponse(items=knowledge_bases, total=total)
except Exception as e:
print(e)
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> KnowledgeFileListResponse:
"""
Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists.
"""
try:
with get_db_context(db) as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User, Knowledge)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
)
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
# Apply filename search
if filter:
q = filter.get("query")
if q:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
query = query.order_by(File.updated_at.desc(), File.id.asc())
# Count before pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
rows = query.all()
items = []
for file, user, knowledge in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
collection=self._to_knowledge_model(
knowledge, db=db
).model_dump(),
)
)
return KnowledgeFileListResponse(items=items, total=total)
except Exception as e:
print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(
self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
knowledge = self.get_knowledge_by_id(id, db=db)
if not knowledge:
return False
if knowledge.user_id == user_id:
return True
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
knowledge_bases = self.get_knowledge_bases(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
knowledge_base
for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id
or has_access(
user_id, permission, knowledge_base.access_control, user_group_ids
or AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
def get_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
knowledge = db.query(Knowledge).filter_by(id=id).first()
return KnowledgeModel.model_validate(knowledge) if knowledge else None
return self._to_knowledge_model(knowledge, db=db) if knowledge else None
except Exception:
return None
def update_knowledge_by_id(
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id, db=db)
if not knowledge:
return None
if knowledge.user_id == user_id:
return knowledge
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
if AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_id=knowledge.id,
permission="write",
user_group_ids=user_group_ids,
db=db,
):
return knowledge
return None
def get_knowledges_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[KnowledgeModel]:
try:
with get_db_context(db) as db:
knowledges = (
db.query(Knowledge)
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
.filter(KnowledgeFile.file_id == file_id)
.all()
)
return [
self._to_knowledge_model(knowledge, db=db)
for knowledge in knowledges
]
except Exception:
return []
def search_files_by_id(
self,
knowledge_id: str,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeFileListResponse:
try:
with get_db_context(db) as db:
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
# Default sort: updated_at descending
primary_sort = File.updated_at.desc()
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(KnowledgeFile.user_id == user_id)
elif view_option == "shared":
query = query.filter(KnowledgeFile.user_id != user_id)
order_by = filter.get("order_by")
direction = filter.get("direction")
is_asc = direction == "asc"
if order_by == "name":
primary_sort = (
File.filename.asc() if is_asc else File.filename.desc()
)
elif order_by == "created_at":
primary_sort = (
File.created_at.asc() if is_asc else File.created_at.desc()
)
elif order_by == "updated_at":
primary_sort = (
File.updated_at.asc() if is_asc else File.updated_at.desc()
)
# Apply sort with secondary key for deterministic pagination
query = query.order_by(primary_sort, File.id.asc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
files = []
for file, user in items:
files.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=files, total=total)
except Exception as e:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileModel]:
try:
with get_db_context(db) as db:
files = (
db.query(File)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
.all()
)
return [FileModel.model_validate(file) for file in files]
except Exception:
return []
def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileMetadataResponse]:
try:
with get_db_context(db) as db:
files = self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files]
except Exception:
return []
def add_file_to_knowledge_by_id(
self,
knowledge_id: str,
file_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[KnowledgeFileModel]:
with get_db_context(db) as db:
knowledge_file = KnowledgeFileModel(
**{
"id": str(uuid.uuid4()),
"knowledge_id": knowledge_id,
"file_id": file_id,
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
result = KnowledgeFile(**knowledge_file.model_dump())
db.add(result)
db.commit()
db.refresh(result)
if result:
return KnowledgeFileModel.model_validate(result)
else:
return None
except Exception:
return None
def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db_context(db) as db:
db.query(KnowledgeFile).filter_by(
knowledge_id=knowledge_id, file_id=file_id
).delete()
db.commit()
return True
except Exception:
return False
def reset_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
knowledge = self.get_knowledge_by_id(id=id)
with get_db_context(db) as db:
# Delete all knowledge_file entries for this knowledge_id
db.query(KnowledgeFile).filter_by(knowledge_id=id).delete()
db.commit()
# Update the knowledge entry's updated_at timestamp
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(),
"updated_at": int(time.time()),
}
)
db.commit()
return self.get_knowledge_by_id(id=id)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def update_knowledge_by_id(
self,
id: str,
form_data: KnowledgeForm,
overwrite: bool = False,
db: Optional[Session] = None,
) -> Optional[KnowledgeModel]:
try:
with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(exclude={"access_grants"}),
"updated_at": int(time.time()),
}
)
db.commit()
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"knowledge", id, form_data.access_grants, db=db
)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def update_knowledge_data_by_id(
self, id: str, data: dict
self, id: str, data: dict, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try:
with get_db() as db:
knowledge = self.get_knowledge_by_id(id=id)
with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
"data": data,
@@ -213,23 +659,27 @@ class KnowledgeTable:
}
)
db.commit()
return self.get_knowledge_by_id(id=id)
return self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def delete_knowledge_by_id(self, id: str) -> bool:
def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("knowledge", id, db=db)
db.query(Knowledge).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
def delete_all_knowledge(self) -> bool:
with get_db() as db:
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
for knowledge_id in knowledge_ids:
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
db.query(Knowledge).delete()
db.commit()
+28 -16
View File
@@ -2,7 +2,8 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
@@ -41,8 +42,9 @@ class MemoriesTable:
self,
user_id: str,
content: str,
db: Optional[Session] = None,
) -> Optional[MemoryModel]:
with get_db() as db:
with get_db_context(db) as db:
id = str(uuid.uuid4())
memory = MemoryModel(
@@ -68,8 +70,9 @@ class MemoriesTable:
id: str,
user_id: str,
content: str,
db: Optional[Session] = None,
) -> Optional[MemoryModel]:
with get_db() as db:
with get_db_context(db) as db:
try:
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
@@ -79,36 +82,41 @@ class MemoriesTable:
memory.updated_at = int(time.time())
db.commit()
return self.get_memory_by_id(id)
db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None
def get_memories(self) -> list[MemoryModel]:
with get_db() as db:
def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]:
with get_db_context(db) as db:
try:
memories = db.query(Memory).all()
return [MemoryModel.model_validate(memory) for memory in memories]
except Exception:
return None
def get_memories_by_user_id(self, user_id: str) -> list[MemoryModel]:
with get_db() as db:
def get_memories_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[MemoryModel]:
with get_db_context(db) as db:
try:
memories = db.query(Memory).filter_by(user_id=user_id).all()
return [MemoryModel.model_validate(memory) for memory in memories]
except Exception:
return None
def get_memory_by_id(self, id: str) -> Optional[MemoryModel]:
with get_db() as db:
def get_memory_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[MemoryModel]:
with get_db_context(db) as db:
try:
memory = db.get(Memory, id)
return MemoryModel.model_validate(memory)
except Exception:
return None
def delete_memory_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try:
db.query(Memory).filter_by(id=id).delete()
db.commit()
@@ -118,8 +126,10 @@ class MemoriesTable:
except Exception:
return False
def delete_memories_by_user_id(self, user_id: str) -> bool:
with get_db() as db:
def delete_memories_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
db.query(Memory).filter_by(user_id=user_id).delete()
db.commit()
@@ -128,8 +138,10 @@ class MemoriesTable:
except Exception:
return False
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
with get_db() as db:
def delete_memory_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try:
memory = db.get(Memory, id)
if not memory or memory.user_id != user_id:
+312 -49
View File
@@ -3,12 +3,14 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
@@ -39,7 +41,7 @@ class MessageReactionModel(BaseModel):
class Message(Base):
__tablename__ = "message"
id = Column(Text, primary_key=True)
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
@@ -47,6 +49,11 @@ class Message(Base):
reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
# Pins
is_pinned = Column(Boolean, nullable=False, default=False)
pinned_at = Column(BigInteger, nullable=True)
pinned_by = Column(Text, nullable=True)
content = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
@@ -65,12 +72,17 @@ class MessageModel(BaseModel):
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
# Pins
is_pinned: bool = False
pinned_by: Optional[str] = None
pinned_at: Optional[int] = None # timestamp in epoch (time_ns)
content: str
data: Optional[dict] = None
meta: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch (time_ns)
updated_at: int # timestamp in epoch (time_ns)
####################
@@ -79,6 +91,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
temp_id: Optional[str] = None
content: str
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None
@@ -88,7 +101,7 @@ class MessageForm(BaseModel):
class Reactions(BaseModel):
name: str
user_ids: list[str]
users: list[dict]
count: int
@@ -96,8 +109,25 @@ class MessageUserResponse(MessageModel):
user: Optional[UserNameResponse] = None
class MessageUserSlimResponse(MessageUserResponse):
data: bool | None = None
@field_validator("data", mode="before")
def convert_data_to_bool(cls, v):
# No data or not a dict → False
if not isinstance(v, dict):
return False
# True if ANY value in the dict is non-empty
return any(bool(val) for val in v.values())
class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None
reply_to_message: Optional[MessageUserSlimResponse] = None
class MessageWithReactionsResponse(MessageUserSlimResponse):
reactions: list[Reactions]
class MessageResponse(MessageReplyToResponse):
@@ -108,12 +138,18 @@ class MessageResponse(MessageReplyToResponse):
class MessageTable:
def insert_new_message(
self, form_data: MessageForm, channel_id: str, user_id: str
self,
form_data: MessageForm,
channel_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[MessageModel]:
with get_db() as db:
id = str(uuid.uuid4())
with get_db_context(db) as db:
channel_member = Channels.join_channel(channel_id, user_id)
id = str(uuid.uuid4())
ts = int(time.time_ns())
message = MessageModel(
**{
"id": id,
@@ -121,6 +157,9 @@ class MessageTable:
"channel_id": channel_id,
"reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
"is_pinned": False,
"pinned_at": None,
"pinned_by": None,
"content": form_data.content,
"data": form_data.data,
"meta": form_data.meta,
@@ -128,33 +167,64 @@ class MessageTable:
"updated_at": ts,
}
)
result = Message(**message.model_dump())
db.add(result)
db.commit()
db.refresh(result)
return MessageModel.model_validate(result) if result else None
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
with get_db() as db:
def get_message_by_id(
self,
id: str,
include_thread_replies: Optional[bool] = True,
db: Optional[Session] = None,
) -> Optional[MessageResponse]:
with get_db_context(db) as db:
message = db.get(Message, id)
if not message:
return None
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
reactions = self.get_reactions_by_message_id(id)
thread_replies = self.get_thread_replies_by_message_id(id)
reactions = self.get_reactions_by_message_id(id, db=db)
thread_replies = []
if include_thread_replies:
thread_replies = self.get_thread_replies_by_message_id(id, db=db)
# Check if message was sent by webhook (webhook info in meta takes precedence)
webhook_info = message.meta.get("webhook") if message.meta else None
if webhook_info and webhook_info.get("id"):
# Look up webhook by ID to get current name
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
# Webhook was deleted, use placeholder
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
else:
user = Users.get_user_by_id(message.user_id, db=db)
user_info = user.model_dump() if user else None
user = Users.get_user_by_id(message.user_id)
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user.model_dump() if user else None,
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
@@ -166,8 +236,10 @@ class MessageTable:
}
)
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db:
def get_thread_replies_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[MessageReplyToResponse]:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(parent_id=id)
@@ -178,14 +250,35 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@@ -196,17 +289,23 @@ class MessageTable:
)
return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
def get_reply_user_ids_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
return [
message.user_id
for message in db.query(Message).filter_by(parent_id=id).all()
]
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageReplyToResponse]:
with get_db() as db:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, parent_id=None)
@@ -219,14 +318,35 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@@ -238,9 +358,14 @@ class MessageTable:
return messages
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
self,
channel_id: str,
parent_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageReplyToResponse]:
with get_db() as db:
with get_db_context(db) as db:
message = db.get(Message, parent_id)
if not message:
@@ -262,14 +387,35 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id
else None
)
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
@@ -280,10 +426,40 @@ class MessageTable:
)
return messages
def update_message_by_id(
self, id: str, form_data: MessageForm
def get_last_message_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
message = (
db.query(Message)
.filter_by(channel_id=channel_id)
.order_by(Message.created_at.desc())
.first()
)
return MessageModel.model_validate(message) if message else None
def get_pinned_messages_by_channel_id(
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageModel]:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, is_pinned=True)
.order_by(Message.pinned_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
self, id: str, form_data: MessageForm, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = db.get(Message, id)
message.content = form_data.content
message.data = {
@@ -299,10 +475,52 @@ class MessageTable:
db.refresh(message)
return MessageModel.model_validate(message) if message else None
def update_is_pinned_by_id(
self,
id: str,
is_pinned: bool,
pinned_by: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = db.get(Message, id)
message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None
message.pinned_by = pinned_by if is_pinned else None
db.commit()
db.refresh(message)
return MessageModel.model_validate(message) if message else None
def get_unread_message_count(
self,
channel_id: str,
user_id: str,
last_read_at: Optional[int] = None,
db: Optional[Session] = None,
) -> int:
with get_db_context(db) as db:
query = db.query(Message).filter(
Message.channel_id == channel_id,
Message.parent_id == None, # only count top-level messages
Message.created_at > (last_read_at if last_read_at else 0),
)
if user_id:
query = query.filter(Message.user_id != user_id)
return query.count()
def add_reaction_to_message(
self, id: str, user_id: str, name: str
self, id: str, user_id: str, name: str, db: Optional[Session] = None
) -> Optional[MessageReactionModel]:
with get_db() as db:
with get_db_context(db) as db:
# check for existing reaction
existing_reaction = (
db.query(MessageReaction)
.filter_by(message_id=id, user_id=user_id, name=name)
.first()
)
if existing_reaction:
return MessageReactionModel.model_validate(existing_reaction)
reaction_id = str(uuid.uuid4())
reaction = MessageReactionModel(
id=reaction_id,
@@ -317,47 +535,62 @@ class MessageTable:
db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
with get_db() as db:
all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
def get_reactions_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[Reactions]:
with get_db_context(db) as db:
# JOIN User so all user info is fetched in one query
results = (
db.query(MessageReaction, User)
.join(User, MessageReaction.user_id == User.id)
.filter(MessageReaction.message_id == id)
.all()
)
reactions = {}
for reaction in all_reactions:
for reaction, user in results:
if reaction.name not in reactions:
reactions[reaction.name] = {
"name": reaction.name,
"user_ids": [],
"users": [],
"count": 0,
}
reactions[reaction.name]["user_ids"].append(reaction.user_id)
reactions[reaction.name]["users"].append(
{
"id": user.id,
"name": user.name,
}
)
reactions[reaction.name]["count"] += 1
return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str
self, id: str, user_id: str, name: str, db: Optional[Session] = None
) -> bool:
with get_db() as db:
with get_db_context(db) as db:
db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name
).delete()
db.commit()
return True
def delete_reactions_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_reactions_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
def delete_replies_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_replies_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(Message).filter_by(parent_id=id).delete()
db.commit()
return True
def delete_message_by_id(self, id: str) -> bool:
with get_db() as db:
def delete_message_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
db.query(Message).filter_by(id=id).delete()
# Delete all reactions to this message
@@ -366,5 +599,35 @@ class MessageTable:
db.commit()
return True
def search_messages_by_channel_ids(
self,
channel_ids: list[str],
query: str,
start_timestamp: Optional[int] = None,
end_timestamp: Optional[int] = None,
limit: int = 10,
db: Optional[Session] = None,
) -> list[MessageModel]:
"""Search messages in specified channels by content."""
with get_db_context(db) as db:
query_builder = db.query(Message).filter(
Message.channel_id.in_(channel_ids),
Message.content.ilike(f"%{query}%"),
)
if start_timestamp:
query_builder = query_builder.filter(
Message.created_at >= start_timestamp
)
if end_timestamp:
query_builder = query_builder.filter(
Message.created_at <= end_timestamp
)
messages = (
query_builder.order_by(Message.created_at.desc()).limit(limit).all()
)
return [MessageModel.model_validate(msg) for msg in messages]
Messages = MessageTable()
+155 -90
View File
@@ -2,25 +2,23 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import has_access
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Column, Text, Boolean
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
@@ -53,7 +51,7 @@ class ModelMeta(BaseModel):
class Model(Base):
__tablename__ = "model"
id = Column(Text, primary_key=True)
id = Column(Text, primary_key=True, unique=True)
"""
The model's id as used in the API. If set to an existing model, it will override the model.
"""
@@ -79,23 +77,6 @@ class Model(Base):
Holds a JSON encoded blob of metadata, see `ModelMeta`.
"""
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger)
@@ -111,7 +92,7 @@ class ModelModel(BaseModel):
params: ModelParams
meta: ModelMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
is_active: bool
updated_at: int # timestamp in epoch
@@ -129,6 +110,10 @@ class ModelUserResponse(ModelModel):
user: Optional[UserResponse] = None
class ModelAccessResponse(ModelUserResponse):
write_access: Optional[bool] = False
class ModelResponse(ModelModel):
pass
@@ -138,54 +123,75 @@ class ModelListResponse(BaseModel):
total: int
class ModelAccessListResponse(BaseModel):
items: list[ModelAccessResponse]
total: int
class ModelForm(BaseModel):
id: str
base_model_id: Optional[str] = None
name: str
meta: ModelMeta
params: ModelParams
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
is_active: bool = True
class ModelsTable:
def insert_new_model(
self, form_data: ModelForm, user_id: str
) -> Optional[ModelModel]:
model = ModelModel(
**{
**form_data.model_dump(),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
def _get_access_grants(
self, model_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(
exclude={"access_grants"}
)
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
return ModelModel.model_validate(model_data)
def insert_new_model(
self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
try:
with get_db() as db:
result = Model(**model.model_dump())
with get_db_context(db) as db:
result = Model(
**{
**form_data.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"model", result.id, form_data.access_grants, db=db
)
if result:
return ModelModel.model_validate(result)
return self._to_model_model(result, db=db)
else:
return None
except Exception as e:
log.exception(f"Failed to insert a new model: {e}")
return None
def get_all_models(self) -> list[ModelModel]:
with get_db() as db:
return [ModelModel.model_validate(model) for model in db.query(Model).all()]
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [
self._to_model_model(model, db=db) for model in db.query(Model).all()
]
def get_models(self) -> list[ModelUserResponse]:
with get_db() as db:
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
with get_db_context(db) as db:
all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
models = []
@@ -194,36 +200,60 @@ class ModelsTable:
models.append(
ModelUserResponse.model_validate(
{
**ModelModel.model_validate(model).model_dump(),
**self._to_model_model(model, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
)
return models
def get_base_models(self) -> list[ModelModel]:
with get_db() as db:
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
return [
ModelModel.model_validate(model)
self._to_model_model(model, db=db)
for model in db.query(Model).filter(Model.base_model_id == None).all()
]
def get_models_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[ModelUserResponse]:
models = self.get_models()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
models = self.get_models(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
model
for model in models
if model.user_id == user_id
or has_access(user_id, permission, model.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="model",
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Model,
filter=filter,
resource_type="model",
permission=permission,
)
def search_models(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> ModelListResponse:
with get_db() as db:
with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested
query = db.query(Model, User).outerjoin(User, User.id == Model.user_id)
query = query.filter(Model.base_model_id != None)
@@ -235,19 +265,26 @@ class ModelsTable:
or_(
Model.name.ilike(f"%{query_key}%"),
Model.base_model_id.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
)
)
if filter.get("user_id"):
query = query.filter(Model.user_id == filter.get("user_id"))
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Model.user_id == user_id)
elif view_option == "shared":
query = query.filter(Model.user_id != user_id)
# Apply access control filtering
query = self._has_permission(
db,
query,
filter,
permission="read",
)
tag = filter.get("tag")
if tag:
# TODO: This is a simple implementation and should be improved for performance
@@ -292,7 +329,7 @@ class ModelsTable:
for model, user in items:
models.append(
ModelUserResponse(
**ModelModel.model_validate(model).model_dump(),
**self._to_model_model(model, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -303,50 +340,68 @@ class ModelsTable:
return ModelListResponse(items=models, total=total)
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
def get_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
model = db.get(Model, id)
return ModelModel.model_validate(model)
return self._to_model_model(model, db=db) if model else None
except Exception:
return None
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]:
with get_db() as db:
def get_models_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[ModelModel]:
try:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [self._to_model_model(model, db=db) for model in models]
except Exception:
return []
def toggle_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
with get_db_context(db) as db:
try:
is_active = db.query(Model).filter_by(id=id).first().is_active
model = db.query(Model).filter_by(id=id).first()
if not model:
return None
db.query(Model).filter_by(id=id).update(
{
"is_active": not is_active,
"updated_at": int(time.time()),
}
)
model.is_active = not model.is_active
model.updated_at = int(time.time())
db.commit()
db.refresh(model)
return self.get_model_by_id(id)
return self._to_model_model(model, db=db)
except Exception:
return None
def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]:
def update_model_by_id(
self, id: str, model: ModelForm, db: Optional[Session] = None
) -> Optional[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# update only the fields that are present in the model
data = model.model_dump(exclude={"id"})
data = model.model_dump(exclude={"id", "access_grants"})
result = db.query(Model).filter_by(id=id).update(data)
db.commit()
if model.access_grants is not None:
AccessGrants.set_access_grants(
"model", id, model.access_grants, db=db
)
model = db.get(Model, id)
db.refresh(model)
return ModelModel.model_validate(model)
return self.get_model_by_id(id, db=db)
except Exception as e:
log.exception(f"Failed to update the model by id {id}: {e}")
return None
def delete_model_by_id(self, id: str) -> bool:
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("model", id, db=db)
db.query(Model).filter_by(id=id).delete()
db.commit()
@@ -354,9 +409,12 @@ class ModelsTable:
except Exception:
return False
def delete_all_models(self) -> bool:
def delete_all_models(self, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
model_ids = [row[0] for row in db.query(Model.id).all()]
for model_id in model_ids:
AccessGrants.revoke_all_access("model", model_id, db=db)
db.query(Model).delete()
db.commit()
@@ -364,9 +422,11 @@ class ModelsTable:
except Exception:
return False
def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]:
def sync_models(
self, user_id: str, models: list[ModelModel], db: Optional[Session] = None
) -> list[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
# Get existing models
existing_models = db.query(Model).all()
existing_ids = {model.id for model in existing_models}
@@ -379,7 +439,7 @@ class ModelsTable:
if model.id in existing_ids:
db.query(Model).filter_by(id=model.id).update(
{
**model.model_dump(),
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
@@ -387,22 +447,27 @@ class ModelsTable:
else:
new_model = Model(
**{
**model.model_dump(),
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
)
db.add(new_model)
AccessGrants.set_access_grants(
"model", model.id, model.access_grants, db=db
)
# Remove models that are no longer present
for model in existing_models:
if model.id not in new_model_ids:
AccessGrants.revoke_all_access("model", model.id, db=db)
db.delete(model)
db.commit()
return [
ModelModel.model_validate(model) for model in db.query(Model).all()
self._to_model_model(model, db=db)
for model in db.query(Model).all()
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
+190 -92
View File
@@ -4,16 +4,16 @@ import uuid
from typing import Optional
from functools import lru_cache
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse
from open_webui.models.users import User, UserModel, Users, UserResponse
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, Text, JSON
from sqlalchemy import or_, func, cast
####################
# Note DB Schema
@@ -23,15 +23,13 @@ from sqlalchemy.sql import exists
class Note(Base):
__tablename__ = "note"
id = Column(Text, primary_key=True)
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
title = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -46,7 +44,7 @@ class NoteModel(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -61,64 +59,206 @@ class NoteForm(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None
class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
updated_at: int
created_at: int
user: Optional[UserResponse] = None
class NoteListResponse(BaseModel):
items: list[NoteUserResponse]
total: int
class NoteTable:
def _get_access_grants(
self, note_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
return NoteModel.model_validate(note_data)
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Note,
filter=filter,
resource_type="note",
permission=permission,
)
def insert_new_note(
self,
form_data: NoteForm,
user_id: str,
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
note = NoteModel(
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
}
)
new_note = Note(**note.model_dump())
new_note = Note(**note.model_dump(exclude={"access_grants"}))
db.add(new_note)
db.commit()
return note
AccessGrants.set_access_grants(
"note", note.id, form_data.access_grants, db=db
)
return self._to_note_model(new_note, db=db)
def get_notes(
self, skip: Optional[int] = None, limit: Optional[int] = None
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Note).order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
return [self._to_note_model(note, db=db) for note in notes]
def search_notes(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> NoteListResponse:
with get_db_context(db) as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
if query_key:
# Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do")
normalized_query = query_key.replace("-", "").replace(" ", "")
query = query.filter(
or_(
func.replace(
func.replace(Note.title, "-", ""), " ", ""
).ilike(f"%{normalized_query}%"),
func.replace(
func.replace(
cast(Note.data["content"]["md"], Text), "-", ""
),
" ",
"",
).ilike(f"%{normalized_query}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Note.user_id == user_id)
elif view_option == "shared":
query = query.filter(Note.user_id != user_id)
# Apply access control filtering
if "permission" in filter:
permission = filter["permission"]
else:
permission = "write"
query = self._has_permission(
db,
query,
filter,
permission=permission,
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Note.title.asc())
else:
query = query.order_by(Note.title.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Note.created_at.asc())
else:
query = query.order_by(Note.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Note.updated_at.asc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**self._to_note_model(note, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return NoteListResponse(items=notes, total=total)
def get_notes_by_user_id(
self,
user_id: str,
skip: Optional[int] = None,
limit: Optional[int] = None,
permission: str = "read",
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[NoteModel]:
with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id)
query = query.order_by(Note.updated_at.desc())
with get_db_context(db) as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
query = db.query(Note).order_by(Note.updated_at.desc())
query = self._has_permission(
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
)
if skip is not None:
query = query.offset(skip)
@@ -126,67 +266,19 @@ class NoteTable:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
return [self._to_note_model(note, db=db) for note in notes]
def get_notes_by_permission(
self,
user_id: str,
permission: str = "write",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = {group.id for group in user_groups}
# Order newest-first. We stream to keep memory usage low.
query = (
db.query(Note)
.order_by(Note.updated_at.desc())
.execution_options(stream_results=True)
.yield_per(256)
)
results: list[NoteModel] = []
n_skipped = 0
for note in query:
# Fast-pass #1: owner
if note.user_id == user_id:
permitted = True
# Fast-pass #2: public/open
elif note.access_control is None:
# Technically this should mean public access for both read and write, but we'll only do read for now
# We might want to change this behavior later
permitted = permission == "read"
else:
permitted = has_access(
user_id, permission, note.access_control, user_group_ids
)
if not permitted:
continue
# Apply skip AFTER permission filtering so it counts only accessible notes
if skip and n_skipped < skip:
n_skipped += 1
continue
results.append(NoteModel.model_validate(note))
if limit is not None and len(results) >= limit:
break
return results
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
def get_note_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
return self._to_note_model(note, db=db) if note else None
def update_note_by_id(
self, id: str, form_data: NoteUpdateForm
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
) -> Optional[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
if not note:
return None
@@ -200,19 +292,25 @@ class NoteTable:
if "meta" in form_data:
note.meta = {**note.meta, **form_data["meta"]}
if "access_control" in form_data:
note.access_control = form_data["access_control"]
if "access_grants" in form_data:
AccessGrants.set_access_grants(
"note", id, form_data["access_grants"], db=db
)
note.updated_at = int(time.time_ns())
db.commit()
return NoteModel.model_validate(note) if note else None
return self._to_note_model(note, db=db) if note else None
def delete_note_by_id(self, id: str):
with get_db() as db:
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("note", id, db=db)
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
except Exception:
return False
Notes = NoteTable()
+42 -24
View File
@@ -8,14 +8,14 @@ import json
from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# DB MODEL
@@ -25,7 +25,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class OAuthSession(Base):
__tablename__ = "oauth_session"
id = Column(Text, primary_key=True)
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text, nullable=False)
provider = Column(Text, nullable=False)
token = Column(
@@ -102,7 +102,7 @@ class OAuthSessionTable:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting tokens: {e}")
log.error(f"Error decrypting tokens: {type(e).__name__}: {e}")
raise
def create_session(
@@ -110,10 +110,11 @@ class OAuthSessionTable:
user_id: str,
provider: str,
token: dict,
db: Optional[Session] = None,
) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session"""
try:
with get_db() as db:
with get_db_context(db) as db:
current_time = int(time.time())
id = str(uuid.uuid4())
@@ -142,10 +143,12 @@ class OAuthSessionTable:
log.error(f"Error creating OAuth session: {e}")
return None
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
def get_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID"""
try:
with get_db() as db:
with get_db_context(db) as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
@@ -157,11 +160,11 @@ class OAuthSessionTable:
return None
def get_session_by_id_and_user_id(
self, session_id: str, user_id: str
self, session_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID"""
try:
with get_db() as db:
with get_db_context(db) as db:
session = (
db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id)
@@ -177,11 +180,11 @@ class OAuthSessionTable:
return None
def get_session_by_provider_and_user_id(
self, provider: str, user_id: str
self, provider: str, user_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by provider and user ID"""
try:
with get_db() as db:
with get_db_context(db) as db:
session = (
db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
@@ -196,16 +199,25 @@ class OAuthSessionTable:
log.error(f"Error getting OAuth session by provider and user ID: {e}")
return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
def get_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
with get_db() as db:
with get_db_context(db) as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
results = []
for session in sessions:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
try:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
except Exception as e:
log.warning(
f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}"
)
db.query(OAuthSession).filter_by(id=session.id).delete()
db.commit()
return results
@@ -214,11 +226,11 @@ class OAuthSessionTable:
return []
def update_session_by_id(
self, session_id: str, token: dict
self, session_id: str, token: dict, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens"""
try:
with get_db() as db:
with get_db_context(db) as db:
current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update(
@@ -240,10 +252,12 @@ class OAuthSessionTable:
log.error(f"Error updating OAuth session tokens: {e}")
return None
def delete_session_by_id(self, session_id: str) -> bool:
def delete_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> bool:
"""Delete an OAuth session"""
try:
with get_db() as db:
with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit()
return result > 0
@@ -251,10 +265,12 @@ class OAuthSessionTable:
log.error(f"Error deleting OAuth session: {e}")
return False
def delete_sessions_by_user_id(self, user_id: str) -> bool:
def delete_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
"""Delete all OAuth sessions for a user"""
try:
with get_db() as db:
with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit()
return True
@@ -262,10 +278,12 @@ class OAuthSessionTable:
log.error(f"Error deleting OAuth sessions by user ID: {e}")
return False
def delete_sessions_by_provider(self, provider: str) -> bool:
def delete_sessions_by_provider(
self, provider: str, db: Optional[Session] = None
) -> bool:
"""Delete all OAuth sessions for a provider"""
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(OAuthSession).filter_by(provider=provider).delete()
db.commit()
return True
+234
View File
@@ -0,0 +1,234 @@
"""Prompt history model for version tracking."""
import time
import uuid
from typing import Optional
import json
import difflib
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Index
####################
# PromptHistory DB Schema
####################
class PromptHistory(Base):
__tablename__ = "prompt_history"
id = Column(Text, primary_key=True)
prompt_id = Column(Text, nullable=False, index=True)
parent_id = Column(Text, nullable=True) # Reference to parent commit
snapshot = Column(JSON, nullable=False)
user_id = Column(Text, nullable=False)
commit_message = Column(Text, nullable=True)
created_at = Column(BigInteger, nullable=False)
class PromptHistoryModel(BaseModel):
id: str
prompt_id: str
parent_id: Optional[str] = None
snapshot: dict
user_id: str
commit_message: Optional[str] = None
created_at: int
model_config = ConfigDict(from_attributes=True)
class PromptHistoryResponse(PromptHistoryModel):
"""Response model with user info."""
user: Optional[UserResponse] = None
class PromptHistoryTable:
def create_history_entry(
self,
prompt_id: str,
snapshot: dict,
user_id: str,
parent_id: Optional[str] = None,
commit_message: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Create a new history entry (commit) for a prompt."""
with get_db_context(db) as db:
history = PromptHistory(
id=str(uuid.uuid4()),
prompt_id=prompt_id,
parent_id=parent_id,
snapshot=snapshot,
user_id=user_id,
commit_message=commit_message,
created_at=int(time.time()),
)
db.add(history)
db.commit()
db.refresh(history)
return PromptHistoryModel.model_validate(history)
def get_history_by_prompt_id(
self,
prompt_id: str,
limit: int = 50,
offset: int = 0,
db: Optional[Session] = None,
) -> list[PromptHistoryResponse]:
"""Get all history entries for a prompt, ordered by created_at desc."""
with get_db_context(db) as db:
entries = (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
# Get user info for each entry
user_ids = list(set(e.user_id for e in entries))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
PromptHistoryResponse(
**PromptHistoryModel.model_validate(entry).model_dump(),
user=(
users_dict.get(entry.user_id).model_dump()
if users_dict.get(entry.user_id)
else None
),
)
for entry in entries
]
def get_history_entry_by_id(
self,
history_id: str,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Get a specific history entry by ID."""
with get_db_context(db) as db:
entry = (
db.query(PromptHistory).filter(PromptHistory.id == history_id).first()
)
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_latest_history_entry(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> Optional[PromptHistoryModel]:
"""Get the most recent history entry for a prompt."""
with get_db_context(db) as db:
entry = (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.first()
)
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_history_count(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> int:
"""Get the number of history entries for a prompt."""
with get_db_context(db) as db:
return (
db.query(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.count()
)
def compute_diff(
self,
from_id: str,
to_id: str,
db: Optional[Session] = None,
) -> Optional[dict]:
"""Compute diff between two history entries."""
with get_db_context(db) as db:
from_entry = (
db.query(PromptHistory).filter(PromptHistory.id == from_id).first()
)
to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first()
if not from_entry or not to_entry:
return None
from_snapshot = from_entry.snapshot
to_snapshot = to_entry.snapshot
# Compute diff for content field
from_content = from_snapshot.get("content", "")
to_content = to_snapshot.get("content", "")
diff_lines = list(
difflib.unified_diff(
from_content.splitlines(keepends=True),
to_content.splitlines(keepends=True),
fromfile=f"v{from_id[:8]}",
tofile=f"v{to_id[:8]}",
lineterm="",
)
)
return {
"from_id": from_id,
"to_id": to_id,
"from_snapshot": from_snapshot,
"to_snapshot": to_snapshot,
"content_diff": diff_lines,
"name_changed": from_snapshot.get("name") != to_snapshot.get("name"),
}
def delete_history_by_prompt_id(
self,
prompt_id: str,
db: Optional[Session] = None,
) -> bool:
"""Delete all history entries for a prompt."""
with get_db_context(db) as db:
db.query(PromptHistory).filter(
PromptHistory.prompt_id == prompt_id
).delete()
db.commit()
return True
def delete_history_entry(
self,
history_id: str,
db: Optional[Session] = None,
) -> bool:
"""Delete a history entry and reparent its children to grandparent."""
with get_db_context(db) as db:
entry = db.query(PromptHistory).filter_by(id=history_id).first()
if not entry:
return False
# Find children that reference this entry as parent
children = db.query(PromptHistory).filter_by(parent_id=history_id).all()
# Reparent children to grandparent
for child in children:
child.parent_id = entry.parent_id
db.delete(entry)
db.commit()
return True
PromptHistories = PromptHistoryTable()
+529 -61
View File
@@ -1,14 +1,17 @@
import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from open_webui.models.prompt_history import PromptHistories
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
####################
# Prompts DB Schema
@@ -18,38 +21,35 @@ from open_webui.utils.access_control import has_access
class Prompt(Base):
__tablename__ = "prompt"
command = Column(String, primary_key=True)
id = Column(Text, primary_key=True)
command = Column(String, unique=True, index=True)
user_id = Column(String)
title = Column(Text)
name = Column(Text)
content = Column(Text)
timestamp = Column(BigInteger)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
tags = Column(JSON, nullable=True)
is_active = Column(Boolean, default=True)
version_id = Column(Text, nullable=True) # Points to active history entry
created_at = Column(BigInteger, nullable=True)
updated_at = Column(BigInteger, nullable=True)
class PromptModel(BaseModel):
id: Optional[str] = None
command: str
user_id: str
title: str
name: str
content: str
timestamp: int # timestamp in epoch
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
is_active: Optional[bool] = True
version_id: Optional[str] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
access_control: Optional[dict] = None
model_config = ConfigDict(from_attributes=True)
@@ -62,53 +62,152 @@ class PromptUserResponse(PromptModel):
user: Optional[UserResponse] = None
class PromptAccessResponse(PromptUserResponse):
write_access: Optional[bool] = False
class PromptListResponse(BaseModel):
items: list[PromptUserResponse]
total: int
class PromptAccessListResponse(BaseModel):
items: list[PromptAccessResponse]
total: int
class PromptForm(BaseModel):
command: str
title: str
name: str # Changed from title
content: str
access_control: Optional[dict] = None
data: Optional[dict] = None
meta: Optional[dict] = None
tags: Optional[list[str]] = None
access_grants: Optional[list[dict]] = None
version_id: Optional[str] = None # Active version
commit_message: Optional[str] = None # For history tracking
is_production: Optional[bool] = True # Whether to set new version as production
class PromptsTable:
def _get_access_grants(
self, prompt_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
def _to_prompt_model(
self, prompt: Prompt, db: Optional[Session] = None
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(
exclude={"access_grants"}
)
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
return PromptModel.model_validate(prompt_data)
def insert_new_prompt(
self, user_id: str, form_data: PromptForm
self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]:
now = int(time.time())
prompt_id = str(uuid.uuid4())
prompt = PromptModel(
**{
"user_id": user_id,
**form_data.model_dump(),
"timestamp": int(time.time()),
}
id=prompt_id,
user_id=user_id,
command=form_data.command,
name=form_data.name,
content=form_data.content,
data=form_data.data or {},
meta=form_data.meta or {},
tags=form_data.tags or [],
access_grants=[],
is_active=True,
created_at=now,
updated_at=now,
)
try:
with get_db() as db:
result = Prompt(**prompt.model_dump())
with get_db_context(db) as db:
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"prompt", prompt_id, form_data.access_grants, db=db
)
if result:
return PromptModel.model_validate(result)
current_access_grants = self._get_access_grants(prompt_id, db=db)
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": form_data.command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": form_data.tags or [],
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt_id,
snapshot=snapshot,
user_id=user_id,
parent_id=None, # Initial commit has no parent
commit_message=form_data.commit_message or "Initial version",
db=db,
)
# Set the initial version as the production version
if history_entry:
result.version_id = history_entry.id
db.commit()
db.refresh(result)
return self._to_prompt_model(result, db=db)
else:
return None
except Exception:
return None
def get_prompt_by_command(self, command: str) -> Optional[PromptModel]:
def get_prompt_by_id(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
"""Get prompt by UUID."""
try:
with get_db() as db:
prompt = db.query(Prompt).filter_by(command=command).first()
return PromptModel.model_validate(prompt)
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
def get_prompts(self) -> list[PromptUserResponse]:
with get_db() as db:
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
def get_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
with get_db_context(db) as db:
all_prompts = (
db.query(Prompt)
.filter(Prompt.is_active == True)
.order_by(Prompt.updated_at.desc())
.all()
)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
prompts = []
@@ -117,7 +216,7 @@ class PromptsTable:
prompts.append(
PromptUserResponse.model_validate(
{
**PromptModel.model_validate(prompt).model_dump(),
**self._to_prompt_model(prompt, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -126,42 +225,411 @@ class PromptsTable:
return prompts
def get_prompts_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[PromptUserResponse]:
prompts = self.get_prompts()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
prompts = self.get_prompts(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
prompt
for prompt in prompts
if prompt.user_id == user_id
or has_access(user_id, permission, prompt.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="prompt",
resource_id=prompt.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def search_prompts(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> PromptListResponse:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = query.filter(Prompt.is_active == True)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Prompt.name.ilike(f"%{query_key}%"),
Prompt.command.ilike(f"%{query_key}%"),
Prompt.content.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Prompt.user_id == user_id)
elif view_option == "shared":
query = query.filter(Prompt.user_id != user_id)
# Apply access grant filtering
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Prompt,
filter=filter,
resource_type="prompt",
permission="read",
)
tag = filter.get("tag")
if tag:
# Search for tag in JSON array field
like_pattern = f'%"{tag.lower()}"%'
tags_text = func.lower(cast(Prompt.tags, String))
query = query.filter(tags_text.like(like_pattern))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Prompt.name.asc())
else:
query = query.order_by(Prompt.name.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Prompt.created_at.asc())
else:
query = query.order_by(Prompt.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Prompt.updated_at.asc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
else:
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**self._to_prompt_model(prompt, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return PromptListResponse(items=prompts, total=total)
def update_prompt_by_command(
self, command: str, form_data: PromptForm
self,
command: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
prompt.title = form_data.title
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
current_access_grants = self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.content != form_data.content
or form_data.access_grants is not None
)
# Update prompt fields
prompt.name = form_data.name
prompt.content = form_data.content
prompt.access_control = form_data.access_control
prompt.timestamp = int(time.time())
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
prompt.updated_at = int(time.time())
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"prompt", prompt.id, form_data.access_grants, db=db
)
current_access_grants = self._get_access_grants(prompt.id, db=db)
db.commit()
return PromptModel.model_validate(prompt)
# Create history entry only if content changed
if content_changed:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
parent_id=parent_id,
commit_message=form_data.commit_message,
db=db,
)
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def delete_prompt_by_command(self, command: str) -> bool:
def update_prompt_by_id(
self,
prompt_id: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
try:
with get_db() as db:
db.query(Prompt).filter_by(command=command).delete()
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
parent_id = latest_history.id if latest_history else None
current_access_grants = self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
prompt.name != form_data.name
or prompt.command != form_data.command
or prompt.content != form_data.content
or form_data.access_grants is not None
or (form_data.tags is not None and prompt.tags != form_data.tags)
)
# Update prompt fields
prompt.name = form_data.name
prompt.command = form_data.command
prompt.content = form_data.content
prompt.data = form_data.data or prompt.data
prompt.meta = form_data.meta or prompt.meta
if form_data.tags is not None:
prompt.tags = form_data.tags
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"prompt", prompt.id, form_data.access_grants, db=db
)
current_access_grants = self._get_access_grants(prompt.id, db=db)
prompt.updated_at = int(time.time())
db.commit()
return True
# Create history entry only if content changed
if content_changed:
snapshot = {
"name": form_data.name,
"content": form_data.content,
"command": prompt.command,
"data": form_data.data or {},
"meta": form_data.meta or {},
"tags": prompt.tags or [],
"access_grants": [
grant.model_dump() for grant in current_access_grants
],
}
history_entry = PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
parent_id=parent_id,
commit_message=form_data.commit_message,
db=db,
)
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_metadata(
self,
prompt_id: str,
name: str,
command: str,
tags: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
"""Update only name, command, and tags (no history created)."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
prompt.name = name
prompt.command = command
if tags is not None:
prompt.tags = tags
prompt.updated_at = int(time.time())
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_version(
self,
prompt_id: str,
version_id: str,
db: Optional[Session] = None,
) -> Optional[PromptModel]:
"""Set the active version of a prompt and restore content from that version's snapshot."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if not prompt:
return None
history_entry = PromptHistories.get_history_entry_by_id(
version_id, db=db
)
if not history_entry:
return None
# Restore prompt content from the snapshot
snapshot = history_entry.snapshot
if snapshot:
prompt.name = snapshot.get("name", prompt.name)
prompt.content = snapshot.get("content", prompt.content)
prompt.data = snapshot.get("data", prompt.data)
prompt.meta = snapshot.get("meta", prompt.meta)
prompt.tags = snapshot.get("tags", prompt.tags)
# Note: command and access_grants are not restored from snapshot
prompt.version_id = version_id
prompt.updated_at = int(time.time())
db.commit()
return self._to_prompt_model(prompt, db=db)
except Exception:
return None
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def hard_delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Permanently delete a prompt and its history."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
# Delete prompt
db.query(Prompt).filter_by(command=command).delete()
db.commit()
return True
return False
except Exception:
return False
def get_tags(self, db: Optional[Session] = None) -> list[str]:
try:
with get_db_context(db) as db:
prompts = db.query(Prompt).filter_by(is_active=True).all()
tags = set()
for prompt in prompts:
if prompt.tags:
for tag in prompt.tags:
if tag:
tags.add(tag)
return sorted(list(tags))
except Exception:
return []
Prompts = PromptsTable()
+339
View File
@@ -0,0 +1,339 @@
import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, or_
log = logging.getLogger(__name__)
####################
# Skills DB Schema
####################
class Skill(Base):
__tablename__ = "skill"
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
name = Column(Text, unique=True)
description = Column(Text, nullable=True)
content = Column(Text)
meta = Column(JSON)
is_active = Column(Boolean, default=True)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class SkillMeta(BaseModel):
tags: Optional[list[str]] = []
class SkillModel(BaseModel):
id: str
user_id: str
name: str
description: Optional[str] = None
content: str
meta: SkillMeta
is_active: bool = True
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class SkillUserModel(SkillModel):
user: Optional[UserResponse] = None
class SkillResponse(BaseModel):
id: str
user_id: str
name: str
description: Optional[str] = None
meta: SkillMeta
is_active: bool = True
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
class SkillUserResponse(SkillResponse):
user: Optional[UserResponse] = None
model_config = ConfigDict(extra="allow")
class SkillAccessResponse(SkillUserResponse):
write_access: Optional[bool] = False
class SkillForm(BaseModel):
id: str
name: str
description: Optional[str] = None
content: str
meta: SkillMeta = SkillMeta()
is_active: bool = True
access_grants: Optional[list[dict]] = None
class SkillListResponse(BaseModel):
items: list[SkillUserResponse] = []
total: int = 0
class SkillAccessListResponse(BaseModel):
items: list[SkillAccessResponse] = []
total: int = 0
class SkillsTable:
def _get_access_grants(
self, skill_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(
exclude={"access_grants"}
)
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
return SkillModel.model_validate(skill_data)
def insert_new_skill(
self,
user_id: str,
form_data: SkillForm,
db: Optional[Session] = None,
) -> Optional[SkillModel]:
with get_db_context(db) as db:
try:
result = Skill(
**{
**form_data.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"skill", result.id, form_data.access_grants, db=db
)
if result:
return self._to_skill_model(result, db=db)
else:
return None
except Exception as e:
log.exception(f"Error creating a new skill: {e}")
return None
def get_skill_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[SkillModel]:
try:
with get_db_context(db) as db:
skill = db.get(Skill, id)
return self._to_skill_model(skill, db=db) if skill else None
except Exception:
return None
def get_skill_by_name(
self, name: str, db: Optional[Session] = None
) -> Optional[SkillModel]:
try:
with get_db_context(db) as db:
skill = db.query(Skill).filter_by(name=name).first()
return self._to_skill_model(skill, db=db) if skill else None
except Exception:
return None
def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]:
with get_db_context(db) as db:
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
user_ids = list(set(skill.user_id for skill in all_skills))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
skills = []
for skill in all_skills:
user = users_dict.get(skill.user_id)
skills.append(
SkillUserModel.model_validate(
{
**self._to_skill_model(skill, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
)
return skills
def get_skills_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[SkillUserModel]:
skills = self.get_skills(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
skill
for skill in skills
if skill.user_id == user_id
or AccessGrants.has_access(
user_id=user_id,
resource_type="skill",
resource_id=skill.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def search_skills(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> SkillListResponse:
try:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
# Join with User table for user filtering
query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Skill.name.ilike(f"%{query_key}%"),
Skill.description.ilike(f"%{query_key}%"),
Skill.id.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Skill.user_id == user_id)
elif view_option == "shared":
query = query.filter(Skill.user_id != user_id)
# Apply access grant filtering
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Skill,
filter=filter,
resource_type="skill",
permission="read",
)
query = query.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
skills = []
for skill, user in items:
skills.append(
SkillUserResponse(
**self._to_skill_model(skill, db=db).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return SkillListResponse(items=skills, total=total)
except Exception as e:
log.exception(f"Error searching skills: {e}")
return SkillListResponse(items=[], total=0)
def update_skill_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[SkillModel]:
try:
with get_db_context(db) as db:
access_grants = updated.pop("access_grants", None)
db.query(Skill).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())}
)
db.commit()
if access_grants is not None:
AccessGrants.set_access_grants("skill", id, access_grants, db=db)
skill = db.query(Skill).get(id)
db.refresh(skill)
return self._to_skill_model(skill, db=db)
except Exception:
return None
def toggle_skill_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[SkillModel]:
with get_db_context(db) as db:
try:
skill = db.query(Skill).filter_by(id=id).first()
if not skill:
return None
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
db.commit()
db.refresh(skill)
return self._to_skill_model(skill, db=db)
except Exception:
return None
def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("skill", id, db=db)
db.query(Skill).filter_by(id=id).delete()
db.commit()
return True
except Exception:
return False
Skills = SkillsTable()
+58 -13
View File
@@ -3,15 +3,14 @@ import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, JSON, PrimaryKeyConstraint, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
@@ -52,8 +51,10 @@ class TagChatIdForm(BaseModel):
class TagTable:
def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]:
with get_db() as db:
def insert_new_tag(
self, name: str, user_id: str, db: Optional[Session] = None
) -> Optional[TagModel]:
with get_db_context(db) as db:
id = name.replace(" ", "_").lower()
tag = TagModel(**{"id": id, "user_id": user_id, "name": name})
try:
@@ -70,27 +71,29 @@ class TagTable:
return None
def get_tag_by_name_and_user_id(
self, name: str, user_id: str
self, name: str, user_id: str, db: Optional[Session] = None
) -> Optional[TagModel]:
try:
id = name.replace(" ", "_").lower()
with get_db() as db:
with get_db_context(db) as db:
tag = db.query(Tag).filter_by(id=id, user_id=user_id).first()
return TagModel.model_validate(tag)
except Exception:
return None
def get_tags_by_user_id(self, user_id: str) -> list[TagModel]:
with get_db() as db:
def get_tags_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db_context(db) as db:
return [
TagModel.model_validate(tag)
for tag in (db.query(Tag).filter_by(user_id=user_id).all())
]
def get_tags_by_ids_and_user_id(
self, ids: list[str], user_id: str
self, ids: list[str], user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db() as db:
with get_db_context(db) as db:
return [
TagModel.model_validate(tag)
for tag in (
@@ -98,9 +101,11 @@ class TagTable:
)
]
def delete_tag_by_name_and_user_id(self, name: str, user_id: str) -> bool:
def delete_tag_by_name_and_user_id(
self, name: str, user_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
id = name.replace(" ", "_").lower()
res = db.query(Tag).filter_by(id=id, user_id=user_id).delete()
log.debug(f"res: {res}")
@@ -110,5 +115,45 @@ class TagTable:
log.error(f"delete_tag: {e}")
return False
def delete_tags_by_ids_and_user_id(
self, ids: list[str], user_id: str, db: Optional[Session] = None
) -> bool:
"""Delete all tags whose id is in *ids* for the given user, in one query."""
if not ids:
return True
try:
with get_db_context(db) as db:
db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete(
synchronize_session=False
)
db.commit()
return True
except Exception as e:
log.error(f"delete_tags_by_ids: {e}")
return False
def ensure_tags_exist(
self, names: list[str], user_id: str, db: Optional[Session] = None
) -> None:
"""Create tag rows for any *names* that don't already exist for *user_id*."""
if not names:
return
ids = [n.replace(" ", "_").lower() for n in names]
with get_db_context(db) as db:
existing = {
t.id
for t in db.query(Tag.id)
.filter(Tag.id.in_(ids), Tag.user_id == user_id)
.all()
}
new_tags = [
Tag(id=tag_id, name=name, user_id=user_id)
for tag_id, name in zip(ids, names)
if tag_id not in existing
]
if new_tags:
db.add_all(new_tags)
db.commit()
Tags = TagTable()
+90 -70
View File
@@ -2,19 +2,16 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Column, String, Text
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# Tools DB Schema
@@ -24,7 +21,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"])
class Tool(Base):
__tablename__ = "tool"
id = Column(String, primary_key=True)
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
name = Column(Text)
content = Column(Text)
@@ -32,23 +29,6 @@ class Tool(Base):
meta = Column(JSONField)
valves = Column(JSONField)
access_control = Column(JSON, nullable=True) # Controls data access levels.
# Defines access control rules for this entry.
# - `None`: Public access, available to all users with the "user" role.
# - `{}`: Private access, restricted exclusively to the owner.
# - Custom permissions: Specific access control for reading and writing;
# Can specify group or user-level restrictions:
# {
# "read": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# },
# "write": {
# "group_ids": ["group_id1", "group_id2"],
# "user_ids": ["user_id1", "user_id2"]
# }
# }
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
@@ -65,7 +45,7 @@ class ToolModel(BaseModel):
content: str
specs: list[dict]
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@@ -87,7 +67,7 @@ class ToolResponse(BaseModel):
user_id: str
name: str
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
@@ -98,12 +78,16 @@ class ToolUserResponse(ToolResponse):
model_config = ConfigDict(extra="allow")
class ToolAccessResponse(ToolUserResponse):
write_access: Optional[bool] = False
class ToolForm(BaseModel):
id: str
name: str
content: str
meta: ToolMeta
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class ToolValves(BaseModel):
@@ -111,48 +95,65 @@ class ToolValves(BaseModel):
class ToolsTable:
def insert_new_tool(
self, user_id: str, form_data: ToolForm, specs: list[dict]
) -> Optional[ToolModel]:
with get_db() as db:
tool = ToolModel(
**{
**form_data.model_dump(),
"specs": specs,
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
def _get_access_grants(
self, tool_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
return ToolModel.model_validate(tool_data)
def insert_new_tool(
self,
user_id: str,
form_data: ToolForm,
specs: list[dict],
db: Optional[Session] = None,
) -> Optional[ToolModel]:
with get_db_context(db) as db:
try:
result = Tool(**tool.model_dump())
result = Tool(
**{
**form_data.model_dump(exclude={"access_grants"}),
"specs": specs,
"user_id": user_id,
"updated_at": int(time.time()),
"created_at": int(time.time()),
}
)
db.add(result)
db.commit()
db.refresh(result)
AccessGrants.set_access_grants(
"tool", result.id, form_data.access_grants, db=db
)
if result:
return ToolModel.model_validate(result)
return self._to_tool_model(result, db=db)
else:
return None
except Exception as e:
log.exception(f"Error creating a new tool: {e}")
return None
def get_tool_by_id(self, id: str) -> Optional[ToolModel]:
def get_tool_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ToolModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
tool = db.get(Tool, id)
return ToolModel.model_validate(tool)
return self._to_tool_model(tool, db=db) if tool else None
except Exception:
return None
def get_tools(self) -> list[ToolUserModel]:
with get_db() as db:
def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
with get_db_context(db) as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
user_ids = list(set(tool.user_id for tool in all_tools))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
tools = []
@@ -161,7 +162,7 @@ class ToolsTable:
tools.append(
ToolUserModel.model_validate(
{
**ToolModel.model_validate(tool).model_dump(),
**self._to_tool_model(tool, db=db).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -169,43 +170,56 @@ class ToolsTable:
return tools
def get_tools_by_user_id(
self, user_id: str, permission: str = "write"
self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[ToolUserModel]:
tools = self.get_tools()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
tools = self.get_tools(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [
tool
for tool in tools
if tool.user_id == user_id
or has_access(user_id, permission, tool.access_control, user_group_ids)
or AccessGrants.has_access(
user_id=user_id,
resource_type="tool",
resource_id=tool.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
def get_tool_valves_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
try:
with get_db() as db:
with get_db_context(db) as db:
tool = db.get(Tool, id)
return tool.valves if tool.valves else {}
except Exception as e:
log.exception(f"Error getting tool valves by id {id}")
return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]:
def update_tool_valves_by_id(
self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[ToolValves]:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).update(
{"valves": valves, "updated_at": int(time.time())}
)
db.commit()
return self.get_tool_by_id(id)
return self.get_tool_by_id(id, db=db)
except Exception:
return None
def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "tools" and "valves" settings
@@ -222,10 +236,10 @@ class ToolsTable:
return None
def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id)
user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "tools" and "valves" settings
@@ -237,7 +251,7 @@ class ToolsTable:
user_settings["tools"]["valves"][id] = valves
# Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings})
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
return user_settings["tools"]["valves"][id]
except Exception as e:
@@ -246,23 +260,29 @@ class ToolsTable:
)
return None
def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]:
def update_tool_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[ToolModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
access_grants = updated.pop("access_grants", None)
db.query(Tool).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())}
)
db.commit()
if access_grants is not None:
AccessGrants.set_access_grants("tool", id, access_grants, db=db)
tool = db.query(Tool).get(id)
db.refresh(tool)
return ToolModel.model_validate(tool)
return self._to_tool_model(tool, db=db)
except Exception:
return None
def delete_tool_by_id(self, id: str) -> bool:
def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("tool", id, db=db)
db.query(Tool).filter_by(id=id).delete()
db.commit()
+457 -138
View File
@@ -1,18 +1,35 @@
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from sqlalchemy.orm import Session, defer
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select
from sqlalchemy import or_, case
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import (
BigInteger,
JSON,
Column,
String,
Boolean,
Text,
Date,
exists,
select,
cast,
)
from sqlalchemy import or_, case, func
from sqlalchemy.dialects.postgresql import JSONB
import datetime
@@ -21,59 +38,73 @@ import datetime
####################
class User(Base):
__tablename__ = "user"
id = Column(String, primary_key=True)
name = Column(String)
email = Column(String)
username = Column(String(50), nullable=True)
role = Column(String)
profile_image_url = Column(Text)
bio = Column(Text, nullable=True)
gender = Column(Text, nullable=True)
date_of_birth = Column(Date, nullable=True)
info = Column(JSONField, nullable=True)
settings = Column(JSONField, nullable=True)
api_key = Column(String, nullable=True, unique=True)
oauth_sub = Column(Text, unique=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class UserSettings(BaseModel):
ui: Optional[dict] = {}
model_config = ConfigDict(extra="allow")
pass
class User(Base):
__tablename__ = "user"
id = Column(String, primary_key=True, unique=True)
email = Column(String)
username = Column(String(50), nullable=True)
role = Column(String)
name = Column(String)
profile_image_url = Column(Text)
profile_banner_image_url = Column(Text, nullable=True)
bio = Column(Text, nullable=True)
gender = Column(Text, nullable=True)
date_of_birth = Column(Date, nullable=True)
timezone = Column(String, nullable=True)
presence_state = Column(String, nullable=True)
status_emoji = Column(String, nullable=True)
status_message = Column(Text, nullable=True)
status_expires_at = Column(BigInteger, nullable=True)
info = Column(JSON, nullable=True)
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
class UserModel(BaseModel):
id: str
name: str
email: str
username: Optional[str] = None
role: str = "pending"
profile_image_url: str
name: str
profile_image_url: Optional[str] = None
profile_banner_image_url: Optional[str] = None
bio: Optional[str] = None
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
timezone: Optional[str] = None
presence_state: Optional[str] = None
status_emoji: Optional[str] = None
status_message: Optional[str] = None
status_expires_at: Optional[int] = None
info: Optional[dict] = None
settings: Optional[UserSettings] = None
api_key: Optional[str] = None
oauth_sub: Optional[str] = None
oauth: Optional[dict] = None
scim: Optional[dict] = None
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -81,6 +112,44 @@ class UserModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="after")
def set_profile_image_url(self):
if not self.profile_image_url:
self.profile_image_url = f"/api/v1/users/{self.id}/profile/image"
return self
class UserStatusModel(UserModel):
is_active: bool = False
model_config = ConfigDict(from_attributes=True)
class ApiKey(Base):
__tablename__ = "api_key"
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text, nullable=False)
key = Column(Text, unique=True, nullable=False)
data = Column(JSON, nullable=True)
expires_at = Column(BigInteger, nullable=True)
last_used_at = Column(BigInteger, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
class ApiKeyModel(BaseModel):
id: str
user_id: str
key: str
data: Optional[dict] = None
expires_at: Optional[int] = None
last_used_at: Optional[int] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
@@ -94,6 +163,11 @@ class UpdateProfileForm(BaseModel):
gender: Optional[str] = None
date_of_birth: Optional[datetime.date] = None
@field_validator("profile_image_url")
@classmethod
def check_profile_image_url(cls, v: str) -> str:
return validate_profile_image_url(v)
class UserGroupIdsModel(UserModel):
group_ids: list[str] = []
@@ -113,11 +187,20 @@ class UserGroupIdsListResponse(BaseModel):
total: int
class UserInfoResponse(BaseModel):
class UserStatus(BaseModel):
status_emoji: Optional[str] = None
status_message: Optional[str] = None
status_expires_at: Optional[int] = None
class UserInfoResponse(UserStatus):
id: str
name: str
email: str
role: str
bio: Optional[str] = None
groups: Optional[list] = []
is_active: bool = False
class UserIdNameResponse(BaseModel):
@@ -125,6 +208,12 @@ class UserIdNameResponse(BaseModel):
name: str
class UserIdNameStatusResponse(UserStatus):
id: str
name: str
is_active: Optional[bool] = None
class UserInfoListResponse(BaseModel):
users: list[UserInfoResponse]
total: int
@@ -135,18 +224,18 @@ class UserIdNameListResponse(BaseModel):
total: int
class UserResponse(BaseModel):
id: str
name: str
email: str
role: str
profile_image_url: str
class UserNameResponse(BaseModel):
id: str
name: str
role: str
class UserResponse(UserNameResponse):
email: str
class UserProfileImageResponse(UserNameResponse):
email: str
profile_image_url: str
@@ -162,6 +251,11 @@ class UserUpdateForm(BaseModel):
profile_image_url: str
password: Optional[str] = None
@field_validator("profile_image_url")
@classmethod
def check_profile_image_url(cls, v: str) -> str:
return validate_profile_image_url(v)
class UsersTable:
def insert_new_user(
@@ -171,20 +265,23 @@ class UsersTable:
email: str,
profile_image_url: str = "/user.png",
role: str = "pending",
oauth_sub: Optional[str] = None,
username: Optional[str] = None,
oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]:
with get_db() as db:
with get_db_context(db) as db:
user = UserModel(
**{
"id": id,
"name": name,
"email": email,
"name": name,
"role": role,
"profile_image_url": profile_image_url,
"last_active_at": int(time.time()),
"created_at": int(time.time()),
"updated_at": int(time.time()),
"oauth_sub": oauth_sub,
"username": username,
"oauth": oauth,
}
)
result = User(**user.model_dump())
@@ -196,35 +293,86 @@ class UsersTable:
else:
return None
def get_user_by_id(self, id: str) -> Optional[UserModel]:
def get_user_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
except Exception:
return None
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]:
def get_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
user = db.query(User).filter_by(api_key=api_key).first()
return UserModel.model_validate(user)
with get_db_context(db) as db:
user = (
db.query(User)
.join(ApiKey, User.id == ApiKey.user_id)
.filter(ApiKey.key == api_key)
.first()
)
return UserModel.model_validate(user) if user else None
except Exception:
return None
def get_user_by_email(self, email: str) -> Optional[UserModel]:
def get_user_by_email(
self, email: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
user = db.query(User).filter_by(email=email).first()
return UserModel.model_validate(user)
with get_db_context(db) as db:
user = (
db.query(User)
.filter(func.lower(User.email) == email.lower())
.first()
)
return UserModel.model_validate(user) if user else None
except Exception:
return None
def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]:
def get_user_by_oauth_sub(
self, provider: str, sub: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
user = db.query(User).filter_by(oauth_sub=sub).first()
return UserModel.model_validate(user)
with get_db_context(db) as db: # type: Session
dialect_name = db.bind.dialect.name
query = db.query(User)
if dialect_name == "sqlite":
query = query.filter(User.oauth.contains({provider: {"sub": sub}}))
elif dialect_name == "postgresql":
query = query.filter(
User.oauth[provider].cast(JSONB)["sub"].astext == sub
)
user = query.first()
return UserModel.model_validate(user) if user else None
except Exception as e:
# You may want to log the exception here
return None
def get_user_by_scim_external_id(
self, provider: str, external_id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db_context(db) as db: # type: Session
dialect_name = db.bind.dialect.name
query = db.query(User)
if dialect_name == "sqlite":
query = query.filter(
User.scim.contains({provider: {"external_id": external_id}})
)
elif dialect_name == "postgresql":
query = query.filter(
User.scim[provider].cast(JSONB)["external_id"].astext
== external_id
)
user = query.first()
return UserModel.model_validate(user) if user else None
except Exception:
return None
@@ -233,10 +381,11 @@ class UsersTable:
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
db: Optional[Session] = None,
) -> dict:
with get_db() as db:
with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested
query = db.query(User)
query = db.query(User).options(defer(User.profile_image_url))
if filter:
query_key = filter.get("query")
@@ -248,6 +397,17 @@ class UsersTable:
)
)
channel_id = filter.get("channel_id")
if channel_id:
query = query.filter(
exists(
select(ChannelMember.id).where(
ChannelMember.user_id == User.id,
ChannelMember.channel_id == channel_id,
)
)
)
user_ids = filter.get("user_ids")
group_ids = filter.get("group_ids")
@@ -340,7 +500,6 @@ class UsersTable:
query = query.order_by(User.created_at.desc())
# Count BEFORE pagination
query = query.distinct(User.id)
total = query.count()
# correct pagination logic
@@ -355,30 +514,52 @@ class UsersTable:
"total": total,
}
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
def get_users_by_group_id(
self, group_id: str, db: Optional[Session] = None
) -> list[UserModel]:
with get_db_context(db) as db:
users = (
db.query(User)
.options(defer(User.profile_image_url))
.join(GroupMember, User.id == GroupMember.user_id)
.filter(GroupMember.group_id == group_id)
.all()
)
return [UserModel.model_validate(user) for user in users]
def get_num_users(self) -> Optional[int]:
with get_db() as db:
def get_users_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> list[UserStatusModel]:
with get_db_context(db) as db:
users = (
db.query(User)
.options(defer(User.profile_image_url))
.filter(User.id.in_(user_ids))
.all()
)
return [UserModel.model_validate(user) for user in users]
def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
with get_db_context(db) as db:
return db.query(User).count()
def has_users(self) -> bool:
with get_db() as db:
def has_users(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
return db.query(db.query(User).exists()).scalar()
def get_first_user(self) -> UserModel:
def get_first_user(self, db: Optional[Session] = None) -> UserModel:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).order_by(User.created_at).first()
return UserModel.model_validate(user)
except Exception:
return None
def get_user_webhook_url_by_id(self, id: str) -> Optional[str]:
def get_user_webhook_url_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if user.settings is None:
@@ -392,8 +573,8 @@ class UsersTable:
except Exception:
return None
def get_num_users_active_today(self) -> Optional[int]:
with get_db() as db:
def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[int]:
with get_db_context(db) as db:
current_timestamp = int(datetime.datetime.now().timestamp())
today_midnight_timestamp = current_timestamp - (current_timestamp % 86400)
query = db.query(User).filter(
@@ -401,75 +582,159 @@ class UsersTable:
)
return query.count()
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
def update_user_role_by_id(
self, id: str, role: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
db.query(User).filter_by(id=id).update({"role": role})
db.commit()
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.role = role
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
def update_user_status_by_id(
self, id: str, form_data: UserStatus, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
for key, value in form_data.model_dump(exclude_none=True).items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
def update_user_profile_image_url_by_id(
self, id: str, profile_image_url: str
self, id: str, profile_image_url: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
db.query(User).filter_by(id=id).update(
{"profile_image_url": profile_image_url}
)
db.commit()
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.profile_image_url = profile_image_url
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]:
try:
with get_db() as db:
db.query(User).filter_by(id=id).update(
{"last_active_at": int(time.time())}
)
db.commit()
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
except Exception:
return None
def update_user_oauth_sub_by_id(
self, id: str, oauth_sub: str
def update_last_active_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub})
db.commit()
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.last_active_at = int(time.time())
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
def update_user_oauth_by_id(
self, id: str, provider: str, sub: str, db: Optional[Session] = None
) -> Optional[UserModel]:
"""
Update or insert an OAuth provider/sub pair into the user's oauth JSON field.
Example resulting structure:
{
"google": { "sub": "123" },
"github": { "sub": "abc" }
}
"""
try:
with get_db() as db:
db.query(User).filter_by(id=id).update(updated)
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
# Load existing oauth JSON or create empty
oauth = user.oauth or {}
# Update or insert provider entry
oauth[provider] = {"sub": sub}
# Persist updated JSON
db.query(User).filter_by(id=id).update({"oauth": oauth})
db.commit()
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
# return UserModel(**user.dict())
except Exception:
return None
def update_user_scim_by_id(
self,
id: str,
provider: str,
external_id: str,
db: Optional[Session] = None,
) -> Optional[UserModel]:
"""
Update or insert a SCIM provider/external_id pair into the user's scim JSON field.
Example resulting structure:
{
"microsoft": { "external_id": "abc" },
"okta": { "external_id": "def" }
}
"""
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
scim = user.scim or {}
scim[provider] = {"external_id": external_id}
db.query(User).filter_by(id=id).update({"scim": scim})
db.commit()
return UserModel.model_validate(user)
except Exception:
return None
def update_user_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
for key, value in updated.items():
setattr(user, key, value)
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception as e:
print(e)
return None
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
def update_user_settings_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
user_settings = db.query(User).filter_by(id=id).first().settings
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user_settings = user.settings
if user_settings is None:
user_settings = {}
@@ -484,15 +749,15 @@ class UsersTable:
except Exception:
return None
def delete_user_by_id(self, id: str) -> bool:
def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
# Remove User from Groups
Groups.remove_user_from_all_groups(id)
# Delete User Chats
result = Chats.delete_chats_by_user_id(id)
result = Chats.delete_chats_by_user_id(id, db=db)
if result:
with get_db() as db:
with get_db_context(db) as db:
# Delete User
db.query(User).filter_by(id=id).delete()
db.commit()
@@ -503,35 +768,89 @@ class UsersTable:
except Exception:
return False
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
def get_user_api_key_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try:
with get_db() as db:
result = db.query(User).filter_by(id=id).update({"api_key": api_key})
db.commit()
return True if result == 1 else False
except Exception:
return False
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
try:
with get_db() as db:
user = db.query(User).filter_by(id=id).first()
return user.api_key
with get_db_context(db) as db:
api_key = db.query(ApiKey).filter_by(user_id=id).first()
return api_key.key if api_key else None
except Exception:
return None
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db:
def update_user_api_key_by_id(
self, id: str, api_key: str, db: Optional[Session] = None
) -> bool:
try:
with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
now = int(time.time())
new_api_key = ApiKey(
id=f"key_{id}",
user_id=id,
key=api_key,
created_at=now,
updated_at=now,
)
db.add(new_api_key)
db.commit()
return True
except Exception:
return False
def delete_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
return True
except Exception:
return False
def get_valid_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [user.id for user in users]
def get_super_admin_user(self) -> Optional[UserModel]:
with get_db() as db:
def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]:
with get_db_context(db) as db:
user = db.query(User).filter_by(role="admin").first()
if user:
return UserModel.model_validate(user)
else:
return None
def get_active_user_count(self, db: Optional[Session] = None) -> int:
with get_db_context(db) as db:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
count = (
db.query(User).filter(User.last_active_at >= three_minutes_ago).count()
)
return count
@staticmethod
def is_active(user: UserModel) -> bool:
"""Compute active status from an already-loaded UserModel (no DB hit)."""
if user.last_active_at:
three_minutes_ago = int(time.time()) - 180
return user.last_active_at >= three_minutes_ago
return False
def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=user_id).first()
if user and user.last_active_at:
# Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180
return user.last_active_at >= three_minutes_ago
return False
Users = UsersTable()
@@ -6,10 +6,8 @@ from urllib.parse import quote
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalDocumentLoader(BaseLoader):
@@ -4,10 +4,8 @@ from typing import Iterator, List, Union
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalWebLoader(BaseLoader):
+24 -14
View File
@@ -30,11 +30,10 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
known_source_ext = [
"go",
@@ -115,7 +114,7 @@ class TikaLoader:
endpoint += "/"
endpoint += "tika/text"
r = requests.put(endpoint, data=data, headers=headers)
r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
if r.ok:
raw_metadata = r.json()
@@ -144,19 +143,17 @@ class DoclingLoader:
with open(self.file_path, "rb") as f:
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
files = {
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
}
headers["X-Api-Key"] = f"{self.api_key}"
r = requests.post(
f"{self.url}/v1/convert/file",
files=files,
files={
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
},
data={
"image_export_mode": "placeholder",
**self.params,
@@ -322,22 +319,33 @@ class Loader:
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"),
)
else:
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
azure_credential=DefaultAzureCredential(),
api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"),
)
elif self.engine == "mineru" and file_ext in [
"pdf"
]: # MinerU currently only supports PDF
mineru_timeout = self.kwargs.get("MINERU_API_TIMEOUT", 300)
if mineru_timeout:
try:
mineru_timeout = int(mineru_timeout)
except ValueError:
mineru_timeout = 300
loader = MinerULoader(
file_path=file_path,
api_mode=self.kwargs.get("MINERU_API_MODE", "local"),
api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"),
api_key=self.kwargs.get("MINERU_API_KEY", ""),
params=self.kwargs.get("MINERU_PARAMS", {}),
timeout=mineru_timeout,
)
elif (
self.engine == "mistral_ocr"
@@ -353,7 +361,9 @@ class Loader:
else:
if file_ext == "pdf":
loader = PyPDFLoader(
file_path, extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES")
file_path,
extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"),
mode=self.kwargs.get("PDF_LOADER_MODE", "page"),
)
elif file_ext == "csv":
loader = CSVLoader(file_path, autodetect_encoding=True)
@@ -26,11 +26,13 @@ class MinerULoader:
api_url: str = "http://localhost:8000",
api_key: str = "",
params: dict = None,
timeout: Optional[int] = 300,
):
self.file_path = file_path
self.api_mode = api_mode.lower()
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
# Parse params dict with defaults
self.params = params or {}
@@ -101,7 +103,7 @@ class MinerULoader:
f"{self.api_url}/file_parse",
data=form_data,
files=files,
timeout=300, # 5 minute timeout for large documents
timeout=self.timeout,
)
response.raise_for_status()
@@ -300,7 +302,7 @@ class MinerULoader:
response = requests.put(
upload_url,
data=f,
timeout=300, # 5 minute timeout for large files
timeout=self.timeout,
)
response.raise_for_status()
except FileNotFoundError:
@@ -9,11 +9,10 @@ from typing import List, Dict, Any
from contextlib import asynccontextmanager
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MistralLoader:
@@ -4,10 +4,8 @@ from typing import Iterator, List, Literal, Union
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class TavilyLoader(BaseLoader):
@@ -4,10 +4,8 @@ from xml.etree.ElementTree import ParseError
from typing import Any, Dict, Generator, List, Optional, Sequence, Union
from urllib.parse import parse_qs, urlparse
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_NETLOCS = {
@@ -5,12 +5,10 @@ import numpy as np
from colbert.infra import ColBERTConfig
from colbert.modeling.checkpoint import Checkpoint
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.models.base_reranker import BaseReranker
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ColBERT(BaseReranker):
@@ -4,13 +4,11 @@ from typing import Optional, List, Tuple
from urllib.parse import quote
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
from open_webui.retrieval.models.base_reranker import BaseReranker
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalReranker(BaseReranker):
@@ -19,10 +17,12 @@ class ExternalReranker(BaseReranker):
api_key: str,
url: str = "http://localhost:8080/v1/rerank",
model: str = "reranker",
timeout: Optional[int] = None,
):
self.api_key = api_key
self.url = url
self.model = model
self.timeout = timeout
def predict(
self, sentences: List[Tuple[str, str]], user=None
@@ -53,6 +53,8 @@ class ExternalReranker(BaseReranker):
f"{self.url}",
headers=headers,
json=payload,
timeout=self.timeout,
verify=REQUESTS_VERIFY,
)
r.raise_for_status()
+62 -29
View File
@@ -12,7 +12,10 @@ import re
from urllib.parse import quote
from huggingface_hub import snapshot_download
from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriever
from langchain_classic.retrievers import (
ContextualCompressionRetriever,
EnsembleRetriever,
)
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
@@ -26,9 +29,9 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.chats import Chats
from open_webui.models.notes import Notes
from open_webui.models.access_grants import AccessGrants
from open_webui.retrieval.vector.main import GetResult
from open_webui.utils.access_control import has_access
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.misc import get_message_list
@@ -37,9 +40,10 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import (
SRC_LOG_LEVELS,
AIOHTTP_CLIENT_TIMEOUT,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
AIOHTTP_CLIENT_SESSION_SSL,
)
from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
@@ -48,7 +52,6 @@ from open_webui.config import (
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
from typing import Any
@@ -594,9 +597,14 @@ async def agenerate_openai_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f"{url}/embeddings", headers=headers, json=form_data
f"{url}/embeddings",
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
@@ -683,8 +691,15 @@ async def agenerate_azure_openai_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(full_url, headers=headers, json=form_data) as r:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
full_url,
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
if "data" in data:
@@ -759,9 +774,14 @@ async def agenerate_ollama_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(
f"{url}/api/embed", headers=headers, json=form_data
f"{url}/api/embed",
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
data = await r.json()
@@ -790,7 +810,9 @@ def get_embedding_function(
return await asyncio.to_thread(
(
lambda query, prefix=None: embedding_function.encode(
query, **({"prompt": prefix} if prefix else {})
query,
batch_size=int(embedding_batch_size),
**({"prompt": prefix} if prefix else {}),
).tolist()
),
query,
@@ -985,7 +1007,12 @@ async def get_sources_from_items(
if note and (
user.role == "admin"
or note.user_id == user.id
or has_access(user.id, "read", note.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="note",
resource_id=note.id,
permission="read",
)
):
# User has access to the note
query_result = {
@@ -1077,7 +1104,12 @@ async def get_sources_from_items(
if knowledge_base and (
user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
)
):
if (
item.get("context") == "full"
@@ -1086,25 +1118,26 @@ async def get_sources_from_items(
if knowledge_base and (
user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control)
or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission="read",
)
):
file_ids = knowledge_base.data.get("file_ids", [])
files = Knowledges.get_files_by_id(knowledge_base.id)
documents = []
metadatas = []
for file_id in file_ids:
file_object = Files.get_file_by_id(file_id)
if file_object:
documents.append(file_object.data.get("content", ""))
metadatas.append(
{
"file_id": file_id,
"name": file_object.filename,
"source": file_object.filename,
}
)
for file in files:
documents.append(file.data.get("content", ""))
metadatas.append(
{
"file_id": file.id,
"name": file.filename,
"source": file.filename,
}
)
query_result = {
"documents": [documents],
@@ -1285,7 +1318,7 @@ class RerankCompressor(BaseDocumentCompressor):
scores = None
if reranking:
scores = self.reranking_function(query, documents)
scores = await asyncio.to_thread(self.reranking_function, query, documents)
else:
from sentence_transformers import util
@@ -24,10 +24,8 @@ from open_webui.config import (
CHROMA_CLIENT_AUTH_PROVIDER,
CHROMA_CLIENT_AUTH_CREDENTIALS,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ChromaClient(VectorDBBase):
@@ -71,7 +69,11 @@ class ChromaClient(VectorDBBase):
return self.client.delete_collection(name=collection_name)
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
try:
@@ -80,6 +82,7 @@ class ChromaClient(VectorDBBase):
result = collection.query(
query_embeddings=vectors,
n_results=limit,
where=filter,
)
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
@@ -153,7 +153,11 @@ class ElasticsearchClient(VectorDBBase):
# Status: works
def search(
self, collection_name: str, vectors: list[list[float]], limit: int
self,
collection_name: str,
vectors: list[list[float]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
query = {
"size": limit,
@@ -25,10 +25,8 @@ from open_webui.config import (
MILVUS_DISKANN_MAX_DEGREE,
MILVUS_DISKANN_SEARCH_LIST_SIZE,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MilvusClient(VectorDBBase):
@@ -181,7 +179,11 @@ class MilvusClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
collection_name = collection_name.replace("-", "_")
@@ -200,23 +202,24 @@ class MilvusClient(VectorDBBase):
def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Construct the filter string for querying
collection_name = collection_name.replace("-", "_")
if not self.has_collection(collection_name):
log.warning(
f"Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}"
)
return None
filter_string = " && ".join(
[
f'metadata["{key}"] == {json.dumps(value)}'
for key, value in filter.items()
]
)
filter_expressions = []
for key, value in filter.items():
if isinstance(value, str):
filter_expressions.append(f'metadata["{key}"] == "{value}"')
else:
filter_expressions.append(f'metadata["{key}"] == {value}')
filter_string = " && ".join(filter_expressions)
collection = Collection(f"{self.collection_prefix}_{collection_name}")
collection.load()
all_results = []
try:
log.info(
@@ -224,24 +227,25 @@ class MilvusClient(VectorDBBase):
)
iterator = collection.query_iterator(
filter=filter_string,
expr=filter_string,
output_fields=[
"id",
"data",
"metadata",
],
limit=limit, # Pass the limit directly; -1 means no limit.
limit=limit if limit > 0 else -1,
)
all_results = []
while True:
result = iterator.next()
if not result:
batch = iterator.next()
if not batch:
iterator.close()
break
all_results += result
all_results.extend(batch)
log.info(f"Total results from query: {len(all_results)}")
return self._result_to_get_result([all_results])
log.debug(f"Total results from query: {len(all_results)}")
return self._result_to_get_result([all_results] if all_results else [[]])
except Exception as e:
log.exception(
@@ -12,7 +12,6 @@ from open_webui.config import (
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
@@ -29,7 +28,6 @@ from pymilvus import (
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
RESOURCE_ID_FIELD = "resource_id"
@@ -157,10 +155,13 @@ class MilvusClient(VectorDBBase):
for item in items
]
collection.insert(entities)
collection.flush()
def search(
self, collection_name: str, vectors: List[List[float]], limit: int
self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
if not vectors:
return None
@@ -263,15 +264,23 @@ class MilvusClient(VectorDBBase):
else:
expr.append(f"metadata['{key}'] == {value}")
results = collection.query(
iterator = collection.query_iterator(
expr=" and ".join(expr),
output_fields=["id", "text", "metadata"],
limit=limit,
limit=limit if limit else -1,
)
ids = [res["id"] for res in results]
documents = [res["text"] for res in results]
metadatas = [res["metadata"] for res in results]
all_results = []
while True:
batch = iterator.next()
if not batch:
iterator.close()
break
all_results.extend(batch)
ids = [res["id"] for res in all_results]
documents = [res["text"] for res in all_results]
metadatas = [res["metadata"] for res in all_results]
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
@@ -0,0 +1,427 @@
from typing import Optional, List, Dict, Any
import logging
import re
import json
from sqlalchemy import (
func,
literal,
cast,
column,
create_engine,
Column,
Integer,
MetaData,
LargeBinary,
select,
text,
Text,
Table,
values,
)
from sqlalchemy.sql import true
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
from sqlalchemy.dialects.postgresql import JSONB, array
from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2
from sqlalchemy.dialects import registry
class OpenGaussDialect(PGDialect_psycopg2):
name = "opengauss"
def _get_server_version_info(self, connection):
try:
version = connection.exec_driver_sql("SELECT version()").scalar()
if not version:
return (9, 0, 0)
match = re.search(
r"openGauss\s+(\d+)\.(\d+)\.(\d+)(?:-\w+)?", version, re.IGNORECASE
)
if match:
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
return super()._get_server_version_info(connection)
except Exception:
return (9, 0, 0)
# Register dialect
registry.register("opengauss", __name__, "OpenGaussDialect")
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
OPENGAUSS_DB_URL,
OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH,
OPENGAUSS_POOL_SIZE,
OPENGAUSS_POOL_MAX_OVERFLOW,
OPENGAUSS_POOL_TIMEOUT,
OPENGAUSS_POOL_RECYCLE,
)
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class DocumentChunk(Base):
__tablename__ = "document_chunk"
id = Column(Text, primary_key=True)
vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True)
collection_name = Column(Text, nullable=False)
text = Column(Text, nullable=True)
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
class OpenGaussClient(VectorDBBase):
def __init__(self) -> None:
if not OPENGAUSS_DB_URL:
from open_webui.internal.db import ScopedSession
self.session = ScopedSession
else:
engine_kwargs = {"pool_pre_ping": True, "dialect": OpenGaussDialect()}
if isinstance(OPENGAUSS_POOL_SIZE, int) and OPENGAUSS_POOL_SIZE > 0:
engine_kwargs.update(
{
"pool_size": OPENGAUSS_POOL_SIZE,
"max_overflow": OPENGAUSS_POOL_MAX_OVERFLOW,
"pool_timeout": OPENGAUSS_POOL_TIMEOUT,
"pool_recycle": OPENGAUSS_POOL_RECYCLE,
"poolclass": QueuePool,
}
)
else:
engine_kwargs["poolclass"] = NullPool
engine = create_engine(OPENGAUSS_DB_URL, **engine_kwargs)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
self.session = scoped_session(SessionLocal)
try:
connection = self.session.connection()
Base.metadata.create_all(bind=connection)
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_vector "
"ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);"
)
)
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name "
"ON document_chunk (collection_name);"
)
)
self.session.commit()
log.info("OpenGauss vector database initialization completed.")
except Exception as e:
self.session.rollback()
log.exception(f"OpenGauss Initialization failed.: {e}")
raise
def check_vector_length(self) -> None:
metadata = MetaData()
try:
document_chunk_table = Table(
"document_chunk", metadata, autoload_with=self.session.bind
)
except NoSuchTableError:
return
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
if isinstance(vector_type, Vector):
db_vector_length = vector_type.dim
if db_vector_length != VECTOR_LENGTH:
raise Exception(
f"Vector dimension mismatch: configured {VECTOR_LENGTH} vs. {db_vector_length} in the database."
)
else:
raise Exception("The 'vector' column type is not Vector.")
else:
raise Exception(
"The 'vector' column does not exist in the 'document_chunk' table."
)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
current_length = len(vector)
if current_length < VECTOR_LENGTH:
vector += [0.0] * (VECTOR_LENGTH - current_length)
elif current_length > VECTOR_LENGTH:
vector = vector[:VECTOR_LENGTH]
return vector
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
try:
new_items = []
for item in items:
vector = self.adjust_vector_length(item["vector"])
new_chunk = DocumentChunk(
id=item["id"],
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info(
f"Inserting {len(new_items)} items into collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to insert data: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
try:
for item in items:
vector = self.adjust_vector_length(item["vector"])
existing = (
self.session.query(DocumentChunk)
.filter(DocumentChunk.id == item["id"])
.first()
)
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = collection_name
else:
new_chunk = DocumentChunk(
id=item["id"],
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
log.info(
f"Inserting/updating {len(items)} items in collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to insert or update data.: {e}")
raise
def search(
self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not vectors:
return None
vectors = [self.adjust_vector_length(vector) for vector in vectors]
num_queries = len(vectors)
def vector_expr(vector):
return cast(array(vector), Vector(VECTOR_LENGTH))
qid_col = column("qid", Integer)
q_vector_col = column("q_vector", Vector(VECTOR_LENGTH))
query_vectors = (
values(qid_col, q_vector_col)
.data(
[(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)]
)
.alias("query_vectors")
)
result_fields = [
DocumentChunk.id,
DocumentChunk.text,
DocumentChunk.vmetadata,
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label(
"distance"
),
]
subq = (
select(*result_fields)
.where(DocumentChunk.collection_name == collection_name)
.order_by(
DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)
)
)
if limit is not None:
subq = subq.limit(limit)
subq = subq.lateral("result")
stmt = (
select(
query_vectors.c.qid,
subq.c.id,
subq.c.text,
subq.c.vmetadata,
subq.c.distance,
)
.select_from(query_vectors)
.join(subq, true())
.order_by(query_vectors.c.qid, subq.c.distance)
)
result_proxy = self.session.execute(stmt)
results = result_proxy.all()
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
documents = [[] for _ in range(num_queries)]
metadatas = [[] for _ in range(num_queries)]
for row in results:
qid = int(row.qid)
ids[qid].append(row.id)
distances[qid].append((2.0 - row.distance) / 2.0)
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
self.session.rollback()
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
self.session.rollback()
log.exception(f"Vector search failed: {e}")
return None
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
for key, value in filter.items():
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
if limit is not None:
query = query.limit(limit)
results = query.all()
if not results:
return None
ids = [[result.id for result in results]]
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback()
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Conditional query failed: {e}")
return None
def get(
self, collection_name: str, limit: Optional[int] = None
) -> Optional[GetResult]:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
if limit is not None:
query = query.limit(limit)
results = query.all()
if not results:
return None
ids = [[result.id for result in results]]
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback()
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to retrieve data: {e}")
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> None:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
if ids:
query = query.filter(DocumentChunk.id.in_(ids))
if filter:
for key, value in filter.items():
query = query.filter(
DocumentChunk.vmetadata[key].astext == str(value)
)
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'")
except Exception as e:
self.session.rollback()
log.exception(f"Failed to delete data: {e}")
raise
def reset(self) -> None:
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info(f"Reset completed. Deleted {deleted} items")
except Exception as e:
self.session.rollback()
log.exception(f"Reset failed: {e}")
raise
def close(self) -> None:
pass
def has_collection(self, collection_name: str) -> bool:
try:
exists = (
self.session.query(DocumentChunk)
.filter(DocumentChunk.collection_name == collection_name)
.first()
is not None
)
self.session.rollback()
return exists
except Exception as e:
self.session.rollback()
log.exception(f"Failed to check collection existence: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info(f"Collection '{collection_name}' has been deleted")
@@ -113,7 +113,11 @@ class OpenSearchClient(VectorDBBase):
self.client.indices.delete(index=self._get_index_name(collection_name))
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not self.has_collection(collection_name):
@@ -207,7 +211,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def upsert(self, collection_name: str, items: list[VectorItem]):
self._create_index_if_not_exists(
@@ -230,7 +234,7 @@ class OpenSearchClient(VectorDBBase):
for item in batch
]
bulk(self.client, actions)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def delete(
self,
@@ -259,7 +263,7 @@ class OpenSearchClient(VectorDBBase):
self.client.delete_by_query(
index=self._get_index_name(collection_name), body=query_body
)
self.client.indices.refresh(self._get_index_name(collection_name))
self.client.indices.refresh(index=self._get_index_name(collection_name))
def reset(self):
indices = self.client.indices.get(index=f"{self.index_prefix}_*")
@@ -55,10 +55,8 @@ from open_webui.config import (
ORACLE_DB_POOL_MAX,
ORACLE_DB_POOL_INCREMENT,
)
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class Oracle23aiClient(VectorDBBase):
@@ -258,8 +256,7 @@ class Oracle23aiClient(VectorDBBase):
with connection.cursor() as cursor:
try:
log.info("Creating Table document_chunk")
cursor.execute(
"""
cursor.execute("""
BEGIN
EXECUTE IMMEDIATE '
CREATE TABLE IF NOT EXISTS document_chunk (
@@ -276,12 +273,10 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
log.info("Creating Index document_chunk_collection_name_idx")
cursor.execute(
"""
cursor.execute("""
BEGIN
EXECUTE IMMEDIATE '
CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx
@@ -293,12 +288,10 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx")
cursor.execute(
"""
cursor.execute("""
BEGIN
EXECUTE IMMEDIATE '
CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx
@@ -314,8 +307,7 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
connection.commit()
log.info("Database initialization completed successfully.")
@@ -523,7 +515,11 @@ class Oracle23aiClient(VectorDBBase):
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Search for similar vectors in the database.
@@ -51,8 +51,6 @@ from open_webui.config import (
PGVECTOR_USE_HALFVEC,
)
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
USE_HALFVEC = PGVECTOR_USE_HALFVEC
@@ -61,7 +59,6 @@ VECTOR_OPCLASS = "halfvec_cosine_ops" if USE_HALFVEC else "vector_cosine_ops"
Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def pgcrypto_encrypt(val, key):
@@ -92,9 +89,9 @@ class PgvectorClient(VectorDBBase):
# if no pgvector uri, use the existing database connection
if not PGVECTOR_DB_URL:
from open_webui.internal.db import Session
from open_webui.internal.db import ScopedSession
self.session = Session
self.session = ScopedSession
else:
if isinstance(PGVECTOR_POOL_SIZE, int):
if PGVECTOR_POOL_SIZE > 0:
@@ -123,34 +120,26 @@ class PgvectorClient(VectorDBBase):
# Ensure the pgvector extension is available
# Use a conditional check to avoid permission issues on Azure PostgreSQL
if PGVECTOR_CREATE_EXTENSION:
self.session.execute(
text(
"""
self.session.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
CREATE EXTENSION IF NOT EXISTS vector;
END IF;
END $$;
"""
)
)
"""))
if PGVECTOR_PGCRYPTO:
# Ensure the pgcrypto extension is available for encryption
# Use a conditional check to avoid permission issues on Azure PostgreSQL
self.session.execute(
text(
"""
self.session.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN
CREATE EXTENSION IF NOT EXISTS pgcrypto;
END IF;
END $$;
"""
)
)
"""))
if not PGVECTOR_PGCRYPTO_KEY:
raise ValueError(
@@ -218,15 +207,13 @@ class PgvectorClient(VectorDBBase):
def _ensure_vector_index(self, index_method: str, index_options: str) -> None:
index_name = "idx_document_chunk_vector"
existing_index_def = self.session.execute(
text(
"""
text("""
SELECT indexdef
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'document_chunk'
AND indexname = :index_name
"""
),
"""),
{"index_name": index_name},
).scalar()
@@ -312,8 +299,7 @@ class PgvectorClient(VectorDBBase):
# Ensure metadata is converted to its JSON text representation
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
text("""
INSERT INTO document_chunk
(id, vector, collection_name, text, vmetadata)
VALUES (
@@ -322,8 +308,7 @@ class PgvectorClient(VectorDBBase):
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO NOTHING
"""
),
"""),
{
"id": item["id"],
"vector": vector,
@@ -365,8 +350,7 @@ class PgvectorClient(VectorDBBase):
vector = self.adjust_vector_length(item["vector"])
json_metadata = json.dumps(item["metadata"])
self.session.execute(
text(
"""
text("""
INSERT INTO document_chunk
(id, vector, collection_name, text, vmetadata)
VALUES (
@@ -379,8 +363,7 @@ class PgvectorClient(VectorDBBase):
collection_name = EXCLUDED.collection_name,
text = EXCLUDED.text,
vmetadata = EXCLUDED.vmetadata
"""
),
"""),
{
"id": item["id"],
"vector": vector,
@@ -429,7 +412,8 @@ class PgvectorClient(VectorDBBase):
self,
collection_name: str,
vectors: List[List[float]],
limit: Optional[int] = None,
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not vectors:
@@ -477,9 +461,47 @@ class PgvectorClient(VectorDBBase):
)
# Build the lateral subquery for each query vector
where_clauses = [DocumentChunk.collection_name == collection_name]
# Apply metadata filter if provided
if filter:
for key, value in filter.items():
if isinstance(value, dict) and "$in" in value:
# Handle $in operator: {"field": {"$in": [values]}}
in_values = value["$in"]
if PGVECTOR_PGCRYPTO:
where_clauses.append(
pgcrypto_decrypt(
DocumentChunk.vmetadata,
PGVECTOR_PGCRYPTO_KEY,
JSONB,
)[key].astext.in_([str(v) for v in in_values])
)
else:
where_clauses.append(
DocumentChunk.vmetadata[key].astext.in_(
[str(v) for v in in_values]
)
)
else:
# Handle simple equality: {"field": "value"}
if PGVECTOR_PGCRYPTO:
where_clauses.append(
pgcrypto_decrypt(
DocumentChunk.vmetadata,
PGVECTOR_PGCRYPTO_KEY,
JSONB,
)[key].astext
== str(value)
)
else:
where_clauses.append(
DocumentChunk.vmetadata[key].astext == str(value)
)
subq = (
select(*result_fields)
.where(DocumentChunk.collection_name == collection_name)
.where(*where_clauses)
.order_by(
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
)
@@ -31,15 +31,12 @@ from open_webui.config import (
PINECONE_METRIC,
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class PineconeClient(VectorDBBase):
@@ -393,7 +390,11 @@ class PineconeClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
if not vectors or not vectors[0]:
@@ -22,12 +22,10 @@ from open_webui.config import (
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
NO_LIMIT = 999999999
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient(VectorDBBase):
@@ -147,7 +145,11 @@ class QdrantClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int
self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
if limit is None:
@@ -13,7 +13,6 @@ from open_webui.config import (
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
@@ -30,7 +29,6 @@ TENANT_ID_FIELD = "tenant_id"
DEFAULT_DIMENSION = 384
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def _tenant_filter(tenant_id: str) -> models.FieldCondition:
@@ -256,7 +254,11 @@ class QdrantClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: List[List[float | int]], limit: int
self,
collection_name: str,
vectors: List[List[float | int]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Search for the nearest neighbor items based on the vectors with tenant isolation.
@@ -6,13 +6,11 @@ from open_webui.retrieval.vector.main import (
SearchResult,
)
from open_webui.config import S3_VECTOR_BUCKET_NAME, S3_VECTOR_REGION
from open_webui.env import SRC_LOG_LEVELS
from typing import List, Optional, Dict, Any, Union
import logging
import boto3
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class S3VectorClient(VectorDBBase):
@@ -297,7 +295,11 @@ class S3VectorClient(VectorDBBase):
raise
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""
Search for similar vectors in a collection using multiple query vectors.
@@ -12,9 +12,13 @@ from open_webui.retrieval.vector.main import (
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import (
WEAVIATE_HTTP_HOST,
WEAVIATE_GRPC_HOST,
WEAVIATE_HTTP_PORT,
WEAVIATE_GRPC_PORT,
WEAVIATE_API_KEY,
WEAVIATE_HTTP_SECURE,
WEAVIATE_GRPC_SECURE,
WEAVIATE_SKIP_INIT_CHECKS,
)
@@ -52,9 +56,13 @@ class WeaviateClient(VectorDBBase):
try:
# Build connection parameters
connection_params = {
"host": WEAVIATE_HTTP_HOST,
"port": WEAVIATE_HTTP_PORT,
"http_host": WEAVIATE_HTTP_HOST,
"http_port": WEAVIATE_HTTP_PORT,
"http_secure": WEAVIATE_HTTP_SECURE,
"grpc_host": WEAVIATE_GRPC_HOST,
"grpc_port": WEAVIATE_GRPC_PORT,
"grpc_secure": WEAVIATE_GRPC_SECURE,
"skip_init_checks": WEAVIATE_SKIP_INIT_CHECKS,
}
# Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty
@@ -63,7 +71,7 @@ class WeaviateClient(VectorDBBase):
weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY)
)
self.client = weaviate.connect_to_local(**connection_params)
self.client = weaviate.connect_to_custom(**connection_params)
self.client.connect()
except Exception as e:
raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e
@@ -159,7 +167,11 @@ class WeaviateClient(VectorDBBase):
)
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):
@@ -53,6 +53,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
return PgvectorClient()
case VectorType.OPENGAUSS:
from open_webui.retrieval.vector.dbs.opengauss import OpenGaussClient
return OpenGaussClient()
case VectorType.ELASTICSEARCH:
from open_webui.retrieval.vector.dbs.elasticsearch import (
ElasticsearchClient,
+5 -1
View File
@@ -53,7 +53,11 @@ class VectorDBBase(ABC):
@abstractmethod
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
pass
@@ -12,3 +12,4 @@ class VectorType(StrEnum):
ORACLE23AI = "oracle23ai"
S3VECTOR = "s3vector"
WEAVIATE = "weaviate"
OPENGAUSS = "opengauss"
+10 -10
View File
@@ -4,6 +4,7 @@ KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figu
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
# Removes large/redundant fields from metadata dict.
metadata = {
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
}
@@ -13,16 +14,15 @@ def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
def process_metadata(
metadata: dict[str, any],
) -> dict[str, any]:
# Removes large fields and converts non-serializable types (datetime, list, dict) to strings.
result = {}
for key, value in metadata.items():
# Remove large fields
# Skip large fields
if key in KEYS_TO_EXCLUDE:
del metadata[key]
continue
# Convert non-serializable fields to strings
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
return metadata
if isinstance(value, (datetime, list, dict)):
result[key] = str(value)
else:
result[key] = value
return result
@@ -1,10 +1,8 @@
import logging
from typing import Optional
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
"""
Azure AI Search integration for Open WebUI.
-2
View File
@@ -4,11 +4,9 @@ from pprint import pprint
from typing import Optional
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
import argparse
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
"""
Documentation: https://docs.microsoft.com/en-us/bing/search-apis/bing-web-search/overview
"""
+5 -7
View File
@@ -4,20 +4,18 @@ from typing import Optional
import requests
import json
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def _parse_response(response):
result = {}
results = []
if "data" in response:
data = response["data"]
if "webPages" in data:
webPages = data["webPages"]
if "value" in webPages:
result["webpage"] = [
results = [
{
"id": item.get("id", ""),
"name": item.get("name", ""),
@@ -31,7 +29,7 @@ def _parse_response(response):
}
for item in webPages["value"]
]
return result
return results
def search_bocha(
@@ -53,7 +51,7 @@ def search_bocha(
response = requests.post(url, headers=headers, data=payload, timeout=5)
response.raise_for_status()
results = _parse_response(response.json())
print(results)
if filter_list:
results = get_filtered_results(results, filter_list)
@@ -61,5 +59,5 @@ def search_bocha(
SearchResult(
link=result["url"], title=result.get("name"), snippet=result.get("summary")
)
for result in results.get("webpage", [])[:count]
for result in results[:count]
]
+9 -2
View File
@@ -1,12 +1,11 @@
import logging
import time
from typing import Optional
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_brave(
@@ -27,6 +26,14 @@ def search_brave(
params = {"q": query, "count": count}
response = requests.get(url, headers=headers, params=params)
# Handle 429 rate limiting - Brave free tier allows 1 request/second
# If rate limited, wait 1 second and retry once before failing
if response.status_code == 429:
log.info("Brave Search API rate limited (429), retrying after 1 second...")
time.sleep(1)
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()

Some files were not shown because too many files have changed in this diff Show More