Compare commits

...
645 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
417 changed files with 49745 additions and 12191 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]
+361
View File
@@ -5,6 +5,367 @@ 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.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
+14 -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,6 +53,7 @@ ARG USE_SLIM
ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL
ARG USE_AUXILIARY_EMBEDDING_MODEL
ARG UID
ARG GID
@@ -67,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" \
@@ -88,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 ##
@@ -124,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
@@ -133,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/ && \
+212 -9
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)
@@ -1013,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
@@ -1263,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"
@@ -1343,6 +1385,18 @@ 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_SHARING", "False").lower() == "true"
@@ -1468,6 +1522,15 @@ 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": {
@@ -1475,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,
@@ -1491,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,
},
@@ -1526,6 +1592,10 @@ DEFAULT_USER_PERMISSIONS = {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
"memories": USER_PERMISSIONS_FEATURES_MEMORIES,
},
"settings": {
"interface": USER_PERMISSIONS_SETTINGS_INTERFACE,
},
}
@@ -1541,6 +1611,12 @@ ENABLE_FOLDERS = PersistentConfig(
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",
@@ -1553,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",
@@ -2067,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",
@@ -2213,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")
@@ -2340,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)
@@ -2727,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",
@@ -2815,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(
@@ -2827,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",
@@ -3127,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",
@@ -3267,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",
@@ -3291,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
####################################
@@ -3313,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")
)
@@ -3628,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
+110 -9
View File
@@ -117,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):
@@ -192,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
####################################
@@ -339,6 +365,11 @@ 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"
@@ -354,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
####################################
@@ -382,6 +415,18 @@ try:
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
####################################
@@ -409,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
)
@@ -423,17 +478,21 @@ 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,}$",
)
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(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
)
PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "")
BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
@@ -498,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
####################################
@@ -507,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
@@ -524,15 +597,11 @@ 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"))
####################################
@@ -652,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:
@@ -667,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 == "":
@@ -716,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
####################################
@@ -760,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
####################################
@@ -776,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
-2
View File
@@ -35,7 +35,6 @@ 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 GLOBAL_LOG_LEVEL
@@ -51,7 +50,6 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
+15 -3
View File
@@ -14,11 +14,13 @@ from open_webui.env import (
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
@@ -75,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
@@ -146,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():
@@ -158,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
+158 -24
View File
@@ -64,10 +64,12 @@ 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,
)
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,6 +212,7 @@ 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,
@@ -250,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,
@@ -280,8 +288,10 @@ from open_webui.config import (
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)
@@ -295,6 +305,7 @@ 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,
@@ -306,6 +317,7 @@ from open_webui.config import (
YACY_PASSWORD,
SERPER_API_KEY,
SERPLY_API_KEY,
DDGS_BACKEND,
SERPSTACK_API_KEY,
SERPSTACK_HTTPS,
TAVILY_API_KEY,
@@ -344,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,
@@ -358,8 +373,10 @@ from open_webui.config import (
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,
@@ -476,6 +493,11 @@ from open_webui.env import (
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,
)
@@ -488,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,
@@ -500,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 (
@@ -526,7 +553,6 @@ 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()
@@ -550,8 +576,7 @@ class SPAStaticFiles(StaticFiles):
raise ex
print(
rf"""
print(rf"""
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
@@ -563,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()
@@ -578,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...")
@@ -602,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",
@@ -620,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
@@ -774,15 +836,32 @@ 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
@@ -886,11 +965,17 @@ 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
@@ -915,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
@@ -955,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
@@ -974,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
@@ -1088,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
@@ -1141,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
@@ -1283,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-"):
@@ -1324,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
@@ -1334,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)
@@ -1398,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"])
@@ -1407,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
@@ -1645,7 +1754,7 @@ async def chat_completion(
)
# Insert chat files from parent message if any
parent_message = metadata.get("parent_message", {})
parent_message = metadata.get("parent_message") or {}
parent_message_files = parent_message.get("files", [])
if parent_message_files:
try:
@@ -1694,9 +1803,11 @@ async def chat_completion(
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:
@@ -1746,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")
@@ -1758,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)
@@ -1898,10 +2023,12 @@ async def get_app_config(request: Request):
"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,
@@ -1912,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,
@@ -2280,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")
@@ -2340,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}
@@ -12,7 +12,6 @@ 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"
@@ -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"
@@ -12,7 +12,6 @@ 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"
@@ -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
@@ -12,7 +12,6 @@ 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"
@@ -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")
@@ -12,7 +12,6 @@ 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"
@@ -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")
@@ -173,12 +173,10 @@ def upgrade() -> None:
for uid, api_key in users_with_keys:
if api_key:
conn.execute(
sa.text(
"""
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,
@@ -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
@@ -11,7 +11,6 @@ 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"
@@ -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()
+48 -23
View File
@@ -2,9 +2,11 @@ import logging
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.users import UserModel, UserProfileImageResponse, Users
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__)
@@ -73,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"
@@ -87,8 +96,9 @@ class AuthsTable:
profile_image_url: str = "/user.png",
role: str = "pending",
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())
@@ -100,7 +110,7 @@ class AuthsTable:
db.add(result)
user = Users.insert_new_user(
id, name, email, profile_image_url, role, oauth=oauth
id, name, email, profile_image_url, role, oauth=oauth, db=db
)
db.commit()
@@ -112,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):
@@ -133,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})
)
@@ -167,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()
+254 -109
View File
@@ -1,12 +1,18 @@
import json
import secrets
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.access_grants import (
AccessGrantModel,
AccessGrants,
)
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.dialects.postgresql import JSONB
@@ -45,7 +51,6 @@ class Channel(Base):
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
@@ -74,7 +79,7 @@ class ChannelModel(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 (time_ns)
@@ -235,7 +240,7 @@ class ChannelForm(BaseModel):
is_private: Optional[bool] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
group_ids: Optional[list[str]] = None
user_ids: Optional[list[str]] = None
@@ -244,7 +249,26 @@ class CreateChannelForm(ChannelForm):
type: Optional[str] = None
class ChannelWebhookForm(BaseModel):
name: str
profile_image_url: Optional[str] = None
class ChannelTable:
def _get_access_grants(
self, channel_id: str, db: Optional[Session] = None
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
def _to_channel_model(
self, channel: Channel, db: Optional[Session] = None
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(
exclude={"access_grants"}
)
access_grants = self._get_access_grants(channel_data["id"], db=db)
channel_data["access_grants"] = access_grants
return ChannelModel.model_validate(channel_data)
def _collect_unique_user_ids(
self,
@@ -304,21 +328,22 @@ class ChannelTable:
return memberships
def insert_new_channel(
self, form_data: CreateChannelForm, user_id: str
self, form_data: CreateChannelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"type": form_data.type if form_data.type else None,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
}
)
new_channel = Channel(**channel.model_dump())
new_channel = Channel(**channel.model_dump(exclude={"access_grants"}))
if form_data.type in ["group", "dm"]:
users = self._collect_unique_user_ids(
@@ -335,59 +360,32 @@ class ChannelTable:
db.add_all(memberships)
db.add(new_channel)
db.commit()
return channel
AccessGrants.set_access_grants(
"channel", new_channel.id, form_data.access_grants, db=db
)
return self._to_channel_model(new_channel, db=db)
def get_channels(self) -> list[ChannelModel]:
with get_db() as db:
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db_context(db) as db:
channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels]
return [self._to_channel_model(channel, db=db) for channel in channels]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Channel,
filter=filter,
resource_type="channel",
permission=permission,
)
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Channel.access_control.is_(None),
cast(Channel.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Channel.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Channel.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Channel.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]:
with get_db() as db:
def get_channels_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db_context(db) as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
membership_channels = (
@@ -419,10 +417,12 @@ class ChannelTable:
standard_channels = query.all()
all_channels = membership_channels + standard_channels
return [ChannelModel.model_validate(c) for c in all_channels]
return [self._to_channel_model(c, db=db) for c in all_channels]
def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]:
with get_db() as db:
def get_dm_channel_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db_context(db) as db:
# Ensure uniqueness in case a list with duplicates is passed
unique_user_ids = list(set(user_ids))
@@ -452,7 +452,7 @@ class ChannelTable:
.first()
)
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
def add_members_to_channel(
self,
@@ -460,8 +460,9 @@ class ChannelTable:
invited_by: str,
user_ids: Optional[list[str]] = None,
group_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> list[ChannelMemberModel]:
with get_db() as db:
with get_db_context(db) as db:
# 1. Collect all user_ids including groups + inviter
requested_users = self._collect_unique_user_ids(
invited_by, user_ids, group_ids
@@ -494,8 +495,9 @@ class ChannelTable:
self,
channel_id: str,
user_ids: list[str],
db: Optional[Session] = None,
) -> int:
with get_db() as db:
with get_db_context(db) as db:
result = (
db.query(ChannelMember)
.filter(
@@ -507,8 +509,10 @@ class ChannelTable:
db.commit()
return result # number of rows deleted
def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def is_user_channel_manager(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
# Check if the user is the creator of the channel
# or has a 'manager' role in ChannelMember
channel = db.query(Channel).filter(Channel.id == channel_id).first()
@@ -527,9 +531,9 @@ class ChannelTable:
return membership is not None
def join_channel(
self, channel_id: str, user_id: str
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelMemberModel]:
with get_db() as db:
with get_db_context(db) as db:
# Check if the membership already exists
existing_membership = (
db.query(ChannelMember)
@@ -565,8 +569,10 @@ class ChannelTable:
db.commit()
return channel_member
def leave_channel(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def leave_channel(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@@ -587,9 +593,9 @@ class ChannelTable:
return True
def get_member_by_channel_and_user_id(
self, channel_id: str, user_id: str
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelMemberModel]:
with get_db() as db:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@@ -600,8 +606,10 @@ class ChannelTable:
)
return ChannelMemberModel.model_validate(membership) if membership else None
def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]:
with get_db() as db:
def get_members_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelMemberModel]:
with get_db_context(db) as db:
memberships = (
db.query(ChannelMember)
.filter(ChannelMember.channel_id == channel_id)
@@ -612,8 +620,14 @@ class ChannelTable:
for membership in memberships
]
def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool:
with get_db() as db:
def pin_channel(
self,
channel_id: str,
user_id: str,
is_pinned: bool,
db: Optional[Session] = None,
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@@ -631,8 +645,10 @@ class ChannelTable:
db.commit()
return True
def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def update_member_last_read_at(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@@ -651,9 +667,13 @@ class ChannelTable:
return True
def update_member_active_status(
self, channel_id: str, user_id: str, is_active: bool
self,
channel_id: str,
user_id: str,
is_active: bool,
db: Optional[Session] = None,
) -> bool:
with get_db() as db:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@@ -671,8 +691,10 @@ class ChannelTable:
db.commit()
return True
def is_user_channel_member(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
def is_user_channel_member(
self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = (
db.query(ChannelMember)
.filter(
@@ -683,24 +705,31 @@ class ChannelTable:
)
return membership is not None
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
with get_db() as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
def get_channel_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]:
try:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return self._to_channel_model(channel, db=db) if channel else None
except Exception:
return None
def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]:
with get_db() as db:
def get_channels_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db_context(db) as db:
channel_files = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [ChannelModel.model_validate(channel) for channel in channels]
return [self._to_channel_model(channel, db=db) for channel in channels]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str
self, file_id: str, user_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
# 1. Determine which channels have this file
channel_file_rows = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
@@ -724,7 +753,9 @@ class ChannelTable:
return []
# Preload user's group membership
user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)]
user_group_ids = [
g.id for g in Groups.get_groups_by_member_id(user_id, db=db)
]
allowed_channels = []
@@ -741,7 +772,7 @@ class ChannelTable:
.first()
)
if membership:
allowed_channels.append(ChannelModel.model_validate(channel))
allowed_channels.append(self._to_channel_model(channel, db=db))
continue
# --- Case B: standard channel => rely on ACL permissions ---
@@ -756,14 +787,14 @@ class ChannelTable:
allowed = query.first()
if allowed:
allowed_channels.append(ChannelModel.model_validate(allowed))
allowed_channels.append(self._to_channel_model(allowed, db=db))
return allowed_channels
def get_channel_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
# Fetch the channel
channel: Channel = (
db.query(Channel)
@@ -790,7 +821,7 @@ class ChannelTable:
.first()
)
if membership:
return ChannelModel.model_validate(channel)
return self._to_channel_model(channel, db=db)
else:
return None
@@ -799,7 +830,7 @@ class ChannelTable:
# Determine user groups
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
# Apply ACL rules
@@ -812,15 +843,15 @@ class ChannelTable:
channel_allowed = query.first()
return (
ChannelModel.model_validate(channel_allowed)
self._to_channel_model(channel_allowed, db=db)
if channel_allowed
else None
)
def update_channel_by_id(
self, id: str, form_data: ChannelForm
self, id: str, form_data: ChannelForm, db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db() as db:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
if not channel:
return None
@@ -832,16 +863,19 @@ class ChannelTable:
channel.data = form_data.data
channel.meta = form_data.meta
channel.access_control = form_data.access_control
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"channel", id, form_data.access_grants, db=db
)
channel.updated_at = int(time.time_ns())
db.commit()
return ChannelModel.model_validate(channel) if channel else None
return self._to_channel_model(channel, db=db) if channel else None
def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str
self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelFileModel]:
with get_db() as db:
with get_db_context(db) as db:
channel_file = ChannelFileModel(
**{
"id": str(uuid.uuid4()),
@@ -866,10 +900,14 @@ class ChannelTable:
return None
def set_file_message_id_in_channel_by_id(
self, channel_id: str, file_id: str, message_id: str
self,
channel_id: str,
file_id: str,
message_id: str,
db: Optional[Session] = None,
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
channel_file = (
db.query(ChannelFile)
.filter_by(channel_id=channel_id, file_id=file_id)
@@ -886,9 +924,11 @@ class ChannelTable:
except Exception:
return False
def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool:
def remove_file_from_channel_by_id(
self, channel_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(ChannelFile).filter_by(
channel_id=channel_id, file_id=file_id
).delete()
@@ -897,11 +937,116 @@ class ChannelTable:
except Exception:
return False
def delete_channel_by_id(self, id: str):
with get_db() as db:
def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
AccessGrants.revoke_all_access("channel", id, db=db)
db.query(Channel).filter(Channel.id == id).delete()
db.commit()
return True
####################
# Webhook Methods
####################
def insert_webhook(
self,
channel_id: str,
user_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = ChannelWebhookModel(
id=str(uuid.uuid4()),
channel_id=channel_id,
user_id=user_id,
name=form_data.name,
profile_image_url=form_data.profile_image_url,
token=secrets.token_urlsafe(32),
last_used_at=None,
created_at=int(time.time_ns()),
updated_at=int(time.time_ns()),
)
db.add(ChannelWebhook(**webhook.model_dump()))
db.commit()
return webhook
def get_webhooks_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelWebhookModel]:
with get_db_context(db) as db:
webhooks = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.channel_id == channel_id)
.all()
)
return [ChannelWebhookModel.model_validate(w) for w in webhooks]
def get_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def get_webhook_by_id_and_token(
self, webhook_id: str, token: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(
ChannelWebhook.id == webhook_id,
ChannelWebhook.token == token,
)
.first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def update_webhook_by_id(
self,
webhook_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
if not webhook:
return None
webhook.name = form_data.name
webhook.profile_image_url = form_data.profile_image_url
webhook.updated_at = int(time.time_ns())
db.commit()
return ChannelWebhookModel.model_validate(webhook)
def update_webhook_last_used_at(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
if not webhook:
return False
webhook.last_used_at = int(time.time_ns())
db.commit()
return True
def delete_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
result = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.delete()
)
db.commit()
return result > 0
Channels = ChannelTable()
+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
+185 -39
View File
@@ -3,7 +3,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, JSONField, get_db, get_db_context
from open_webui.models.users import User
from pydantic import BaseModel, ConfigDict
@@ -67,6 +68,13 @@ class FeedbackIdResponse(BaseModel):
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
@@ -119,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(
**{
@@ -148,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
@@ -159,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
@@ -170,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:
@@ -234,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)
@@ -243,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)
@@ -253,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)
@@ -264,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
@@ -284,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
@@ -303,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
@@ -312,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
@@ -321,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()
+147 -43
View File
@@ -2,8 +2,9 @@ import logging
import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
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__)
@@ -25,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)
@@ -44,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
@@ -62,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
@@ -73,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")
@@ -93,7 +109,6 @@ class FileForm(BaseModel):
path: str
data: dict = {}
meta: dict = {}
access_control: Optional[dict] = None
class FileUpdateForm(BaseModel):
@@ -108,8 +123,10 @@ class FileListResponse(BaseModel):
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(),
@@ -132,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:
@@ -151,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(
@@ -165,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:
@@ -178,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)
@@ -188,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,
@@ -206,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()
@@ -236,8 +332,10 @@ 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
@@ -248,8 +346,10 @@ class FilesTable:
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}
@@ -260,8 +360,10 @@ class FilesTable:
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}
@@ -271,8 +373,10 @@ class FilesTable:
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()
@@ -281,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 -28
View File
@@ -7,9 +7,9 @@ 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.internal.db import Base, JSONField, get_db, get_db_context
log = logging.getLogger(__name__)
@@ -83,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(
**{
@@ -111,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:
@@ -125,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)
@@ -148,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)
@@ -177,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)
@@ -192,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:
@@ -211,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:
@@ -257,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:
@@ -276,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
@@ -289,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:
@@ -314,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.
@@ -324,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:
@@ -332,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
@@ -345,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 -38
View File
@@ -2,7 +2,8 @@ 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 pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@@ -103,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(
**{
@@ -116,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()
@@ -130,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}
@@ -177,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()
@@ -205,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 [
@@ -228,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)
@@ -244,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)
@@ -253,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)
@@ -262,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 {}
@@ -272,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)
@@ -301,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:
@@ -309,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
@@ -327,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
@@ -342,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:
@@ -351,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(
{
@@ -361,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(
{
@@ -379,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()
+197 -72
View File
@@ -4,7 +4,8 @@ import time
from typing import Optional
import uuid
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.files import FileMetadataResponse
@@ -21,9 +22,9 @@ from sqlalchemy import (
ForeignKey,
cast,
or_,
select,
)
log = logging.getLogger(__name__)
####################
@@ -98,6 +99,16 @@ class GroupResponse(GroupModel):
member_count: Optional[int] = None
class GroupInfoResponse(BaseModel):
id: str
user_id: str
name: str
description: str
member_count: Optional[int] = None
created_at: int
updated_at: int
class GroupForm(BaseModel):
name: str
description: str
@@ -120,9 +131,9 @@ class GroupListResponse(BaseModel):
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),
@@ -146,89 +157,145 @@ class GroupTable:
except Exception:
return None
def get_all_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) -> list[GroupResponse]:
with get_db() as db:
query = db.query(Group)
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']}%"))
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
# When share filter is present, member check is handled in the share logic
if "share" in filter:
share_value = filter["share"]
json_share = Group.data["config"]["share"].as_boolean()
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:
query = query.filter(
or_(
Group.data.is_(None),
json_share.is_(None),
json_share == True,
)
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 == False)
and_(Group.data.isnot(None), json_share_lower == "false")
)
groups = query.order_by(Group.updated_at.desc()).all()
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 [
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": self.get_group_member_count_by_id(group.id),
"member_count": count or 0,
}
)
for group in groups
for group, count in results
]
def search_groups(
self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30
self,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> GroupListResponse:
with get_db() as db:
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.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
)
if "share" in filter:
# 'share' is stored in data JSON, support both sqlite and postgres
share_value = filter["share"]
print("Filtering by share:", share_value)
query = query.filter(
Group.data.op("->>")("share") == str(share_value)
)
total = query.count()
query = query.order_by(Group.updated_at.desc())
groups = query.offset(skip).limit(limit).all()
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=self.get_group_member_count_by_id(group.id),
{
**GroupModel.model_validate(group).model_dump(),
"member_count": count or 0,
}
)
for group in groups
for group, count in results
],
"total": total,
}
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]:
with get_db() as db:
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)
@@ -238,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))
@@ -274,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()
@@ -295,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)
@@ -304,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),
@@ -316,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()
@@ -340,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 = (
@@ -369,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_all_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(
@@ -400,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())
@@ -461,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
@@ -499,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
@@ -510,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())
+189 -106
View File
@@ -4,7 +4,8 @@ import time
from typing import Optional
import uuid
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.files import (
File,
@@ -14,9 +15,10 @@ from open_webui.models.files import (
)
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 (
BigInteger,
Column,
@@ -28,10 +30,6 @@ from sqlalchemy import (
or_,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.db.access_control import has_permission
log = logging.getLogger(__name__)
####################
@@ -49,22 +47,6 @@ class Knowledge(Base):
description = Column(Text)
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)
@@ -81,7 +63,7 @@ class KnowledgeModel(BaseModel):
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
@@ -138,7 +120,7 @@ class KnowledgeUserResponse(KnowledgeUserModel):
class KnowledgeForm(BaseModel):
name: str
description: str
access_control: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class FileUserResponse(FileModelResponse):
@@ -156,42 +138,62 @@ class KnowledgeFileListResponse(BaseModel):
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, skip: int = 0, limit: int = 30
self, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> list[KnowledgeUserModel]:
with get_db() as db:
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 = []
@@ -200,7 +202,7 @@ 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,
}
)
@@ -208,10 +210,15 @@ class KnowledgeTable:
return knowledge_bases
def search_knowledge_bases(
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,
) -> KnowledgeListResponse:
try:
with get_db() as db:
with get_db_context(db) as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
@@ -223,6 +230,9 @@ class KnowledgeTable:
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}%"),
)
)
@@ -232,9 +242,16 @@ class KnowledgeTable:
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = has_permission(db, Knowledge, query, filter)
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())
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
total = query.count()
if skip:
@@ -249,8 +266,8 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(
knowledge_base
**self._to_knowledge_model(
knowledge_base, db=db
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
@@ -267,17 +284,17 @@ class KnowledgeTable:
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30
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() as db:
with get_db_context(db) as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User)
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)
@@ -285,7 +302,14 @@ class KnowledgeTable:
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = has_permission(db, Knowledge, query, filter)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
)
# Apply filename search
if filter:
@@ -294,7 +318,7 @@ class KnowledgeTable:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
query = query.order_by(File.updated_at.desc())
query = query.order_by(File.updated_at.desc(), File.id.asc())
# Count before pagination
total = query.count()
@@ -307,7 +331,7 @@ class KnowledgeTable:
rows = query.all()
items = []
for file, user in rows:
for file, user, knowledge in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
@@ -318,6 +342,9 @@ class KnowledgeTable:
if user
else None
),
collection=self._to_knowledge_model(
knowledge, db=db
).model_dump(),
)
)
@@ -327,55 +354,86 @@ class KnowledgeTable:
print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id)
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 get_knowledge_by_id_and_user_id(
self, id: str, user_id: str
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id)
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)}
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
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) -> list[KnowledgeModel]:
def get_knowledges_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[KnowledgeModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
knowledges = (
db.query(Knowledge)
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
@@ -383,7 +441,8 @@ class KnowledgeTable:
.all()
)
return [
KnowledgeModel.model_validate(knowledge) for knowledge in knowledges
self._to_knowledge_model(knowledge, db=db)
for knowledge in knowledges
]
except Exception:
return []
@@ -395,9 +454,10 @@ class KnowledgeTable:
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeFileListResponse:
try:
with get_db() as db:
with get_db_context(db) as db:
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
@@ -405,6 +465,9 @@ class KnowledgeTable:
.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:
@@ -418,27 +481,23 @@ class KnowledgeTable:
order_by = filter.get("order_by")
direction = filter.get("direction")
is_asc = direction == "asc"
if order_by == "name":
if direction == "asc":
query = query.order_by(File.filename.asc())
else:
query = query.order_by(File.filename.desc())
primary_sort = (
File.filename.asc() if is_asc else File.filename.desc()
)
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(File.created_at.asc())
else:
query = query.order_by(File.created_at.desc())
primary_sort = (
File.created_at.asc() if is_asc else File.created_at.desc()
)
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(File.updated_at.asc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
primary_sort = (
File.updated_at.asc() if is_asc else File.updated_at.desc()
)
else:
query = query.order_by(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()
@@ -470,9 +529,11 @@ class KnowledgeTable:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
def get_files_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
files = (
db.query(File)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
@@ -483,18 +544,24 @@ class KnowledgeTable:
except Exception:
return []
def get_file_metadatas_by_id(self, knowledge_id: str) -> list[FileMetadataResponse]:
def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileMetadataResponse]:
try:
with get_db() as db:
files = self.get_files_by_id(knowledge_id)
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
self,
knowledge_id: str,
file_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[KnowledgeFileModel]:
with get_db() as db:
with get_db_context(db) as db:
knowledge_file = KnowledgeFileModel(
**{
"id": str(uuid.uuid4()),
@@ -518,9 +585,11 @@ class KnowledgeTable:
except Exception:
return None
def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> bool:
def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(KnowledgeFile).filter_by(
knowledge_id=knowledge_id, file_id=file_id
).delete()
@@ -529,9 +598,11 @@ class KnowledgeTable:
except Exception:
return False
def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
def reset_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:
# Delete all knowledge_file entries for this knowledge_id
db.query(KnowledgeFile).filter_by(knowledge_id=id).delete()
db.commit()
@@ -544,35 +615,43 @@ 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 update_knowledge_by_id(
self, id: str, form_data: KnowledgeForm, overwrite: bool = False
self,
id: str,
form_data: KnowledgeForm,
overwrite: bool = False,
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(
{
**form_data.model_dump(),
**form_data.model_dump(exclude={"access_grants"}),
"updated_at": int(time.time()),
}
)
db.commit()
return self.get_knowledge_by_id(id=id)
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,
@@ -580,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:
+201 -43
View File
@@ -3,7 +3,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, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember
@@ -137,9 +138,13 @@ 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:
with get_db_context(db) as db:
channel_member = Channels.join_channel(channel_id, user_id)
id = str(uuid.uuid4())
@@ -169,26 +174,57 @@ class MessageTable:
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
),
@@ -200,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)
@@ -212,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
@@ -230,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)
@@ -253,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
@@ -272,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:
@@ -296,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
@@ -314,8 +426,10 @@ class MessageTable:
)
return messages
def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageModel]:
with get_db() as db:
def get_last_message_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = (
db.query(Message)
.filter_by(channel_id=channel_id)
@@ -325,9 +439,13 @@ class MessageTable:
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
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id, is_pinned=True)
@@ -339,9 +457,9 @@ class MessageTable:
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
self, id: str, form_data: MessageForm
self, id: str, form_data: MessageForm, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db() as db:
with get_db_context(db) as db:
message = db.get(Message, id)
message.content = form_data.content
message.data = {
@@ -358,9 +476,13 @@ class MessageTable:
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
self,
id: str,
is_pinned: bool,
pinned_by: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[MessageModel]:
with get_db() as db:
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
@@ -370,9 +492,13 @@ class MessageTable:
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
self,
channel_id: str,
user_id: str,
last_read_at: Optional[int] = None,
db: Optional[Session] = None,
) -> int:
with get_db() as db:
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
@@ -383,9 +509,9 @@ class MessageTable:
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)
@@ -409,8 +535,10 @@ 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:
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)
@@ -440,29 +568,29 @@ class MessageTable:
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
@@ -471,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()
+139 -128
View File
@@ -2,23 +2,21 @@ 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.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.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
from open_webui.utils.access_control import has_access
from sqlalchemy import BigInteger, Column, Text, Boolean
log = logging.getLogger(__name__)
@@ -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,78 +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"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Model.access_control.is_(None),
cast(Model.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Model.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Model.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Model.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
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)
@@ -277,6 +265,9 @@ 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}%"),
)
)
@@ -291,7 +282,7 @@ class ModelsTable:
db,
query,
filter,
permission="write",
permission="read",
)
tag = filter.get("tag")
@@ -338,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
@@ -349,58 +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 get_models_by_ids(self, ids: list[str]) -> list[ModelModel]:
def get_models_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[ModelModel]:
try:
with get_db() as db:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [ModelModel.model_validate(model) for model in models]
return [self._to_model_model(model, db=db) for model in models]
except Exception:
return []
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]:
with get_db() as db:
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()
@@ -408,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()
@@ -418,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}
@@ -433,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()),
}
@@ -441,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}")
+86 -164
View File
@@ -4,19 +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 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.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
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
@@ -33,8 +30,6 @@ class Note(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)
@@ -49,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
@@ -64,14 +59,14 @@ 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):
@@ -93,171 +88,89 @@ class NoteListResponse(BaseModel):
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"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
Note.access_control["read"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
Note.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(Note.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
Note.access_control["write"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
Note.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(Note.access_control.isnot(None))
write_exclusions.append(cast(Note.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
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
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> NoteListResponse:
with get_db() as db:
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_(
Note.title.ilike(f"%{query_key}%"),
cast(Note.data["content"]["md"], Text).ilike(
f"%{query_key}%"
),
func.replace(
func.replace(Note.title, "-", ""), " ", ""
).ilike(f"%{normalized_query}%"),
func.replace(
func.replace(
cast(Note.data["content"]["md"], Text), "-", ""
),
" ",
"",
).ilike(f"%{normalized_query}%"),
)
)
@@ -318,7 +231,7 @@ class NoteTable:
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
**self._to_note_model(note, db=db).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -333,12 +246,13 @@ class NoteTable:
self,
user_id: str,
permission: str = "read",
skip: Optional[int] = None,
limit: Optional[int] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[NoteModel]:
with get_db() as db:
with get_db_context(db) as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
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())
@@ -352,17 +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_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
@@ -376,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()
+40 -21
View File
@@ -8,7 +8,8 @@ import json
from cryptography.fernet import Fernet
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.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
@@ -101,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(
@@ -109,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())
@@ -141,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)
@@ -156,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)
@@ -176,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)
@@ -195,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
@@ -213,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(
@@ -239,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
@@ -250,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
@@ -261,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 -11
View File
@@ -3,7 +3,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, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict
@@ -50,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:
@@ -68,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 (
@@ -96,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}")
@@ -108,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()
+89 -67
View File
@@ -2,15 +2,14 @@ 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 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__)
@@ -30,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)
@@ -63,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
@@ -85,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
@@ -96,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):
@@ -109,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 = []
@@ -159,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,
}
)
@@ -167,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
@@ -220,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
@@ -235,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:
@@ -244,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()
+222 -84
View File
@@ -1,7 +1,8 @@
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
@@ -11,9 +12,10 @@ 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 pydantic import BaseModel, ConfigDict, field_validator, model_validator
from sqlalchemy import (
BigInteger,
JSON,
@@ -26,7 +28,7 @@ from sqlalchemy import (
select,
cast,
)
from sqlalchemy import or_, case
from sqlalchemy import or_, case, func
from sqlalchemy.dialects.postgresql import JSONB
import datetime
@@ -69,6 +71,7 @@ class User(Base):
settings = Column(JSON, nullable=True)
oauth = Column(JSON, nullable=True)
scim = Column(JSON, nullable=True)
last_active_at = Column(BigInteger)
updated_at = Column(BigInteger)
@@ -84,7 +87,7 @@ class UserModel(BaseModel):
name: str
profile_image_url: str
profile_image_url: Optional[str] = None
profile_banner_image_url: Optional[str] = None
bio: Optional[str] = None
@@ -101,6 +104,7 @@ class UserModel(BaseModel):
settings: Optional[UserSettings] = None
oauth: Optional[dict] = None
scim: Optional[dict] = None
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -108,6 +112,12 @@ 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
@@ -153,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] = []
@@ -183,6 +198,9 @@ class UserInfoResponse(UserStatus):
name: str
email: str
role: str
bio: Optional[str] = None
groups: Optional[list] = []
is_active: bool = False
class UserIdNameResponse(BaseModel):
@@ -233,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(
@@ -242,9 +265,11 @@ class UsersTable:
email: str,
profile_image_url: str = "/user.png",
role: str = "pending",
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,
@@ -255,6 +280,7 @@ class UsersTable:
"last_active_at": int(time.time()),
"created_at": int(time.time()),
"updated_at": int(time.time()),
"username": username,
"oauth": oauth,
}
)
@@ -267,17 +293,21 @@ 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:
with get_db_context(db) as db:
user = (
db.query(User)
.join(ApiKey, User.id == ApiKey.user_id)
@@ -288,17 +318,25 @@ class UsersTable:
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, provider: str, 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: # type: Session
with get_db_context(db) as db: # type: Session
dialect_name = db.bind.dialect.name
query = db.query(User)
@@ -315,15 +353,39 @@ class UsersTable:
# 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
def get_users(
self,
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")
@@ -452,40 +514,52 @@ class UsersTable:
"total": total,
}
def get_users_by_group_id(self, group_id: str) -> list[UserModel]:
with get_db() as db:
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_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
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) -> Optional[int]:
with get_db() as db:
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:
@@ -499,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(
@@ -508,62 +582,70 @@ 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
self, id: str, form_data: UserStatus, db: Optional[Session] = None
) -> Optional[UserModel]:
try:
with get_db() as db:
db.query(User).filter_by(id=id).update(
{**form_data.model_dump(exclude_none=True)}
)
db.commit()
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_last_active_by_id(self, id: str) -> Optional[UserModel]:
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(
{"last_active_at": int(time.time())}
)
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_oauth_by_id(
self, id: str, provider: str, sub: str
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.
@@ -574,7 +656,7 @@ class UsersTable:
}
"""
try:
with get_db() as db:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
@@ -594,23 +676,65 @@ class UsersTable:
except Exception:
return None
def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
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() 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
scim = user.scim or {}
scim[provider] = {"external_id": external_id}
db.query(User).filter_by(id=id).update({"scim": scim})
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_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 = {}
@@ -625,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()
@@ -644,17 +768,21 @@ class UsersTable:
except Exception:
return False
def get_user_api_key_by_id(self, id: str) -> Optional[str]:
def get_user_api_key_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try:
with get_db() as db:
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 update_user_api_key_by_id(self, id: str, api_key: str) -> bool:
def update_user_api_key_by_id(
self, id: str, api_key: str, db: Optional[Session] = None
) -> bool:
try:
with get_db() as db:
with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete()
db.commit()
@@ -674,30 +802,32 @@ class UsersTable:
except Exception:
return False
def delete_user_api_key_by_id(self, id: str) -> bool:
def delete_user_api_key_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(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]) -> list[str]:
with get_db() as db:
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) -> int:
with get_db() as db:
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 = (
@@ -705,8 +835,16 @@ class UsersTable:
)
return count
def is_user_active(self, user_id: str) -> bool:
with get_db() as db:
@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
+6 -4
View File
@@ -30,7 +30,7 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.env import 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__)
@@ -114,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()
@@ -143,7 +143,7 @@ class DoclingLoader:
with open(self.file_path, "rb") as f:
headers = {}
if self.api_key:
headers["X-Api-Key"] = f"Bearer {self.api_key}"
headers["X-Api-Key"] = f"{self.api_key}"
r = requests.post(
f"{self.url}/v1/convert/file",
@@ -361,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)
@@ -4,11 +4,10 @@ from typing import Optional, List, Tuple
from urllib.parse import quote
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
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__)
@@ -55,6 +54,7 @@ class ExternalReranker(BaseReranker):
headers=headers,
json=payload,
timeout=self.timeout,
verify=REQUESTS_VERIFY,
)
r.raise_for_status()
+47 -11
View File
@@ -29,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
@@ -40,8 +40,10 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
AIOHTTP_CLIENT_SESSION_SSL,
)
from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX,
@@ -595,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()
@@ -684,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:
@@ -760,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()
@@ -791,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,
@@ -986,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 = {
@@ -1078,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"
@@ -1087,7 +1118,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",
)
):
files = Knowledges.get_files_by_id(knowledge_base.id)
@@ -69,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:
@@ -78,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,
@@ -179,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("-", "_")
@@ -157,7 +157,11 @@ class MilvusClient(VectorDBBase):
collection.insert(entities)
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
@@ -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}_*")
@@ -256,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 (
@@ -274,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
@@ -291,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
@@ -312,8 +307,7 @@ class Oracle23aiClient(VectorDBBase):
RAISE;
END IF;
END;
"""
)
""")
connection.commit()
log.info("Database initialization completed successfully.")
@@ -521,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,7 +51,6 @@ from open_webui.config import (
PGVECTOR_USE_HALFVEC,
)
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
USE_HALFVEC = PGVECTOR_USE_HALFVEC
@@ -90,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:
@@ -121,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(
@@ -216,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()
@@ -310,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 (
@@ -320,8 +308,7 @@ class PgvectorClient(VectorDBBase):
pgp_sym_encrypt(:metadata_text, :key)
)
ON CONFLICT (id) DO NOTHING
"""
),
"""),
{
"id": item["id"],
"vector": vector,
@@ -363,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 (
@@ -377,8 +363,7 @@ class PgvectorClient(VectorDBBase):
collection_name = EXCLUDED.collection_name,
text = EXCLUDED.text,
vmetadata = EXCLUDED.vmetadata
"""
),
"""),
{
"id": item["id"],
"vector": vector,
@@ -427,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:
@@ -475,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))
)
@@ -33,7 +33,6 @@ from open_webui.config import (
)
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
@@ -391,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]:
@@ -145,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:
@@ -254,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.
@@ -295,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,4 +1,5 @@
import logging
import time
from typing import Optional
import requests
@@ -25,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()
@@ -13,12 +13,14 @@ def search_duckduckgo(
count: int,
filter_list: Optional[list[str]] = None,
concurrent_requests: Optional[int] = None,
backend: Optional[str] = "auto",
) -> list[SearchResult]:
"""
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
Args:
query (str): The query to search for
count (int): The number of results to return
backend (str): The search backend to use (auto, duckduckgo, google, brave, etc.)
Returns:
list[SearchResult]: A list of search results
@@ -32,7 +34,7 @@ def search_duckduckgo(
# Use the ddgs.text() method to perform the search
try:
search_results = ddgs.text(
query, safesearch="moderate", max_results=count, backend="lite"
query, safesearch="moderate", max_results=count, backend=backend
)
except RatelimitException as e:
log.error(f"RatelimitException: {e}")
+2 -2
View File
@@ -8,7 +8,7 @@ from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
log = logging.getLogger(__name__)
@@ -31,7 +31,7 @@ def search_external(
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers["X-OpenWebUI-Chat-Id"] = str(chat_id)
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
response = requests.post(
external_url,
@@ -3,7 +3,6 @@ from typing import Optional, List
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
log = logging.getLogger(__name__)
@@ -7,17 +7,21 @@ from yarl import URL
log = logging.getLogger(__name__)
def search_jina(api_key: str, query: str, count: int) -> list[SearchResult]:
def search_jina(
api_key: str, query: str, count: int, base_url: str = ""
) -> list[SearchResult]:
"""
Search using Jina's Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): The Jina API key
query (str): The query to search for
count (int): The number of results to return
base_url (str): Optional custom base URL for the Jina API
Returns:
list[SearchResult]: A list of search results
"""
jina_search_endpoint = "https://s.jina.ai/"
jina_search_endpoint = base_url if base_url else "https://s.jina.ai/"
headers = {
"Accept": "application/json",
@@ -5,7 +5,6 @@ import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
+11 -3
View File
@@ -36,6 +36,7 @@ from open_webui.config import (
WEB_LOADER_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
FIRECRAWL_TIMEOUT,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
EXTERNAL_WEB_LOADER_URL,
@@ -173,7 +174,7 @@ class URLProcessingMixin:
def _safe_process_url_sync(self, url: str) -> bool:
"""Synchronous version of safety checks."""
if self.verify_ssl and not self._verify_ssl_cert(url):
if self.verify_ssl and not verify_ssl_cert(url):
raise ValueError(f"SSL certificate verification failed for {url}")
self._sync_wait_for_rate_limit()
return True
@@ -189,6 +190,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
continue_on_failure: bool = True,
api_key: Optional[str] = None,
api_url: Optional[str] = None,
timeout: Optional[int] = None,
mode: Literal["crawl", "scrape", "map"] = "scrape",
proxy: Optional[Dict[str, str]] = None,
params: Optional[Dict] = None,
@@ -231,6 +233,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
self.continue_on_failure = continue_on_failure
self.api_key = api_key
self.api_url = api_url
self.timeout = timeout
self.mode = mode
self.params = params or {}
@@ -253,7 +256,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ignore_invalid_urls=True,
remove_base64_images=True,
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
wait_timeout=len(self.web_paths) * 3,
wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
**self.params,
)
@@ -294,7 +297,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ignore_invalid_urls=True,
remove_base64_images=True,
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
wait_timeout=len(self.web_paths) * 3,
wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
**self.params,
)
@@ -697,6 +700,11 @@ def get_web_loader(
WebLoaderClass = SafeFireCrawlLoader
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
if FIRECRAWL_TIMEOUT.value:
try:
web_loader_args["timeout"] = int(FIRECRAWL_TIMEOUT.value)
except ValueError:
pass
if WEB_LOADER_ENGINE.value == "tavily":
WebLoaderClass = SafeTavilyLoader
+164
View File
@@ -0,0 +1,164 @@
import base64
import io
import json
import logging
import os
from typing import Optional, List
import requests
from fastapi import Request
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.utils.headers import include_user_info_headers
from open_webui.env import FORWARD_SESSION_INFO_HEADER_CHAT_ID
from xml.etree import ElementTree as ET
from xml.etree.ElementTree import Element
log = logging.getLogger(__name__)
def xml_element_contents_to_string(element: Element) -> str:
buffer = [element.text if element.text else ""]
for child in element:
buffer.append(xml_element_contents_to_string(child))
buffer.append(element.tail if element.tail else "")
return "".join(buffer)
def search_yandex(
request: Request,
yandex_search_url: str,
yandex_search_api_key: str,
yandex_search_config: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
user=None,
) -> List[SearchResult]:
try:
headers = {
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Authorization": f"Api-Key {yandex_search_api_key}",
}
if user is not None:
headers = include_user_info_headers(headers, user)
chat_id = getattr(request.state, "chat_id", None)
if chat_id:
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id)
payload = {} if yandex_search_config == "" else json.loads(yandex_search_config)
if type(payload.get("query", None)) != dict:
payload["query"] = {}
if "searchType" not in payload["query"]:
payload["query"]["searchType"] = "SEARCH_TYPE_RU"
payload["query"]["queryText"] = query
if type(payload.get("groupSpec", None)) != dict:
payload["groupSpec"] = {}
if "groupMode" not in payload["groupSpec"]:
payload["groupSpec"]["groupMode"] = "GROUP_MODE_DEEP"
payload["groupSpec"]["groupsOnPage"] = count
payload["groupSpec"]["docsInGroup"] = 1
response = requests.post(
(
"https://searchapi.api.cloud.yandex.net/v2/web/search"
if yandex_search_url == ""
else yandex_search_url
),
headers=headers,
json=payload,
)
response.raise_for_status()
response_body = response.json()
if "rawData" not in response_body:
raise Exception(f"No `rawData` in response body: {response_body}")
search_result_body_bytes = base64.decodebytes(
bytes(response_body["rawData"], "utf-8")
)
doc_root = ET.parse(io.BytesIO(search_result_body_bytes))
results = []
for group in doc_root.findall("response/results/grouping/group"):
results.append(
{
"url": xml_element_contents_to_string(group.find("doc/url")).strip(
"\n"
),
"title": xml_element_contents_to_string(
group.find("doc/title")
).strip("\n"),
"snippet": xml_element_contents_to_string(
group.find("doc/passages/passage")
),
}
)
results = get_filtered_results(results, filter_list)
results = [
SearchResult(
link=result.get("url"),
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results[:count]
]
log.info(f"Yandex search results: {results}")
return results
except Exception as e:
log.error(f"Error in search: {e}")
return []
if __name__ == "__main__":
from starlette.datastructures import Headers
from fastapi import FastAPI
result = search_yandex(
Request(
{
"type": "http",
"asgi.version": "3.0",
"asgi.spec_version": "2.0",
"method": "GET",
"path": "/internal",
"query_string": b"",
"headers": Headers({}).raw,
"client": ("127.0.0.1", 12345),
"server": ("127.0.0.1", 80),
"scheme": "http",
"app": FastAPI(),
},
None,
),
os.environ.get("YANDEX_WEB_SEARCH_URL", ""),
os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""),
os.environ.get(
"YANDEX_WEB_SEARCH_CONFIG", '{"query": {"searchType": "SEARCH_TYPE_COM"}}'
),
"TOP movies of the past year",
3,
)
print(result)
+454
View File
@@ -0,0 +1,454 @@
from typing import Optional
from datetime import datetime, timedelta
from collections import defaultdict
import logging
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from open_webui.models.users import Users
from open_webui.models.feedbacks import Feedbacks
from open_webui.utils.auth import get_admin_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
router = APIRouter()
####################
# Response Models
####################
class ModelAnalyticsEntry(BaseModel):
model_id: str
count: int
class ModelAnalyticsResponse(BaseModel):
models: list[ModelAnalyticsEntry]
class UserAnalyticsEntry(BaseModel):
user_id: str
name: Optional[str] = None
email: Optional[str] = None
count: int
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
class UserAnalyticsResponse(BaseModel):
users: list[UserAnalyticsEntry]
####################
# Endpoints
####################
@router.get("/models", response_model=ModelAnalyticsResponse)
async def get_model_analytics(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts per model."""
counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [
ModelAnalyticsEntry(model_id=model_id, count=count)
for model_id, count in sorted(counts.items(), key=lambda x: -x[1])
]
return ModelAnalyticsResponse(models=models)
@router.get("/users", response_model=UserAnalyticsResponse)
async def get_user_analytics(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
limit: int = Query(50, description="Max users to return"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts and token usage per user with user info."""
counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
token_usage = ChatMessages.get_token_usage_by_user(
start_date=start_date, end_date=end_date, db=db
)
# Get user info for top users
top_user_ids = [
uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]
]
user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)}
users = []
for user_id in top_user_ids:
u = user_info.get(user_id)
tokens = token_usage.get(user_id, {})
users.append(
UserAnalyticsEntry(
user_id=user_id,
name=u.name if u else None,
email=u.email if u else None,
count=counts[user_id],
input_tokens=tokens.get("input_tokens", 0),
output_tokens=tokens.get("output_tokens", 0),
total_tokens=tokens.get("total_tokens", 0),
)
)
return UserAnalyticsResponse(users=users)
@router.get("/messages", response_model=list[ChatMessageModel])
async def get_messages(
model_id: Optional[str] = Query(None, description="Filter by model ID"),
user_id: Optional[str] = Query(None, description="Filter by user ID"),
chat_id: Optional[str] = Query(None, description="Filter by chat ID"),
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
skip: int = Query(0),
limit: int = Query(50, le=100),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Query messages with filters."""
if chat_id:
return ChatMessages.get_messages_by_chat_id(chat_id=chat_id, db=db)
elif model_id:
return ChatMessages.get_messages_by_model_id(
model_id=model_id,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
db=db,
)
elif user_id:
return ChatMessages.get_messages_by_user_id(
user_id=user_id, skip=skip, limit=limit, db=db
)
else:
# Return empty if no filter specified
return []
class SummaryResponse(BaseModel):
total_messages: int
total_chats: int
total_models: int
total_users: int
@router.get("/summary", response_model=SummaryResponse)
async def get_summary(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get summary statistics for the dashboard."""
model_counts = ChatMessages.get_message_count_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
user_counts = ChatMessages.get_message_count_by_user(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
chat_counts = ChatMessages.get_message_count_by_chat(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return SummaryResponse(
total_messages=sum(model_counts.values()),
total_chats=len(chat_counts),
total_models=len(model_counts),
total_users=len(user_counts),
)
class DailyStatsEntry(BaseModel):
date: str
models: dict[str, int]
class DailyStatsResponse(BaseModel):
data: list[DailyStatsEntry]
@router.get("/daily", response_model=DailyStatsResponse)
async def get_daily_stats(
start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"),
end_date: Optional[int] = Query(None, description="End timestamp (epoch)"),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get message counts grouped by model for time-series chart."""
if granularity == "hourly":
counts = ChatMessages.get_hourly_message_counts_by_model(
start_date=start_date, end_date=end_date, db=db
)
else:
counts = ChatMessages.get_daily_message_counts_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
return DailyStatsResponse(
data=[
DailyStatsEntry(date=date, models=models)
for date, models in sorted(counts.items())
]
)
class TokenUsageEntry(BaseModel):
model_id: str
input_tokens: int
output_tokens: int
total_tokens: int
message_count: int
class TokenUsageResponse(BaseModel):
models: list[TokenUsageEntry]
total_input_tokens: int
total_output_tokens: int
total_tokens: int
@router.get("/tokens", response_model=TokenUsageResponse)
async def get_token_usage(
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
group_id: Optional[str] = Query(None, description="Filter by user group ID"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get token usage aggregated by model."""
usage = ChatMessages.get_token_usage_by_model(
start_date=start_date, end_date=end_date, group_id=group_id, db=db
)
models = [
TokenUsageEntry(model_id=model_id, **data)
for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"])
]
total_input = sum(m.input_tokens for m in models)
total_output = sum(m.output_tokens for m in models)
return TokenUsageResponse(
models=models,
total_input_tokens=total_input,
total_output_tokens=total_output,
total_tokens=total_input + total_output,
)
####################
# Model Chats Browser
####################
class ModelChatEntry(BaseModel):
chat_id: str
user_id: Optional[str] = None
user_name: Optional[str] = None
first_message: Optional[str] = None
updated_at: int
class ModelChatsResponse(BaseModel):
chats: list[ModelChatEntry]
total: int
@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
async def get_model_chats(
model_id: str,
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
skip: int = Query(0),
limit: int = Query(50, le=100),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get chats that used a specific model, with preview and feedback info."""
# Get chat IDs that used this model
chat_ids = ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
db=db,
)
if not chat_ids:
return ModelChatsResponse(chats=[], total=0)
# Get chat details from messages only
chats_data = []
for chat_id in chat_ids:
messages = ChatMessages.get_messages_by_chat_id(chat_id, db=db)
if not messages:
continue
# Get user_id from first user message
first_user_msg = next((m for m in messages if m.role == "user"), None)
user_id = first_user_msg.user_id if first_user_msg else None
# Extract first message content as preview
first_message = None
if first_user_msg and first_user_msg.content:
content = first_user_msg.content
if isinstance(content, str):
first_message = content[:200]
elif isinstance(content, list):
text_parts = [b.get("text", "") for b in content if isinstance(b, dict)]
first_message = " ".join(text_parts)[:200]
# Get user info
user_name = None
if user_id:
user_info = Users.get_user_by_id(user_id, db=db)
user_name = user_info.name if user_info else None
# Timestamps from messages
updated_at = max(m.created_at for m in messages) if messages else 0
chats_data.append(
ModelChatEntry(
chat_id=chat_id,
user_id=user_id,
user_name=user_name,
first_message=first_message,
updated_at=updated_at,
)
)
return ModelChatsResponse(chats=chats_data, total=len(chats_data))
####################
# Model Overview
####################
class HistoryEntry(BaseModel):
date: str
won: int = 0
lost: int = 0
class TagEntry(BaseModel):
tag: str
count: int
class ModelOverviewResponse(BaseModel):
history: list[HistoryEntry]
tags: list[TagEntry]
@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
async def get_model_overview(
model_id: str,
days: int = Query(30, description="Number of days of history (0 for all)"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get model overview with feedback history and chat tags."""
# Get chat IDs that used this model
chat_ids = ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
start_date=None,
end_date=None,
skip=0,
limit=10000, # Get all chats
db=db,
)
# Get feedback history per day
history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0})
# Calculate start date for history
now = datetime.now()
start_dt = None
if days > 0:
start_dt = now - timedelta(days=days)
for chat_id in chat_ids:
feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
for fb in feedbacks:
if fb.data and "rating" in fb.data:
rating = fb.data["rating"]
fb_date = datetime.fromtimestamp(fb.created_at)
# Filter by date range
if start_dt and fb_date < start_dt:
continue
date_str = fb_date.strftime("%Y-%m-%d")
if rating == 1:
history_counts[date_str]["won"] += 1
elif rating == -1:
history_counts[date_str]["lost"] += 1
# Fill in missing days
history = []
if history_counts or days > 0:
end_dt = now
if days > 0:
current = start_dt
elif history_counts:
# Find earliest date
min_date = min(history_counts.keys())
current = datetime.strptime(min_date, "%Y-%m-%d")
else:
current = now
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
counts = history_counts.get(date_str, {"won": 0, "lost": 0})
history.append(
HistoryEntry(
date=date_str,
won=counts["won"],
lost=counts["lost"],
)
)
current += timedelta(days=1)
# Get chat tags
tag_counts: dict[str, int] = defaultdict(int)
for chat_id in chat_ids:
chat = Chats.get_chat_by_id(chat_id, db=db)
if chat and chat.meta:
for tag in chat.meta.get("tags", []):
tag_counts[tag] += 1
# Sort by count and take top 10
tags = [
TagEntry(tag=tag, count=count)
for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]
]
return ModelOverviewResponse(history=history, tags=tags)
+49 -12
View File
@@ -35,12 +35,16 @@ from pydantic import BaseModel
from open_webui.utils.misc import strict_match_mime_type
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.headers import include_user_info_headers
from open_webui.config import (
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_COMPUTE_TYPE,
WHISPER_MODEL_DIR,
WHISPER_VAD_FILTER,
CACHE_DIR,
WHISPER_LANGUAGE,
WHISPER_MULTILINGUAL,
ELEVENLABS_API_BASE_URL,
)
@@ -49,11 +53,11 @@ from open_webui.env import (
ENV,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
DEVICE_TYPE,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
router = APIRouter()
# Constants
@@ -129,7 +133,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
faster_whisper_kwargs = {
"model_size_or_path": model,
"device": DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu",
"compute_type": "int8",
"compute_type": WHISPER_COMPUTE_TYPE,
"download_root": WHISPER_MODEL_DIR,
"local_files_only": not auto_update,
}
@@ -328,6 +332,20 @@ def load_speech_pipeline(request):
@router.post("/speech")
async def speech(request: Request, user=Depends(get_verified_user)):
if request.app.state.config.TTS_ENGINE == "":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != "admin" and not has_permission(
user.id, "chat.tts", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
body = await request.body()
name = hashlib.sha256(
body
@@ -585,8 +603,9 @@ def transcription_handler(request, file_path, metadata, user=None):
segments, info = model.transcribe(
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
vad_filter=WHISPER_VAD_FILTER,
language=languages[0],
multilingual=WHISPER_MULTILINGUAL,
)
log.info(
"Detected language '%s' with probability %f"
@@ -620,12 +639,14 @@ def transcription_handler(request, file_path, metadata, user=None):
if user and ENABLE_FORWARD_USER_INFO_HEADERS:
headers = include_user_info_headers(headers, user)
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers=headers,
files={"file": (filename, open(file_path, "rb"))},
data=payload,
)
with open(file_path, "rb") as audio_file:
r = requests.post(
url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions",
headers=headers,
files={"file": (filename, audio_file)},
data=payload,
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
if r.status_code == 200:
# Successful transcription
@@ -685,6 +706,7 @@ def transcription_handler(request, file_path, metadata, user=None):
headers=headers,
params=params,
data=file_data,
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
if r.status_code == 200:
@@ -796,6 +818,7 @@ def transcription_handler(request, file_path, metadata, user=None):
headers={
"Ocp-Apim-Subscription-Key": api_key,
},
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
r.raise_for_status()
@@ -935,6 +958,7 @@ def transcription_handler(request, file_path, metadata, user=None):
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
r.raise_for_status()
@@ -978,6 +1002,7 @@ def transcription_handler(request, file_path, metadata, user=None):
headers={
"Authorization": f"Bearer {api_key}",
},
timeout=AIOHTTP_CLIENT_TIMEOUT,
)
r.raise_for_status()
@@ -1150,6 +1175,13 @@ def transcription(
language: Optional[str] = Form(None),
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
log.info(f"file.content_type: {file.content_type}")
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
@@ -1214,7 +1246,8 @@ def get_available_models(request: Request) -> list[dict]:
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models"
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models",
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
data = response.json()
@@ -1260,7 +1293,8 @@ def get_available_voices(request) -> dict:
):
try:
response = requests.get(
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices"
f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices",
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
data = response.json()
@@ -1304,7 +1338,9 @@ def get_available_voices(request) -> dict:
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY
}
response = requests.get(url, headers=headers)
response = requests.get(
url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST
)
response.raise_for_status()
voices = response.json()
@@ -1336,6 +1372,7 @@ def get_elevenlabs_voices(api_key: str) -> dict:
"xi-api-key": api_key,
"Content-Type": "application/json",
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
+384 -203
View File
@@ -1,3 +1,4 @@
import asyncio
import re
import uuid
import time
@@ -19,6 +20,7 @@ from open_webui.models.auths import (
UpdatePasswordForm,
)
from open_webui.models.users import (
UserModel,
UserProfileImageResponse,
Users,
UpdateProfileForm,
@@ -37,6 +39,8 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_INITIAL_ADMIN_SIGNUP,
ENABLE_OAUTH_TOKEN_EXCHANGE,
AIOHTTP_CLIENT_SESSION_SSL,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response, JSONResponse
@@ -45,6 +49,8 @@ from open_webui.config import (
ENABLE_OAUTH_SIGNUP,
ENABLE_LDAP,
ENABLE_PASSWORD_AUTH,
OAUTH_PROVIDERS,
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
)
from pydantic import BaseModel
@@ -62,6 +68,8 @@ from open_webui.utils.auth import (
get_password_hash,
get_http_authorization_cred,
)
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.webhook import post_webhook
from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.groups import apply_default_group_assignment
@@ -85,6 +93,63 @@ signin_rate_limiter = RateLimiter(
redis_client=get_redis_client(), limit=5 * 3, window=60 * 3
)
def create_session_response(
request: Request, user, db, response: Response = None, set_cookie: bool = False
) -> dict:
"""
Create JWT token and build session response for a user.
Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints.
Args:
request: FastAPI request object
user: User object
db: Database session
response: FastAPI response object (required if set_cookie is True)
set_cookie: Whether to set the auth cookie on the response
"""
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
if set_cookie and response:
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": f"/api/v1/users/{user.id}/profile/image",
"permissions": user_permissions,
}
############################
# GetSessionUser
############################
@@ -103,7 +168,10 @@ class SessionUserInfoResponse(SessionUserResponse, UserStatus):
@router.get("/", response_model=SessionUserInfoResponse)
async def get_session_user(
request: Request, response: Response, user=Depends(get_current_user)
request: Request,
response: Response,
user=Depends(get_current_user),
db: Session = Depends(get_session),
):
auth_header = request.headers.get("Authorization")
@@ -137,7 +205,7 @@ async def get_session_user(
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
user.id, request.app.state.config.USER_PERMISSIONS, db=db
)
return {
@@ -166,12 +234,15 @@ async def get_session_user(
@router.post("/update/profile", response_model=UserProfileImageResponse)
async def update_profile(
form_data: UpdateProfileForm, session_user=Depends(get_verified_user)
form_data: UpdateProfileForm,
session_user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if session_user:
user = Users.update_user_by_id(
session_user.id,
form_data.model_dump(),
db=db,
)
if user:
return user
@@ -181,6 +252,32 @@ async def update_profile(
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################
# Update Timezone
############################
class UpdateTimezoneForm(BaseModel):
timezone: str
@router.post("/update/timezone")
async def update_timezone(
form_data: UpdateTimezoneForm,
session_user=Depends(get_current_user),
db: Session = Depends(get_session),
):
if session_user:
Users.update_user_by_id(
session_user.id,
{"timezone": form_data.timezone},
db=db,
)
return {"status": True}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################
# Update Password
############################
@@ -188,13 +285,17 @@ async def update_profile(
@router.post("/update/password", response_model=bool)
async def update_password(
form_data: UpdatePasswordForm, session_user=Depends(get_current_user)
form_data: UpdatePasswordForm,
session_user=Depends(get_current_user),
db: Session = Depends(get_session),
):
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
if session_user:
user = Auths.authenticate_user(
session_user.email, lambda pw: verify_password(form_data.password, pw)
session_user.email,
lambda pw: verify_password(form_data.password, pw),
db=db,
)
if user:
@@ -203,7 +304,7 @@ async def update_password(
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.new_password)
return Auths.update_user_password_by_id(user.id, hashed)
return Auths.update_user_password_by_id(user.id, hashed, db=db)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
else:
@@ -214,7 +315,12 @@ async def update_password(
# LDAP Authentication
############################
@router.post("/ldap", response_model=SessionUserResponse)
async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
async def ldap_auth(
request: Request,
response: Response,
form_data: LdapForm,
db: Session = Depends(get_session),
):
# Security checks FIRST - before loading any config
if not request.app.state.config.ENABLE_LDAP:
raise HTTPException(400, detail="LDAP authentication is not enabled")
@@ -272,7 +378,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
auto_bind="NONE",
authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS",
)
if not connection_app.bind():
if not await asyncio.to_thread(connection_app.bind):
raise HTTPException(400, detail="Application account bind failed")
ENABLE_LDAP_GROUP_MANAGEMENT = (
@@ -293,7 +399,8 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
)
log.info(f"LDAP search attributes: {search_attributes}")
search_success = connection_app.search(
search_success = await asyncio.to_thread(
connection_app.search,
search_base=LDAP_SEARCH_BASE,
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=search_attributes,
@@ -397,15 +504,15 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
auto_bind="NONE",
authentication="SIMPLE",
)
if not connection_user.bind():
if not await asyncio.to_thread(connection_user.bind):
raise HTTPException(400, "Authentication failed.")
user = Users.get_user_by_email(email)
user = Users.get_user_by_email(email, db=db)
if not user:
try:
role = (
"admin"
if not Users.has_users()
if not Users.has_users(db=db)
else request.app.state.config.DEFAULT_USER_ROLE
)
@@ -414,6 +521,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
password=str(uuid.uuid4()),
name=cn,
role=role,
db=db,
)
if not user:
@@ -424,6 +532,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
except HTTPException:
@@ -434,65 +543,27 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
500, detail="Internal error occurred during LDAP user creation."
)
user = Auths.authenticate_user_by_email(email)
user = Auths.authenticate_user_by_email(email, db=db)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=(
datetime.datetime.fromtimestamp(
expires_at, datetime.timezone.utc
)
if expires_at
else None
),
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
if (
user.role != "admin"
and ENABLE_LDAP_GROUP_MANAGEMENT
and user_groups
):
if ENABLE_LDAP_GROUP_CREATION:
Groups.create_groups_by_group_names(user.id, user_groups)
Groups.create_groups_by_group_names(user.id, user_groups, db=db)
try:
Groups.sync_groups_by_group_names(user.id, user_groups)
Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
log.info(
f"Successfully synced groups for user {user.id}: {user_groups}"
)
except Exception as e:
log.error(f"Failed to sync groups for user {user.id}: {e}")
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(
request, user, db, response, set_cookie=True
)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
@@ -508,7 +579,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
@router.post("/signin", response_model=SessionUserResponse)
async def signin(request: Request, response: Response, form_data: SigninForm):
async def signin(
request: Request,
response: Response,
form_data: SigninForm,
db: Session = Depends(get_session),
):
if not ENABLE_PASSWORD_AUTH:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -529,14 +605,16 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
except Exception as e:
pass
if not Users.get_user_by_email(email.lower()):
await signup(
if not Users.get_user_by_email(email.lower(), db=db):
await signup_handler(
request,
response,
SignupForm(email=email, password=str(uuid.uuid4()), name=name),
email,
str(uuid.uuid4()),
name,
db=db,
)
user = Auths.authenticate_user_by_email(email)
user = Auths.authenticate_user_by_email(email, db=db)
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin":
group_names = request.headers.get(
WEBUI_AUTH_TRUSTED_GROUPS_HEADER, ""
@@ -544,28 +622,34 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
group_names = [name.strip() for name in group_names if name.strip()]
if group_names:
Groups.sync_groups_by_group_names(user.id, group_names)
Groups.sync_groups_by_group_names(user.id, group_names, db=db)
elif WEBUI_AUTH == False:
admin_email = "admin@localhost"
admin_password = "admin"
if Users.get_user_by_email(admin_email.lower()):
if Users.get_user_by_email(admin_email.lower(), db=db):
user = Auths.authenticate_user(
admin_email.lower(), lambda pw: verify_password(admin_password, pw)
admin_email.lower(),
lambda pw: verify_password(admin_password, pw),
db=db,
)
else:
if Users.has_users():
if Users.has_users(db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup(
await signup_handler(
request,
response,
SignupForm(email=admin_email, password=admin_password, name="User"),
admin_email,
admin_password,
"User",
db=db,
)
user = Auths.authenticate_user(
admin_email.lower(), lambda pw: verify_password(admin_password, pw)
admin_email.lower(),
lambda pw: verify_password(admin_password, pw),
db=db,
)
else:
if signin_rate_limiter.is_limited(form_data.email.lower()):
@@ -584,52 +668,13 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
form_data.password = password_bytes.decode("utf-8", errors="ignore")
user = Auths.authenticate_user(
form_data.email.lower(), lambda pw: verify_password(form_data.password, pw)
form_data.email.lower(),
lambda pw: verify_password(form_data.password, pw),
db=db,
)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
return create_session_response(request, user, db, response, set_cookie=True)
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@@ -639,9 +684,70 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
############################
async def signup_handler(
request: Request,
email: str,
password: str,
name: str,
profile_image_url: str = "/user.png",
*,
db: Session,
) -> UserModel:
"""
Core user-creation logic shared by the signup endpoint and
trusted-header / no-auth auto-registration flows.
Returns the newly created UserModel.
Raises HTTPException on failure.
"""
has_users = Users.has_users(db=db)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
email=email.lower(),
password=hashed,
name=name,
profile_image_url=profile_image_url,
role=role,
db=db,
)
if not user:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
return user
@router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm):
has_users = Users.has_users()
async def signup(
request: Request,
response: Response,
form_data: SignupForm,
db: Session = Depends(get_session),
):
has_users = Users.has_users(db=db)
if WEBUI_AUTH:
if (
@@ -663,7 +769,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
if Users.get_user_by_email(form_data.email.lower()):
if Users.get_user_by_email(form_data.email.lower(), db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
@@ -672,89 +778,26 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
except Exception as e:
raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
user = Auths.insert_new_auth(
form_data.email.lower(),
hashed,
user = await signup_handler(
request,
form_data.email,
form_data.password,
form_data.name,
form_data.profile_image_url,
role,
db=db,
)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
)
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
return create_session_response(request, user, db, response, set_cookie=True)
except HTTPException:
raise
except Exception as err:
log.error(f"Signup error: {str(err)}")
raise HTTPException(500, detail="An internal error occurred during signup.")
@router.get("/signout")
async def signout(request: Request, response: Response):
async def signout(
request: Request, response: Response, db: Session = Depends(get_session)
):
# get auth token from headers or cookies
token = None
@@ -776,7 +819,7 @@ async def signout(request: Request, response: Response):
if oauth_session_id:
response.delete_cookie("oauth_session_id")
session = OAuthSessions.get_session_by_id(oauth_session_id)
session = OAuthSessions.get_session_by_id(oauth_session_id, db=db)
oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
if session
@@ -839,14 +882,17 @@ async def signout(request: Request, response: Response):
@router.post("/add", response_model=SigninResponse)
async def add_user(
request: Request, form_data: AddUserForm, user=Depends(get_admin_user)
request: Request,
form_data: AddUserForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
if Users.get_user_by_email(form_data.email.lower()):
if Users.get_user_by_email(form_data.email.lower(), db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try:
@@ -862,12 +908,14 @@ async def add_user(
form_data.name,
form_data.profile_image_url,
form_data.role,
db=db,
)
if user:
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
db=db,
)
token = create_token(data={"id": user.id})
@@ -878,10 +926,12 @@ async def add_user(
"email": user.email,
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"profile_image_url": f"/api/v1/users/{user.id}/profile/image",
}
else:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
except HTTPException:
raise
except Exception as err:
log.error(f"Add user error: {str(err)}")
raise HTTPException(
@@ -895,7 +945,9 @@ async def add_user(
@router.get("/admin/details")
async def get_admin_details(request: Request, user=Depends(get_current_user)):
async def get_admin_details(
request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)
):
if request.app.state.config.SHOW_ADMIN_DETAILS:
admin_email = request.app.state.config.ADMIN_EMAIL
admin_name = None
@@ -903,11 +955,11 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}")
if admin_email:
admin = Users.get_user_by_email(admin_email)
admin = Users.get_user_by_email(admin_email, db=db)
if admin:
admin_name = admin.name
else:
admin = Users.get_first_user()
admin = Users.get_first_user(db=db)
if admin:
admin_email = admin.email
admin_name = admin.name
@@ -929,6 +981,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
async def get_admin_config(request: Request, user=Depends(get_admin_user)):
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
@@ -940,9 +993,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
@@ -951,6 +1007,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
class AdminConfig(BaseModel):
SHOW_ADMIN_DETAILS: bool
ADMIN_EMAIL: Optional[str] = None
WEBUI_URL: str
ENABLE_SIGNUP: bool
ENABLE_API_KEYS: bool
@@ -962,9 +1019,12 @@ class AdminConfig(BaseModel):
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
ENABLE_FOLDERS: bool
FOLDER_MAX_FILE_COUNT: Optional[int | str] = None
ENABLE_CHANNELS: bool
ENABLE_MEMORIES: bool
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
ENABLE_USER_STATUS: bool
PENDING_USER_OVERLAY_TITLE: Optional[str] = None
PENDING_USER_OVERLAY_CONTENT: Optional[str] = None
RESPONSE_WATERMARK: Optional[str] = None
@@ -975,6 +1035,7 @@ async def update_admin_config(
request: Request, form_data: AdminConfig, user=Depends(get_admin_user)
):
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
@@ -987,7 +1048,11 @@ async def update_admin_config(
)
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
request.app.state.config.FOLDER_MAX_FILE_COUNT = (
int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ""
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
@@ -1007,6 +1072,7 @@ async def update_admin_config(
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS
request.app.state.config.PENDING_USER_OVERLAY_TITLE = (
form_data.PENDING_USER_OVERLAY_TITLE
@@ -1019,6 +1085,7 @@ async def update_admin_config(
return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL,
"WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
@@ -1030,9 +1097,12 @@ async def update_admin_config(
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
@@ -1149,7 +1219,9 @@ async def update_ldap_config(
# create api key
@router.post("/api_key", response_model=ApiKey)
async def generate_api_key(request: Request, user=Depends(get_current_user)):
async def generate_api_key(
request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)
):
if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS
):
@@ -1159,7 +1231,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
)
api_key = create_api_key()
success = Users.update_user_api_key_by_id(user.id, api_key)
success = Users.update_user_api_key_by_id(user.id, api_key, db=db)
if success:
return {
@@ -1171,17 +1243,126 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
# delete api key
@router.delete("/api_key", response_model=bool)
async def delete_api_key(user=Depends(get_current_user)):
return Users.delete_user_api_key_by_id(user.id)
async def delete_api_key(
user=Depends(get_current_user), db: Session = Depends(get_session)
):
return Users.delete_user_api_key_by_id(user.id, db=db)
# get api key
@router.get("/api_key", response_model=ApiKey)
async def get_api_key(user=Depends(get_current_user)):
api_key = Users.get_user_api_key_by_id(user.id)
async def get_api_key(
user=Depends(get_current_user), db: Session = Depends(get_session)
):
api_key = Users.get_user_api_key_by_id(user.id, db=db)
if api_key:
return {
"api_key": api_key,
}
else:
raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
############################
# Token Exchange
############################
class TokenExchangeForm(BaseModel):
token: str # OAuth access token from external provider
@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse)
async def token_exchange(
request: Request,
response: Response,
provider: str,
form_data: TokenExchangeForm,
db: Session = Depends(get_session),
):
"""
Exchange an external OAuth provider token for an OpenWebUI JWT.
This endpoint is disabled by default. Set ENABLE_OAUTH_TOKEN_EXCHANGE=True to enable.
"""
if not ENABLE_OAUTH_TOKEN_EXCHANGE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token exchange is disabled",
)
provider = provider.lower()
# Check if provider is configured
if provider not in OAUTH_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Provider '{provider}' is not configured",
)
# Get the OAuth client for this provider
oauth_manager = request.app.state.oauth_manager
client = oauth_manager.get_client(provider)
if not client:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"OAuth client for '{provider}' not found",
)
# Validate the token by calling the userinfo endpoint
try:
token_data = {"access_token": form_data.token, "token_type": "Bearer"}
user_data = await client.userinfo(token=token_data)
if not user_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token or unable to fetch user info",
)
except Exception as e:
log.warning(f"Token exchange failed for provider {provider}: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid token or unable to validate with provider",
)
# Extract user information from the token claims
email_claim = request.app.state.config.OAUTH_EMAIL_CLAIM
username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM
# Get sub claim
sub = user_data.get(
request.app.state.config.OAUTH_SUB_CLAIM
or OAUTH_PROVIDERS[provider].get("sub_claim", "sub")
)
if not sub:
log.warning(f"Token exchange failed: sub claim missing from user data")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token missing required 'sub' claim",
)
email = user_data.get(email_claim, "")
if not email:
log.warning(f"Token exchange failed: email claim missing from user data")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token missing required email claim",
)
email = email.lower()
# Try to find the user by OAuth sub
user = Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user and OAUTH_MERGE_ACCOUNTS_BY_EMAIL.value:
# Try to find by email if merge is enabled
user = Users.get_user_by_email(email, db=db)
if user:
# Link the OAuth sub to this user
Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
if not user:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not found. Please sign in via the web interface first.",
)
return create_session_response(request, user, db)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -6,6 +6,7 @@ import aiohttp
from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config
from open_webui.config import BannerModel
@@ -223,12 +224,15 @@ async def verify_tool_servers_config(
try:
if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url)
discovery_urls = await get_discovery_urls(form_data.url)
for discovery_url in discovery_urls:
log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
)
async with aiohttp.ClientSession(trust_env=True) as session:
async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
async with session.get(
discovery_url
) as oauth_server_metadata_response:
+305 -24
View File
@@ -1,5 +1,7 @@
from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from open_webui.models.users import Users, UserModel
@@ -10,15 +12,268 @@ from open_webui.models.feedbacks import (
FeedbackForm,
FeedbackUserResponse,
FeedbackListResponse,
LeaderboardFeedbackData,
ModelHistoryEntry,
ModelHistoryResponse,
Feedbacks,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
router = APIRouter()
# Leaderboard Elo Rating Computation
#
# How it works:
# 1. Each model starts with a rating of 1000
# 2. When a user picks a winner between two models, ratings are adjusted:
# - Winner gains points, loser loses points
# - The amount depends on expected outcome (upset = bigger change)
# 3. The Elo formula: new_rating = old_rating + K * (actual - expected)
# - K=32 controls how much ratings can change per match
# - expected = probability of winning based on current ratings
#
# Query-based re-ranking (optional):
# When a user searches for a topic (e.g., "coding"), we want to show
# which models perform best FOR THAT TOPIC. We do this by:
# 1. Computing semantic similarity between the query and each feedback's tags
# 2. Using that similarity as a weight in the Elo calculation
# 3. Feedbacks about "coding" contribute more to the final ranking
# 4. Feedbacks about unrelated topics (e.g., "cooking") contribute less
# This gives topic-specific leaderboards without needing separate data.
import os
EMBEDDING_MODEL_NAME = os.environ.get(
"AUXILIARY_EMBEDDING_MODEL", "TaylorAI/bge-micro-v2"
)
_embedding_model = None
def _get_embedding_model():
global _embedding_model
if _embedding_model is None:
try:
from sentence_transformers import SentenceTransformer
_embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
except Exception as e:
log.error(f"Embedding model load failed: {e}")
return _embedding_model
def _calculate_elo(
feedbacks: list[LeaderboardFeedbackData], similarities: dict = None
) -> dict:
"""
Calculate Elo ratings for models based on user feedback.
Each feedback represents a comparison where a user rated one model
against its opponents (sibling_model_ids). Rating=1 means the model won,
rating=-1 means it lost.
The Elo system adjusts ratings based on:
- Current rating difference (upsets cause bigger swings)
- Optional similarity weights (for query-based filtering)
Returns: {model_id: {"rating": float, "won": int, "lost": int}}
"""
K_FACTOR = 32 # Standard Elo K-factor for rating volatility
model_stats = {}
def get_or_create_stats(model_id):
if model_id not in model_stats:
model_stats[model_id] = {"rating": 1000.0, "won": 0, "lost": 0}
return model_stats[model_id]
for feedback in feedbacks:
data = feedback.data or {}
winner_id = data.get("model_id")
rating_value = str(data.get("rating", ""))
if not winner_id or rating_value not in ("1", "-1"):
continue
won = rating_value == "1"
weight = similarities.get(feedback.id, 1.0) if similarities else 1.0
for opponent_id in data.get("sibling_model_ids") or []:
winner = get_or_create_stats(winner_id)
opponent = get_or_create_stats(opponent_id)
expected = 1 / (1 + 10 ** ((opponent["rating"] - winner["rating"]) / 400))
winner["rating"] += K_FACTOR * ((1 if won else 0) - expected) * weight
opponent["rating"] += (
K_FACTOR * ((0 if won else 1) - (1 - expected)) * weight
)
if won:
winner["won"] += 1
opponent["lost"] += 1
else:
winner["lost"] += 1
opponent["won"] += 1
return model_stats
def _get_top_tags(feedbacks: list[LeaderboardFeedbackData], limit: int = 5) -> dict:
"""
Count tag occurrences per model and return the most frequent ones.
Each feedback can have tags describing the conversation topic.
This aggregates those tags per model to show what topics each model
is commonly used for.
Returns: {model_id: [{"tag": str, "count": int}, ...]}
"""
from collections import defaultdict
tag_counts = defaultdict(lambda: defaultdict(int))
for feedback in feedbacks:
data = feedback.data or {}
model_id = data.get("model_id")
if model_id:
for tag in data.get("tags", []):
tag_counts[model_id][tag] += 1
return {
model_id: [
{"tag": tag, "count": count}
for tag, count in sorted(tags.items(), key=lambda x: -x[1])[:limit]
]
for model_id, tags in tag_counts.items()
}
def _compute_similarities(feedbacks: list[LeaderboardFeedbackData], query: str) -> dict:
"""
Compute how relevant each feedback is to a search query.
Uses embeddings to find semantic similarity between the query and
each feedback's tags. Higher similarity means the feedback is more
relevant to what the user searched for.
This is used to weight Elo calculations - feedbacks matching the
query have more influence on the final rankings.
Returns: {feedback_id: similarity_score (0-1)}
"""
import numpy as np
embedding_model = _get_embedding_model()
if not embedding_model:
return {}
all_tags = list(
{
tag
for feedback in feedbacks
if feedback.data
for tag in feedback.data.get("tags", [])
}
)
if not all_tags:
return {}
try:
tag_embeddings = embedding_model.encode(all_tags)
query_embedding = embedding_model.encode([query])[0]
except Exception as e:
log.error(f"Embedding error: {e}")
return {}
# Vectorized cosine similarity
tag_norms = np.linalg.norm(tag_embeddings, axis=1)
query_norm = np.linalg.norm(query_embedding)
similarities = np.dot(tag_embeddings, query_embedding) / (
tag_norms * query_norm + 1e-9
)
tag_similarity_map = dict(zip(all_tags, similarities.tolist()))
return {
feedback.id: max(
(
tag_similarity_map.get(tag, 0)
for tag in (feedback.data or {}).get("tags", [])
),
default=0,
)
for feedback in feedbacks
}
class LeaderboardEntry(BaseModel):
model_id: str
rating: int
won: int
lost: int
count: int
top_tags: list[dict]
class LeaderboardResponse(BaseModel):
entries: list[LeaderboardEntry]
@router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard(
query: Optional[str] = None,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get model leaderboard with Elo ratings. Query filters by tag similarity."""
feedbacks = Feedbacks.get_feedbacks_for_leaderboard(db=db)
similarities = None
if query and query.strip():
similarities = await run_in_threadpool(
_compute_similarities, feedbacks, query.strip()
)
elo_stats = _calculate_elo(feedbacks, similarities)
tags_by_model = _get_top_tags(feedbacks)
entries = sorted(
[
LeaderboardEntry(
model_id=mid,
rating=round(s["rating"]),
won=s["won"],
lost=s["lost"],
count=s["won"] + s["lost"],
top_tags=tags_by_model.get(mid, []),
)
for mid, s in elo_stats.items()
],
key=lambda e: e.rating,
reverse=True,
)
return LeaderboardResponse(entries=entries)
@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse)
async def get_model_history(
model_id: str,
days: int = 30,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get daily win/loss history for a specific model."""
history = Feedbacks.get_model_evaluation_history(
model_id=model_id, days=days, db=db
)
return ModelHistoryResponse(model_id=model_id, history=history)
############################
# GetConfig
############################
@@ -60,38 +315,49 @@ async def update_config(
@router.get("/feedbacks/all", response_model=list[FeedbackResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
async def get_all_feedbacks(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
return feedbacks
@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse])
async def get_all_feedback_ids(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
return feedbacks
async def get_all_feedback_ids(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Feedbacks.get_all_feedback_ids(db=db)
@router.delete("/feedbacks/all")
async def delete_all_feedbacks(user=Depends(get_admin_user)):
success = Feedbacks.delete_all_feedbacks()
async def delete_all_feedbacks(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
success = Feedbacks.delete_all_feedbacks(db=db)
return success
@router.get("/feedbacks/all/export", response_model=list[FeedbackModel])
async def export_all_feedbacks(user=Depends(get_admin_user)):
feedbacks = Feedbacks.get_all_feedbacks()
async def export_all_feedbacks(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
return feedbacks
@router.get("/feedbacks/user", response_model=list[FeedbackUserResponse])
async def get_feedbacks(user=Depends(get_verified_user)):
feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id)
async def get_feedbacks(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id, db=db)
return feedbacks
@router.delete("/feedbacks", response_model=bool)
async def delete_feedbacks(user=Depends(get_verified_user)):
success = Feedbacks.delete_feedbacks_by_user_id(user.id)
async def delete_feedbacks(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
success = Feedbacks.delete_feedbacks_by_user_id(user.id, db=db)
return success
@@ -104,6 +370,7 @@ async def get_feedbacks(
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
@@ -116,7 +383,7 @@ async def get_feedbacks(
if direction:
filter["direction"] = direction
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit)
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db)
return result
@@ -125,8 +392,11 @@ async def create_feedback(
request: Request,
form_data: FeedbackForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
feedback = Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data)
feedback = Feedbacks.insert_new_feedback(
user_id=user.id, form_data=form_data, db=db
)
if not feedback:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -137,11 +407,15 @@ async def create_feedback(
@router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def get_feedback_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin":
feedback = Feedbacks.get_feedback_by_id(id=id)
feedback = Feedbacks.get_feedback_by_id(id=id, db=db)
else:
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
feedback = Feedbacks.get_feedback_by_id_and_user_id(
id=id, user_id=user.id, db=db
)
if not feedback:
raise HTTPException(
@@ -153,13 +427,16 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/feedback/{id}", response_model=FeedbackModel)
async def update_feedback_by_id(
id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
id: str,
form_data: FeedbackForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role == "admin":
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db)
else:
feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data
id=id, user_id=user.id, form_data=form_data, db=db
)
if not feedback:
@@ -171,11 +448,15 @@ async def update_feedback_by_id(
@router.delete("/feedback/{id}")
async def delete_feedback_by_id(id: str, user=Depends(get_verified_user)):
async def delete_feedback_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin":
success = Feedbacks.delete_feedback_by_id(id=id)
success = Feedbacks.delete_feedback_by_id(id=id, db=db)
else:
success = Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id)
success = Feedbacks.delete_feedback_by_id_and_user_id(
id=id, user_id=user.id, db=db
)
if not success:
raise HTTPException(
+222 -114
View File
@@ -2,7 +2,6 @@ import logging
import os
import uuid
import json
from fnmatch import fnmatch
from pathlib import Path
from typing import Optional
from urllib.parse import quote
@@ -22,6 +21,8 @@ from fastapi import (
)
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session, SessionLocal
from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@@ -37,6 +38,7 @@ from open_webui.models.files import (
from open_webui.models.chats import Chats
from open_webui.models.knowledge import Knowledges
from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrants
from open_webui.routers.retrieval import ProcessFileForm, process_file
@@ -46,7 +48,6 @@ from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel
@@ -62,9 +63,12 @@ router = APIRouter()
# TODO: Optimize this function to use the knowledge_file table for faster lookups.
def has_access_to_file(
file_id: Optional[str], access_type: str, user=Depends(get_verified_user)
file_id: Optional[str],
access_type: str,
user=Depends(get_verified_user),
db: Optional[Session] = None,
) -> bool:
file = Files.get_file_by_id(file_id)
file = Files.get_file_by_id(file_id, db=db)
log.debug(f"Checking if user has {access_type} access to file")
if not file:
raise HTTPException(
@@ -73,31 +77,38 @@ def has_access_to_file(
)
# Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or has_access(
user.id, access_type, knowledge_base.access_control, user_group_ids
if knowledge_base.user_id == user.id or AccessGrants.has_access(
user_id=user.id,
resource_type="knowledge",
resource_id=knowledge_base.id,
permission=access_type,
user_group_ids=user_group_ids,
db=db,
):
return True
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type
user.id, access_type, db=db
)
for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id:
return True
# Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id)
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
if access_type == "read" and channels:
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = Chats.get_shared_chats_by_file_id(file_id)
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats:
return True
@@ -109,47 +120,78 @@ def has_access_to_file(
############################
def process_uploaded_file(request, file, file_path, file_item, file_metadata, user):
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
def process_uploaded_file(
request,
file,
file_path,
file_item,
file_metadata,
user,
db: Optional[Session] = None,
):
def _process_handler(db_session):
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if strict_match_mime_type(stt_supported_content_types, file.content_type):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata, user)
if strict_match_mime_type(
stt_supported_content_types, file.content_type
):
file_path_processed = Storage.get_file(file_path)
result = transcribe(
request, file_path_processed, file_metadata, user
)
process_file(
request,
ProcessFileForm(
file_id=file_item.id, content=result.get("text", "")
),
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(
request,
ProcessFileForm(file_id=file_item.id),
user=user,
db=db_session,
)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(
request,
ProcessFileForm(
file_id=file_item.id, content=result.get("text", "")
),
ProcessFileForm(file_id=file_item.id),
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
except Exception as e:
log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id(
file_item.id,
{
"status": "failed",
"error": str(e.detail) if hasattr(e, "detail") else str(e),
},
)
except Exception as e:
log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id(
file_item.id,
{
"status": "failed",
"error": str(e.detail) if hasattr(e, "detail") else str(e),
},
db=db_session,
)
if db:
_process_handler(db)
else:
with SessionLocal() as db_session:
_process_handler(db_session)
@router.post("/", response_model=FileModelResponse)
@@ -161,6 +203,7 @@ def upload_file(
process: bool = Query(True),
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
return upload_file_handler(
request,
@@ -170,6 +213,7 @@ def upload_file(
process_in_background=process_in_background,
user=user,
background_tasks=background_tasks,
db=db,
)
@@ -181,6 +225,7 @@ def upload_file_handler(
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None,
db: Optional[Session] = None,
):
log.info(f"file.content_type: {file.content_type} {process}")
@@ -242,20 +287,27 @@ def upload_file_handler(
},
"meta": {
"name": name,
"content_type": file.content_type,
"content_type": (
file.content_type
if isinstance(file.content_type, str)
else None
),
"size": len(contents),
"data": file_metadata,
},
}
),
db=db,
)
if "channel_id" in file_metadata:
channel = Channels.get_channel_by_id_and_user_id(
file_metadata["channel_id"], user.id
file_metadata["channel_id"], user.id, db=db
)
if channel:
Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id)
Channels.add_file_to_channel_by_id(
channel.id, file_item.id, user.id, db=db
)
if process:
if background_tasks and process_in_background:
@@ -277,6 +329,7 @@ def upload_file_handler(
file_item,
file_metadata,
user,
db=db,
)
return {"status": True, **file_item.model_dump()}
else:
@@ -288,6 +341,8 @@ def upload_file_handler(
detail=ERROR_MESSAGES.DEFAULT("Error uploading file"),
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -302,11 +357,15 @@ def upload_file_handler(
@router.get("/", response_model=list[FileModelResponse])
async def list_files(user=Depends(get_verified_user), content: bool = Query(True)):
async def list_files(
user=Depends(get_verified_user),
content: bool = Query(True),
db: Session = Depends(get_session),
):
if user.role == "admin":
files = Files.get_files()
files = Files.get_files(db=db)
else:
files = Files.get_files_by_user_id(user.id)
files = Files.get_files_by_user_id(user.id, db=db)
if not content:
for file in files:
@@ -328,34 +387,41 @@ async def search_files(
description="Filename pattern to search for. Supports wildcards such as '*.txt'",
),
content: bool = Query(True),
skip: int = Query(0, ge=0, description="Number of files to skip"),
limit: int = Query(
100, ge=1, le=1000, description="Maximum number of files to return"
),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
"""
Search for files by filename with support for wildcard patterns.
Uses SQL-based filtering with pagination for better performance.
"""
# Get files according to user role
if user.role == "admin":
files = Files.get_files()
else:
files = Files.get_files_by_user_id(user.id)
# Determine user_id: null for admin (search all), user.id for regular users
user_id = None if user.role == "admin" else user.id
# Get matching files
matching_files = [
file for file in files if fnmatch(file.filename.lower(), filename.lower())
]
# Use optimized database query with pagination
files = Files.search_files(
user_id=user_id,
filename=filename,
skip=skip,
limit=limit,
db=db,
)
if not matching_files:
if not files:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No files found matching the pattern.",
)
if not content:
for file in matching_files:
if "content" in file.data:
for file in files:
if file.data and "content" in file.data:
del file.data["content"]
return matching_files
return files
############################
@@ -364,8 +430,10 @@ async def search_files(
@router.delete("/all")
async def delete_all_files(user=Depends(get_admin_user)):
result = Files.delete_all_files()
async def delete_all_files(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
result = Files.delete_all_files(db=db)
if result:
try:
Storage.delete_all_files()
@@ -391,8 +459,10 @@ async def delete_all_files(user=Depends(get_admin_user)):
@router.get("/{id}", response_model=Optional[FileModel])
async def get_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_file_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -403,7 +473,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
return file
else:
@@ -415,9 +485,12 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/process/status")
async def get_file_process_status(
id: str, stream: bool = Query(False), user=Depends(get_verified_user)
id: str,
stream: bool = Query(False),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id)
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -428,37 +501,40 @@ async def get_file_process_status(
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
if stream:
MAX_FILE_PROCESSING_DURATION = 3600 * 2
async def event_stream(file_item):
if file_item:
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_item.id)
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
async def event_stream(file_id):
# NOTE: We intentionally do NOT capture the request's db session here.
# Each poll creates its own short-lived session to avoid holding a
# connection for hours. A WebSocket push would be more efficient.
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_id) # Creates own session
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
if status:
event = {"status": status}
if status == "failed":
event["error"] = data.get("error")
if status:
event = {"status": status}
if status == "failed":
event["error"] = data.get("error")
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
break
else:
# Legacy
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
break
else:
# Legacy
break
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
break
await asyncio.sleep(0.5)
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
await asyncio.sleep(1)
return StreamingResponse(
event_stream(file),
event_stream(file.id),
media_type="text/event-stream",
)
else:
@@ -476,8 +552,10 @@ async def get_file_process_status(
@router.get("/{id}/data/content")
async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_file_data_content_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -488,7 +566,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
return {"content": file.data.get("content", "")}
else:
@@ -508,10 +586,14 @@ class ContentForm(BaseModel):
@router.post("/{id}/data/content/update")
async def update_file_data_content_by_id(
request: Request, id: str, form_data: ContentForm, user=Depends(get_verified_user)
def update_file_data_content_by_id(
request: Request,
id: str,
form_data: ContentForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id)
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -522,7 +604,7 @@ async def update_file_data_content_by_id(
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
or has_access_to_file(id, "write", user, db=db)
):
try:
process_file(
@@ -530,7 +612,7 @@ async def update_file_data_content_by_id(
ProcessFileForm(file_id=id, content=form_data.content),
user=user,
)
file = Files.get_file_by_id(id=id)
file = Files.get_file_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
log.error(f"Error processing file: {file.id}")
@@ -550,9 +632,12 @@ async def update_file_data_content_by_id(
@router.get("/{id}/content")
async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), attachment: bool = Query(False)
id: str,
user=Depends(get_verified_user),
attachment: bool = Query(False),
db: Session = Depends(get_session),
):
file = Files.get_file_by_id(id)
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -563,7 +648,7 @@ async def get_file_content_by_id(
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
try:
file_path = Storage.get_file(file.path)
@@ -619,8 +704,10 @@ async def get_file_content_by_id(
@router.get("/{id}/content/html")
async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_html_file_content_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -628,7 +715,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
file_user = Users.get_user_by_id(file.user_id)
file_user = Users.get_user_by_id(file.user_id, db=db)
if not file_user.role == "admin":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -638,7 +725,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
try:
file_path = Storage.get_file(file.path)
@@ -668,8 +755,10 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/content/{file_name}")
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -680,7 +769,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "read", user)
or has_access_to_file(id, "read", user, db=db)
):
file_path = file.path
@@ -730,8 +819,10 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.delete("/{id}")
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
async def delete_file_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file:
raise HTTPException(
@@ -742,10 +833,27 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
if (
file.user_id == user.id
or user.role == "admin"
or has_access_to_file(id, "write", user)
or has_access_to_file(id, "write", user, db=db)
):
result = Files.delete_file_by_id(id)
# Clean up KB associations and embeddings before deleting
knowledges = Knowledges.get_knowledges_by_file_id(id, db=db)
for knowledge in knowledges:
# Remove KB-file relationship
Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db)
# Clean KB embeddings (same logic as /knowledge/{id}/file/remove)
try:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"file_id": id}
)
if file.hash:
VECTOR_DB_CLIENT.delete(
collection_name=knowledge.id, filter={"hash": file.hash}
)
except Exception as e:
log.debug(f"KB embedding cleanup for {knowledge.id}: {e}")
result = Files.delete_file_by_id(id, db=db)
if result:
try:
Storage.delete_file(file.path)
+57 -31
View File
@@ -22,6 +22,8 @@ from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
@@ -31,7 +33,6 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
@@ -44,7 +45,11 @@ router = APIRouter()
@router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(request: Request, user=Depends(get_verified_user)):
async def get_folders(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if request.app.state.config.ENABLE_FOLDERS is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -55,22 +60,23 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
user.id,
"features.folders",
request.app.state.config.USER_PERMISSIONS,
db=db,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folders = Folders.get_folders_by_user_id(user.id)
folders = Folders.get_folders_by_user_id(user.id, db=db)
# Verify folder data integrity
folder_list = []
for folder in folders:
if folder.parent_id and not Folders.get_folder_by_id_and_user_id(
folder.parent_id, user.id
folder.parent_id, user.id, db=db
):
folder = Folders.update_folder_parent_id_by_id_and_user_id(
folder.id, user.id, None
folder.id, user.id, None, db=db
)
if folder.data:
@@ -80,12 +86,12 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
if file.get("type") == "file":
if Files.check_access_by_user_id(
file.get("id"), user.id, "read"
file.get("id"), user.id, "read", db=db
):
valid_files.append(file)
elif file.get("type") == "collection":
if Knowledges.check_access_by_user_id(
file.get("id"), user.id, "read"
file.get("id"), user.id, "read", db=db
):
valid_files.append(file)
else:
@@ -93,7 +99,7 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
folder.data["files"] = valid_files
Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data)
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
)
folder_list.append(FolderNameIdResponse(**folder.model_dump()))
@@ -107,9 +113,13 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
@router.post("/")
def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
def create_folder(
form_data: FolderForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
None, user.id, form_data.name
None, user.id, form_data.name, db=db
)
if folder:
@@ -119,7 +129,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
)
try:
folder = Folders.insert_new_folder(user.id, form_data)
folder = Folders.insert_new_folder(user.id, form_data, db=db)
return folder
except Exception as e:
log.exception(e)
@@ -136,8 +146,10 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
@router.get("/{id}", response_model=Optional[FolderModel])
async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
async def get_folder_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
return folder
else:
@@ -154,15 +166,18 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update")
async def update_folder_name_by_id(
id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user)
id: str,
form_data: FolderUpdateForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
if form_data.name is not None:
# Check if folder with same name exists
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
folder.parent_id, user.id, form_data.name, db=db
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
@@ -171,7 +186,9 @@ async def update_folder_name_by_id(
)
try:
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
folder = Folders.update_folder_by_id_and_user_id(
id, user.id, form_data, db=db
)
return folder
except Exception as e:
log.exception(e)
@@ -198,12 +215,15 @@ class FolderParentIdForm(BaseModel):
@router.post("/{id}/update/parent")
async def update_folder_parent_id_by_id(
id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user)
id: str,
form_data: FolderParentIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
form_data.parent_id, user.id, folder.name
form_data.parent_id, user.id, folder.name, db=db
)
if existing_folder:
@@ -214,7 +234,7 @@ async def update_folder_parent_id_by_id(
try:
folder = Folders.update_folder_parent_id_by_id_and_user_id(
id, user.id, form_data.parent_id
id, user.id, form_data.parent_id, db=db
)
return folder
except Exception as e:
@@ -242,13 +262,16 @@ class FolderIsExpandedForm(BaseModel):
@router.post("/{id}/update/expanded")
async def update_folder_is_expanded_by_id(
id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user)
id: str,
form_data: FolderIsExpandedForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder:
try:
folder = Folders.update_folder_is_expanded_by_id_and_user_id(
id, user.id, form_data.is_expanded
id, user.id, form_data.is_expanded, db=db
)
return folder
except Exception as e:
@@ -276,10 +299,11 @@ async def delete_folder_by_id(
id: str,
delete_contents: Optional[bool] = True,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
if Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db):
chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS, db=db
)
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
@@ -288,19 +312,21 @@ async def delete_folder_by_id(
)
folders = []
folders.append(Folders.get_folder_by_id_and_user_id(id, user.id))
folders.append(Folders.get_folder_by_id_and_user_id(id, user.id, db=db))
while folders:
folder = folders.pop()
if folder:
try:
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db)
for folder_id in folder_ids:
if delete_contents:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
Chats.delete_chats_by_user_id_and_folder_id(
user.id, folder_id, db=db
)
else:
Chats.move_chats_by_user_id_and_folder_id(
user.id, folder_id, None
user.id, folder_id, None, db=db
)
return True
@@ -314,7 +340,7 @@ async def delete_folder_by_id(
finally:
# Get all subfolders
subfolders = Folders.get_folders_by_parent_id_and_user_id(
folder.id, user.id
folder.id, user.id, db=db
)
folders.extend(subfolders)
+107 -45
View File
@@ -6,6 +6,7 @@ import aiohttp
from pathlib import Path
from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.functions import (
FunctionForm,
FunctionModel,
@@ -18,13 +19,15 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
replace_imports,
get_function_module_from_cache,
resolve_valves_schema_options,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel, HttpUrl
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
@@ -37,13 +40,17 @@ router = APIRouter()
@router.get("/", response_model=list[FunctionResponse])
async def get_functions(user=Depends(get_verified_user)):
return Functions.get_functions()
async def get_functions(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return Functions.get_functions(db=db)
@router.get("/list", response_model=list[FunctionUserResponse])
async def get_function_list(user=Depends(get_admin_user)):
return Functions.get_function_list()
async def get_function_list(
user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Functions.get_function_list(db=db)
############################
@@ -52,8 +59,12 @@ async def get_function_list(user=Depends(get_admin_user)):
@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
return Functions.get_functions(include_valves=include_valves)
async def get_functions(
include_valves: bool = False,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
return Functions.get_functions(include_valves=include_valves, db=db)
############################
@@ -110,7 +121,9 @@ async def load_function_from_url(
)
try:
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.get(
url, headers={"Content-Type": "application/json"}
) as resp:
@@ -142,7 +155,10 @@ class SyncFunctionsForm(BaseModel):
@router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
request: Request,
form_data: SyncFunctionsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
for function in form_data.functions:
@@ -164,7 +180,7 @@ async def sync_functions(
)
raise e
return Functions.sync_functions(user.id, form_data.functions)
return Functions.sync_functions(user.id, form_data.functions, db=db)
except Exception as e:
log.exception(f"Failed to load a function: {e}")
raise HTTPException(
@@ -180,7 +196,10 @@ async def sync_functions(
@router.post("/create", response_model=Optional[FunctionResponse])
async def create_new_function(
request: Request, form_data: FunctionForm, user=Depends(get_admin_user)
request: Request,
form_data: FunctionForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
if not form_data.id.isidentifier():
raise HTTPException(
@@ -190,7 +209,7 @@ async def create_new_function(
form_data.id = form_data.id.lower()
function = Functions.get_function_by_id(form_data.id)
function = Functions.get_function_by_id(form_data.id, db=db)
if function is None:
try:
form_data.content = replace_imports(form_data.content)
@@ -203,13 +222,17 @@ async def create_new_function(
FUNCTIONS = request.app.state.FUNCTIONS
FUNCTIONS[form_data.id] = function_module
function = Functions.insert_new_function(user.id, function_type, form_data)
function = Functions.insert_new_function(
user.id, function_type, form_data, db=db
)
function_cache_dir = CACHE_DIR / "functions" / form_data.id
function_cache_dir.mkdir(parents=True, exist_ok=True)
if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True})
Functions.update_function_metadata_by_id(
form_data.id, {"toggle": True}, db=db
)
if function:
return function
@@ -237,8 +260,10 @@ async def create_new_function(
@router.get("/id/{id}", response_model=Optional[FunctionModel])
async def get_function_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def get_function_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
return function
@@ -255,11 +280,13 @@ async def get_function_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/toggle", response_model=Optional[FunctionModel])
async def toggle_function_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def toggle_function_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
function = Functions.update_function_by_id(
id, {"is_active": not function.is_active}
id, {"is_active": not function.is_active}, db=db
)
if function:
@@ -282,11 +309,13 @@ async def toggle_function_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/toggle/global", response_model=Optional[FunctionModel])
async def toggle_global_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def toggle_global_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
function = Functions.update_function_by_id(
id, {"is_global": not function.is_global}
id, {"is_global": not function.is_global}, db=db
)
if function:
@@ -310,7 +339,11 @@ async def toggle_global_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/update", response_model=Optional[FunctionModel])
async def update_function_by_id(
request: Request, id: str, form_data: FunctionForm, user=Depends(get_admin_user)
request: Request,
id: str,
form_data: FunctionForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try:
form_data.content = replace_imports(form_data.content)
@@ -325,10 +358,10 @@ async def update_function_by_id(
updated = {**form_data.model_dump(exclude={"id"}), "type": function_type}
log.debug(updated)
function = Functions.update_function_by_id(id, updated)
function = Functions.update_function_by_id(id, updated, db=db)
if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True})
Functions.update_function_metadata_by_id(id, {"toggle": True}, db=db)
if function:
return function
@@ -352,9 +385,12 @@ async def update_function_by_id(
@router.delete("/id/{id}/delete", response_model=bool)
async def delete_function_by_id(
request: Request, id: str, user=Depends(get_admin_user)
request: Request,
id: str,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
result = Functions.delete_function_by_id(id)
result = Functions.delete_function_by_id(id, db=db)
if result:
FUNCTIONS = request.app.state.FUNCTIONS
@@ -370,11 +406,13 @@ async def delete_function_by_id(
@router.get("/id/{id}/valves", response_model=Optional[dict])
async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)):
function = Functions.get_function_by_id(id)
async def get_function_valves_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
try:
valves = Functions.get_function_valves_by_id(id)
valves = Functions.get_function_valves_by_id(id, db=db)
return valves
except Exception as e:
raise HTTPException(
@@ -395,9 +433,12 @@ async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)):
@router.get("/id/{id}/valves/spec", response_model=Optional[dict])
async def get_function_valves_spec_by_id(
request: Request, id: str, user=Depends(get_admin_user)
request: Request,
id: str,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
@@ -405,7 +446,10 @@ async def get_function_valves_spec_by_id(
if hasattr(function_module, "Valves"):
Valves = function_module.Valves
return Valves.schema()
schema = Valves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(Valves, schema, user)
return schema
return None
else:
raise HTTPException(
@@ -421,9 +465,13 @@ async def get_function_valves_spec_by_id(
@router.post("/id/{id}/valves/update", response_model=Optional[dict])
async def update_function_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_admin_user)
request: Request,
id: str,
form_data: dict,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
@@ -437,7 +485,7 @@ async def update_function_valves_by_id(
valves = Valves(**form_data)
valves_dict = valves.model_dump(exclude_unset=True)
Functions.update_function_valves_by_id(id, valves_dict)
Functions.update_function_valves_by_id(id, valves_dict, db=db)
return valves_dict
except Exception as e:
log.exception(f"Error updating function values by id {id}: {e}")
@@ -464,11 +512,15 @@ async def update_function_valves_by_id(
@router.get("/id/{id}/valves/user", response_model=Optional[dict])
async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user)):
function = Functions.get_function_by_id(id)
async def get_function_user_valves_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function:
try:
user_valves = Functions.get_user_valves_by_id_and_user_id(id, user.id)
user_valves = Functions.get_user_valves_by_id_and_user_id(
id, user.id, db=db
)
return user_valves
except Exception as e:
raise HTTPException(
@@ -484,9 +536,12 @@ async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user
@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict])
async def get_function_user_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user)
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
request, id
@@ -494,7 +549,10 @@ async def get_function_user_valves_spec_by_id(
if hasattr(function_module, "UserValves"):
UserValves = function_module.UserValves
return UserValves.schema()
schema = UserValves.schema()
# Resolve dynamic options for select dropdowns
schema = resolve_valves_schema_options(UserValves, schema, user)
return schema
return None
else:
raise HTTPException(
@@ -505,9 +563,13 @@ async def get_function_user_valves_spec_by_id(
@router.post("/id/{id}/valves/user/update", response_model=Optional[dict])
async def update_function_user_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user)
request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
function = Functions.get_function_by_id(id)
function = Functions.get_function_by_id(id, db=db)
if function:
function_module, function_type, frontmatter = get_function_module_from_cache(
@@ -522,7 +584,7 @@ async def update_function_user_valves_by_id(
user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Functions.update_user_valves_by_id_and_user_id(
id, user.id, user_valves_dict
id, user.id, user_valves_dict, db=db
)
return user_valves_dict
except Exception as e:

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