Compare commits

...
760 Commits
Author SHA1 Message Date
+11 adc9076d17 0.9.3 (#24482)
* refac

* refac

* refac

* Merge pull request #24356 from Classic298/patch-1

doc/chore: Update SECURITY.md

* refac

* refac

* refac

* refac

* refac

* chore: Update SECURITY.md (#24363)

* Update SECURITY.md

* Update SECURITY.md

* Implement asynchronous database ping for health checks (#24380)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix: prevent STT from blocking the uvicorn event loop (#24338)

The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169

* refac

* fix: open file content in new window when clicking file name in FileItemModal (#24125)

Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379)

* Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370)

* refac

* refac

* refac

* refac

* refac

* fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)

Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206

* refac

* refac

* refac

* refac

* refac

* refac

* Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384)

* refac

* refac

* refac

* refac

* refac

* refac

* refac: apply DOMPurify to excel and office HTML render assignments (#24468)

* I18n/improve chinese translation (#24194)

* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)

get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

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

* refac

* refac

* Update catalan translation.json (#24174)

* perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)

Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

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

* refac

* refac

* i18n: Add Tagalog (Filipino) translation (#24254)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* style(env): satisfy ruff (datetime alias, line length, identity check) (#24118)

* Korean Translation Update (#24087)

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: brave search llm context

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)

asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>

* fix:image url validation and signout post (#24420)

* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* chore: format

* chore: changelog (#24358)

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* doc: changelog

* refac

* refac

* refac

* chore: format

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: Athanasios Oikonomou <athoik@gmail.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Vincent Agra <agravj007@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Shamil <ashm.tech@proton.me>
Co-authored-by: Cyp <cypher9715@naver.com>
Co-authored-by: looselyhuman <fieldian@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
2026-05-09 03:17:07 -04:00
Timothy Jaeryang Baek 413dcae8a2 refac 2026-05-09 16:11:19 +09:00
Timothy Jaeryang Baek d34d4297ba chore: format 2026-05-09 16:08:22 +09:00
Timothy Jaeryang Baek 6116c6dca0 refac 2026-05-09 16:06:09 +09:00
Timothy Jaeryang Baek 93931efaa7 refac 2026-05-09 16:05:21 +09:00
Timothy Jaeryang Baek 3ccf263b10 refac 2026-05-09 15:46:33 +09:00
Timothy Jaeryang Baek 75e72ea2f9 doc: changelog 2026-05-09 15:41:58 +09:00
Classic298 b94aad2895 chore: changelog (#24358)
* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog
2026-05-09 15:26:04 +09:00
Timothy Jaeryang Baek 7bcc0e2e5c chore: format 2026-05-09 15:25:27 +09:00
Timothy Jaeryang Baek 46ff3abbb8 refac 2026-05-09 15:23:00 +09:00
Timothy Jaeryang Baek aa51ce482c refac 2026-05-09 15:21:31 +09:00
Timothy Jaeryang Baek 251b80ebec refac 2026-05-09 15:14:32 +09:00
Timothy Jaeryang Baek 4d99baa292 refac 2026-05-09 15:04:09 +09:00
Timothy Jaeryang Baek 3fcad2f627 refac 2026-05-09 08:28:29 +09:00
Timothy Jaeryang Baek 04bd0425ea refac 2026-05-09 07:56:58 +09:00
Timothy Jaeryang Baek 485d689cfd refac 2026-05-09 07:52:15 +09:00
Timothy Jaeryang Baek 85c7373f68 refac 2026-05-09 07:37:53 +09:00
Timothy Jaeryang Baek 11e076817a refac 2026-05-09 07:34:46 +09:00
Classic298 cfd2888545 fix:image url validation and signout post (#24420)
* refac(routers): reject external URLs in profile/model image handlers

* refac(ui): centralize image URL validation in safeImageUrl helper

* refac(auths): make signout POST-only

* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING

Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).

Existing deployments that rely on external profile image forwarding
continue to work unchanged.  Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.
2026-05-09 07:33:31 +09:00
+5 adda20509c fix(mcp): remove asyncio.wait_for/shield from MCP cleanup in chat handler (#24105)
asyncio.wait_for() and asyncio.shield() create new asyncio Tasks which
violate anyio cancel-scope task-ownership rules. The MCPClient's
exit_stack contains anyio resources (streamable_http transport) that
use anyio cancel scopes. When exited from a different task, anyio raises
'Attempted to exit a cancel scope that isn't the current task's current
cancel scope' as a BaseException.

This BaseException propagates through the finally block, discards the
completed response return value, and surfaces as a 500 Internal Server
Error / 'No response returned.' - silently swallowing successful MCP
tool calls and blocking the chat endpoint.

Fix: call client.disconnect() directly in a simple loop. MCPClient.disconnect()
already catches BaseException internally (see prior commit), so no
wrapper is needed.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
Co-authored-by: Circe (Claude Code Sonnet 4.6) <circe@athena-council.org>
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 07:15:24 +09:00
Timothy Jaeryang Baek e1dce99147 refac 2026-05-09 07:13:36 +09:00
Timothy Jaeryang Baek a938c8ae2e refac 2026-05-09 07:11:17 +09:00
Timothy Jaeryang Baek 5b80932e59 refac 2026-05-09 06:56:22 +09:00
Timothy Jaeryang Baek 2ba6b423aa refac 2026-05-09 06:50:11 +09:00
Timothy Jaeryang Baek 02f9fe7890 refac 2026-05-09 06:49:41 +09:00
Timothy Jaeryang Baek 29f6c72e87 refac 2026-05-09 06:44:42 +09:00
Timothy Jaeryang Baek bb0e6cb108 refac 2026-05-09 06:41:42 +09:00
Timothy Jaeryang Baek 6700f7bb72 feat: brave search llm context 2026-05-09 06:34:25 +09:00
Timothy Jaeryang Baek 1baf73bdd5 refac 2026-05-09 06:34:03 +09:00
Timothy Jaeryang Baek 1d892ce2c5 refac 2026-05-09 06:33:26 +09:00
Timothy Jaeryang Baek 794b97025d refac 2026-05-09 06:32:34 +09:00
Timothy Jaeryang Baek ee3b82926b refac 2026-05-09 06:25:38 +09:00
Timothy Jaeryang Baek 38a382ef88 refac 2026-05-09 06:23:51 +09:00
Timothy Jaeryang Baek 34146ab60f refac 2026-05-09 06:20:27 +09:00
Timothy Jaeryang Baek f70b0da156 refac 2026-05-09 06:16:27 +09:00
Timothy Jaeryang Baek af5628f8ef refac 2026-05-09 06:13:58 +09:00
Timothy Jaeryang Baek 9907c0a25a refac 2026-05-09 06:01:02 +09:00
+3 d78c247036 Korean Translation Update (#24087)
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-05-09 05:31:49 +09:00
Shamil ae0827cec0 style(env): satisfy ruff (datetime alias, line length, identity check) (#24118) 2026-05-09 05:30:09 +09:00
Timothy Jaeryang Baek 064fdecb67 refac 2026-05-09 05:29:15 +09:00
Timothy Jaeryang Baek bf4f44ee9c refac 2026-05-09 05:27:47 +09:00
Timothy Jaeryang Baek 212bb68a66 refac 2026-05-09 05:27:32 +09:00
+3 aff78e4958 i18n: Add Tagalog (Filipino) translation (#24254)
Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-05-09 05:25:44 +09:00
Timothy Jaeryang Baek ae43562b86 refac 2026-05-09 05:24:50 +09:00
Timothy Jaeryang Baek 13693554f1 refac 2026-05-09 05:20:28 +09:00
Classic298andClaude 1a3e5ef4c1 perf(prompts): make /tags fetch only the tags column with SQL access filter (#24287)
Non-admin GET /api/v1/prompts/tags went through get_prompts_by_user_id,
which loaded every active prompt with its full content/data/meta plus
owner records and all access grants, then ran one has_access query per
prompt that wasn't owned by the caller - all so the endpoint could
collapse the result to a sorted tag list. With 600 prompts this took
several seconds while the admin path (a single SELECT) returned in <1s.

Add Prompts.get_tags_by_user_id which selects only the tags column and
applies the same EXISTS-based access filter used by /list. Also tighten
the admin get_tags to project just the tags column instead of full rows.
The endpoint is now one DB query (plus one for groups), no row hydration,
no N+1.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 05:20:13 +09:00
Aleix Dorca 26b1a3d7dc Update catalan translation.json (#24174) 2026-05-09 05:17:48 +09:00
Timothy Jaeryang Baek 3ab7b777b1 refac 2026-05-09 05:15:12 +09:00
Timothy Jaeryang Baek 1789303886 refac 2026-05-09 05:14:55 +09:00
Classic298andClaude 41107a34ca perf(prompts): filter prompt list in SQL instead of N+1 has_access loop (#24288)
get_prompts_by_user_id used to fetch every active prompt (with users +
all access grants), then call AccessGrants.has_access() once per prompt
that the user did not own. With 600+ prompts this issued ~600 extra
round-trips per request and explained the multi-second delay reported in
the GET /api/v1/prompts and /api/v1/prompts/tags endpoints for non-admin
users.

Push the access check into a single SQL query via the existing
AccessGrants.has_permission_filter (EXISTS subquery), so only accessible
rows come back from the DB. Users and access grants for the surviving
rows are still batch-fetched, no N+1 anywhere on this path.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-09 05:12:51 +09:00
Shirasawa 114c99ae2f I18n/improve chinese translation (#24194)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-05-09 05:11:48 +09:00
Classic298 3746339cfc refac: apply DOMPurify to excel and office HTML render assignments (#24468) 2026-05-09 05:10:31 +09:00
Timothy Jaeryang Baek 7eeff2fdf9 refac 2026-05-09 05:09:20 +09:00
Timothy Jaeryang Baek 55e7c7854b refac 2026-05-09 05:04:51 +09:00
Timothy Jaeryang Baek c978a788c8 refac 2026-05-09 05:03:38 +09:00
Timothy Jaeryang Baek 3d48596c9e refac 2026-05-09 04:56:25 +09:00
Timothy Jaeryang Baek 072d2000f3 refac 2026-05-09 04:53:47 +09:00
Timothy Jaeryang Baek 9386fc83a3 refac 2026-05-09 04:49:18 +09:00
Jacob Leksan b63da90ae4 Enhance CommitSessionMiddleware to allow health probes to bypass session management, ensuring faster and more reliable responses. (#24384) 2026-05-09 04:46:00 +09:00
Timothy Jaeryang Baek 23ff9943a9 refac 2026-05-09 04:44:20 +09:00
Timothy Jaeryang Baek 33e588cf09 refac 2026-05-09 04:39:44 +09:00
Timothy Jaeryang Baek 005df577fe refac 2026-05-09 04:36:43 +09:00
Timothy Jaeryang Baek 1b4cd705d0 refac 2026-05-09 04:36:23 +09:00
Timothy Jaeryang Baek 8ffc3d746f refac 2026-05-09 04:22:46 +09:00
Timothy Jaeryang Baek c1202a2327 refac 2026-05-09 04:17:58 +09:00
Classic298 55d1db1f38 fix: stream GET /chats/all to prevent OOM on large chat histories (#24461)
Convert the /chats/all endpoint from loading all user chats into memory
at once to a streaming NDJSON response that fetches chats in batches of
100. This prevents Out-of-Memory crashes for users with large chat
histories.

Backend: Added async generator that paginates through chats with
short-lived DB sessions per batch (critical for SQLite lock release).

Frontend: Updated getAllChats to consume the NDJSON stream via
ReadableStream reader, accumulating results for the export file.

Ref: open-webui#22206
2026-05-09 04:11:52 +09:00
Timothy Jaeryang Baek 6082e1adae refac 2026-05-09 04:03:49 +09:00
Timothy Jaeryang Baek c6763521c0 refac 2026-05-09 03:46:08 +09:00
Timothy Jaeryang Baek 7c398a625a refac 2026-05-09 03:45:56 +09:00
Timothy Jaeryang Baek cdfcbc4af6 refac 2026-05-09 03:40:23 +09:00
Timothy Jaeryang Baek 4d766a3edf refac 2026-05-09 03:19:48 +09:00
Classic298 d06e6d6ddc Apply validate_profile_image_url to ChannelWebhookForm.profile_image_url (#24370) 2026-05-09 03:19:25 +09:00
Jacob Leksan 8b78821ba4 Refactor file processing to use asyncio for transcribing, improving concurrency. (#24379) 2026-05-09 03:17:47 +09:00
Timothy Jaeryang Baek 552bbcecfa refac 2026-05-09 03:15:53 +09:00
Timothy Jaeryang Baek f152ad36b3 refac 2026-05-09 03:06:19 +09:00
Timothy Jaeryang Baek 60ea4214aa refac 2026-05-09 02:58:17 +09:00
Timothy Jaeryang Baek bc4d6eef33 refac 2026-05-09 02:56:15 +09:00
Timothy Jaeryang Baek cde72dab71 refac 2026-05-09 02:54:09 +09:00
Timothy Jaeryang Baek ff791b4814 refac 2026-05-09 02:43:07 +09:00
Timothy Jaeryang Baek 7eaecbad5a refac 2026-05-09 02:38:08 +09:00
Timothy Jaeryang Baek 0103d7e82c refac 2026-05-09 02:31:30 +09:00
Timothy Jaeryang Baek 3309f5d9f1 refac 2026-05-09 02:25:26 +09:00
Athanasios Oikonomou e451f8f63b fix: open file content in new window when clicking file name in FileItemModal (#24125)
Previously, clicking the file name link did not open the file content
because the condition checked `!isPDF && item.url`, which failed for
`type === 'file'` items that use an ID-based URL path.

Update the condition to trigger on `item.type === 'file' || item.url`,
and resolve the correct URL by extracting `fileId` from `item.id` or
`item.tempId` instead of using `item.url` directly as the file
identifier.
2026-05-09 02:09:35 +09:00
Timothy Jaeryang Baek 6dff85b9d2 refac 2026-05-09 02:08:08 +09:00
Classic298 7e275c1daa fix: prevent STT from blocking the uvicorn event loop (#24338)
The transcription endpoint was async but called the synchronous transcribe() function directly, blocking the single-threaded uvicorn event loop for the entire duration of inference. This caused all HTTP and WebSocket connections to stall for every user on the instance during STT processing.

- Add asyncio import

- Use async UploadFile.read() instead of synchronous file.file.read()

- Offload the blocking transcribe() call via asyncio.to_thread()

Closes #24169
2026-05-09 02:05:28 +09:00
Timothy Jaeryang Baek 1c1c8b18e5 refac 2026-05-09 02:04:36 +09:00
Timothy Jaeryang Baek 55a572cd39 refac 2026-05-09 02:04:26 +09:00
Timothy Jaeryang Baek 9adc0c442a refac 2026-05-09 02:02:02 +09:00
Timothy Jaeryang Baek fd3368c0bf refac 2026-05-09 01:55:51 +09:00
Timothy Jaeryang Baek ef6d4f2d6c refac 2026-05-09 01:50:58 +09:00
Timothy Jaeryang Baek 6bdc2ffa79 refac 2026-05-09 01:31:42 +09:00
Timothy Jaeryang Baek b72019db39 refac 2026-05-09 01:31:04 +09:00
Timothy Jaeryang Baek 2977910ffd refac 2026-05-09 01:25:01 +09:00
Timothy Jaeryang Baek f39f4a86ae refac 2026-05-09 01:22:25 +09:00
Timothy Jaeryang Baek 1dee67b64d refac 2026-05-09 01:21:17 +09:00
Jacob Leksan 2a18dc98ac Implement asynchronous database ping for health checks (#24380) 2026-05-09 01:20:11 +09:00
Classic298 1f977d072e chore: Update SECURITY.md (#24363)
* Update SECURITY.md

* Update SECURITY.md
2026-05-09 01:19:30 +09:00
Timothy Jaeryang Baek 4754ece4a2 refac 2026-05-09 01:17:57 +09:00
Timothy Jaeryang Baek 5c3edc2539 refac 2026-05-09 01:17:33 +09:00
Timothy Jaeryang Baek 4fe2de7864 refac 2026-05-09 01:13:16 +09:00
Timothy Jaeryang Baek a32d26e61d refac 2026-05-05 04:21:23 +09:00
Timothy Jaeryang Baek 989d5fd4e2 refac 2026-05-05 04:05:15 +09:00
Classic298 4e6a7baab7 Merge pull request #24356 from Classic298/patch-1
doc/chore: Update SECURITY.md
2026-05-05 03:45:57 +09:00
Timothy Jaeryang Baek cde21b9f6d refac 2026-05-05 03:33:47 +09:00
Timothy Jaeryang Baek 86df8bf27e refac 2026-05-05 02:41:22 +09:00
Timothy Jaeryang Baek 5bc80b145f refac 2026-05-04 23:57:20 +09:00
+2 8dae237a0b 0.9.2 (#24081)
* refac

* fix: remove reactive label from onDestroy in Markdown

* Update fi-FI translation.json (#24010)

Added missing translations.

* refac

* i18n: update ko-KR translations (conflict solved) (#23949)

* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation

* i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)

New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.

* fix(utils): Switch throttle decorator to async (#23979)

After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>

* refac

* refac

* refac

* refac

* feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>

* refac

* refac

* Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011)

* feat(ui): add citation source overflow badge (#23918)

* refac

* i18n: enhance and expand Dutch language translations (#23944)

* refac

* refac

* refac

* perf: redirect default model profile image to canonical static URL (#24015)

- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009)

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* refac

* changelog (#24072)

* refactor(firecrawl): use v2 API directly (#23934)

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

* chore: bump

* perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)

The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.

* refac

* chore: format

* chore: i18n

* refac

---------

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
Co-authored-by: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com>
Co-authored-by: Kylapaallikko <Kylapaallikko@users.noreply.github.com>
Co-authored-by: Teay <pythontogoplease@gmail.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: tcx4c70 <tcx4c70@gmail.com>
Co-authored-by: goodbey857 <76645482+goodbey857@users.noreply.github.com>
Co-authored-by: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
Co-authored-by: RomualdYT <romuald@gameurnews.fr>
Co-authored-by: Lucas <lucas@vanosenbruggen.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: Constantine <Runixer@gmail.com>
2026-04-24 05:56:03 -04:00
Timothy Jaeryang Baek 4e2240aada refac 2026-04-24 18:55:39 +09:00
Timothy Jaeryang Baek f93d20ac42 chore: i18n 2026-04-24 18:48:45 +09:00
Timothy Jaeryang Baek 8ff7ff459b chore: format 2026-04-24 18:48:21 +09:00
Timothy Jaeryang Baek f48b8ffbf0 refac 2026-04-24 18:38:57 +09:00
Constantine 3560d2f630 perf(chats): drop redundant db.refresh after commit in update_chat_by_id (#24024)
The chat table has no computed columns (no DEFAULT, SERIAL/IDENTITY,
or TRIGGER that populate server-side values on UPDATE), and every
column modified by update_chat_by_id is set explicitly from Python
values earlier in the function. db.refresh therefore issues a SELECT
that replaces those just-written Python values with the round-tripped
database representation of the same values, which is a no-op for
functional purposes but pulls the entire chat.chat JSON blob back over
the network and through the driver's JSON decoder.

On large, active chats where chat.chat can reach tens of megabytes,
skipping the refresh measurably reduces latency and eliminates one
~JSON-sized transient allocation per write.
2026-04-24 18:34:57 +09:00
Timothy Jaeryang Baek 3aeb691d98 chore: bump 2026-04-24 18:33:27 +09:00
RomualdYTandTim Baek e0d6074cd2 refactor(firecrawl): use v2 API directly (#23934)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-04-24 18:32:08 +09:00
Classic298 b1bd3084f0 changelog (#24072) 2026-04-24 18:30:51 +09:00
Timothy Jaeryang Baek 70b28b629e refac 2026-04-24 18:29:39 +09:00
Timothy Jaeryang Baek 3d1e355df7 refac 2026-04-24 18:20:10 +09:00
Timothy Jaeryang Baek 7102a63c82 refac 2026-04-24 18:06:19 +09:00
Timothy Jaeryang Baek 1cea8ec7d4 refac 2026-04-24 17:59:45 +09:00
Timothy Jaeryang Baek a7a92d2d9b refac 2026-04-24 17:49:22 +09:00
Timothy Jaeryang Baek 2419899ac6 refac 2026-04-24 17:34:12 +09:00
Timothy Jaeryang Baek 60f67c7c17 refac 2026-04-24 17:07:23 +09:00
Timothy Jaeryang Baek 34a55d4524 refac 2026-04-24 17:06:36 +09:00
Timothy Jaeryang Baek 9771898c58 refac 2026-04-24 17:04:47 +09:00
Timothy Jaeryang Baek 752238247c refac 2026-04-24 16:47:30 +09:00
Timothy Jaeryang Baek 3e14524154 refac 2026-04-24 16:39:44 +09:00
Timothy Jaeryang Baek d8b55afb00 refac 2026-04-24 16:37:02 +09:00
Timothy Jaeryang Baek 62693938a3 refac 2026-04-24 16:36:07 +09:00
Jacob Leksan 465d6fe514 feat: enhance RichTextInput configuration to prevent duplicate extensions when rich text is enabled (#24009) 2026-04-24 16:33:46 +09:00
Timothy Jaeryang Baek d6b73ea2f2 refac 2026-04-24 16:31:02 +09:00
Timothy Jaeryang Baek 5774ab4984 refac 2026-04-24 16:26:34 +09:00
Timothy Jaeryang Baek db05fdaf83 refac 2026-04-24 16:23:28 +09:00
Timothy Jaeryang Baek d740b545a4 refac 2026-04-24 16:21:37 +09:00
Timothy Jaeryang Baek 678c44c7cd refac 2026-04-24 16:17:46 +09:00
Timothy Jaeryang Baek 5cc55e2278 refac 2026-04-24 15:51:54 +09:00
Timothy Jaeryang Baek 26711c1bcc refac 2026-04-24 15:46:08 +09:00
Jacob Leksan f2cb63140c perf: redirect default model profile image to canonical static URL (#24015)
- Return 302 to /static/favicon.png instead of streaming the same PNG per
  model id so browsers can cache one asset for default avatars.
- Validate stored /static/ paths with decode, normpath, and /static
  prefix checks; invalid paths fall back to favicon.

Made-with: Cursor
2026-04-24 15:45:10 +09:00
Timothy Jaeryang Baek a76a779c01 refac 2026-04-24 15:40:02 +09:00
Timothy Jaeryang Baek a766521933 refac 2026-04-24 15:39:12 +09:00
Timothy Jaeryang Baek 7da6b82471 refac 2026-04-24 15:35:59 +09:00
Lucas 6089de0b17 i18n: enhance and expand Dutch language translations (#23944) 2026-04-24 15:30:06 +09:00
Timothy Jaeryang Baek b87c755574 refac 2026-04-24 15:29:36 +09:00
RomualdYT b73538ece7 feat(ui): add citation source overflow badge (#23918) 2026-04-24 15:26:53 +09:00
Jacob Leksan 258e9f917b Enhance image loading performance by adding preload links and setting loading attributes for logos in app.html (#24011) 2026-04-24 15:25:54 +09:00
Timothy Jaeryang Baek 6ecba19447 refac 2026-04-24 15:21:52 +09:00
Timothy Jaeryang Baek 90584ab6f3 refac 2026-04-24 15:21:37 +09:00
58bc254809 feat: add PaddleOCR-vl loader support and implement retrieval router infrastructure (#23945)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-04-24 15:19:37 +09:00
Timothy Jaeryang Baek 0e311a95a7 refac 2026-04-24 15:16:37 +09:00
Timothy Jaeryang Baek d0e51bde5d refac 2026-04-24 15:03:29 +09:00
Timothy Jaeryang Baek 4dc5c1eb4f refac 2026-04-24 15:00:47 +09:00
Timothy Jaeryang Baek 89669f3fa1 refac 2026-04-24 14:40:17 +09:00
tcx4c70 f6bd08c852 fix(utils): Switch throttle decorator to async (#23979)
After migration to async db operations, the throttle decorator also
needs to support async. Since the decorator is only used for async funcs
now, we can just switch it to async instead of supporting sync and async
at the same time.

Signed-off-by: Adam Tao <tcx4c70@gmail.com>
2026-04-24 14:39:45 +09:00
joaoback 9b577868c8 i18n: add pt-BR translations for newly added UI items and consistency pass (#23954)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-04-24 14:38:33 +09:00
Teay 91d9870266 i18n: update ko-KR translations (conflict solved) (#23949)
* i18n: update ko-KR translations

* i18n: fix missing ko-KR translations and reviewed pr-bot recommendation
2026-04-24 14:38:12 +09:00
Timothy Jaeryang Baek d47993385a refac 2026-04-24 14:35:15 +09:00
Kylapaallikko a4eb10269e Update fi-FI translation.json (#24010)
Added missing translations.
2026-04-24 14:33:06 +09:00
Tim Baek ef77c3d45b Merge pull request #24048 from Algorithm5838/fix/markdown-ondestroy-reactive
fix: remove reactive label from onDestroy in Markdown
2026-04-24 01:32:43 -04:00
Algorithm5838 83f3a9c543 fix: remove reactive label from onDestroy in Markdown 2026-04-23 22:26:11 +03:00
Timothy Jaeryang Baek d56d74b387 refac 2026-04-23 19:39:06 +09:00
Tim Baek 0a8a620fb6 Merge pull request #23925 from open-webui/dev
0.9.1
2026-04-21 06:45:24 -04:00
Tim Baek f162d4de90 doc 2026-04-21 19:39:44 +09:00
Tim Baek 9f61a6f13c fix 2026-04-21 19:37:22 +09:00
Tim Baek f31768e20e Merge pull request #23318 from open-webui/dev
0.9.0
2026-04-21 03:56:01 -04:00
Timothy Jaeryang Baek 493f238431 refac 2026-04-21 16:46:02 +09:00
Timothy Jaeryang Baek 3b821e1f3a refac 2026-04-21 16:32:17 +09:00
Timothy Jaeryang Baek 085d3cb1c9 refac 2026-04-21 16:16:48 +09:00
Timothy Jaeryang Baek 116eb7fc55 refac 2026-04-21 16:05:26 +09:00
Timothy Jaeryang Baek 65f55847a1 refac 2026-04-21 16:04:48 +09:00
Timothy Jaeryang Baek 0542df147a refac 2026-04-21 15:52:33 +09:00
Timothy Jaeryang Baek 6cc799b1bb chore: format 2026-04-21 15:52:00 +09:00
Timothy Jaeryang Baek b9fc3f367a refac 2026-04-21 15:47:32 +09:00
Timothy Jaeryang Baek c7b6de6ca4 refac 2026-04-21 15:41:07 +09:00
Timothy Jaeryang Baek 5f76c250f8 refac 2026-04-21 15:20:28 +09:00
Classic298 0e3135f8dc chore: changelog (#23187)
* chore: add changelog entry for v0.8.13

* changelog: task management, admin model deletion

* changelog: emoji, shortcode, input

* changelog: swipe-to-reply mobile gesture

* changelog: emoji, recently-used, picker

* changelog: files, chat-input, attachments

* changelog: terminal session tracking, task list visibility

* changelog: move terminal session tracking to Added section

* changelog: performance, shared chat deletion

* changelog: user activity tracking, shared chat deletion optimizations

* changelog: add Russian translation entry

* changelog: MCP tool server timeout configuration

* changelog: image viewer memory optimization

* changelog: error message persistence during streaming

* changelog: codespan, animation, streaming

* changelog: streaming, performance, yield

* changelog: text, animation, streaming

* changelog: websearch, settings, fix

* changelog: automation, scheduling, workflows

* changelog: automations, permissions, access

* changelog: automations, editor, logs

* changelog: german, completion, tokens

* changelog: streaming, entities, defaults

* changelog: pyodide, cache, prompt

* changelog: details, expansion, settings

* changelog: unread, sidebar, automations

* changelog: oauth, gravatar, prompts

* changelog: wake-lock, writing, retrieval

* changelog: mcp, sidebar, usage

* changelog: oauth, citations, sidebar

* changelog: oauth, cookies, tools

* changelog: translations, tamil, localization

* changelog: tasks, fallback, stability

* changelog: title, query, performance

* changelog: sidebar, archived, menu

* changelog: input, drafts, uploads

* changelog: notes, permissions, security

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog date

* reorder changelog entries

* restore changelog ordering

* restore changelog

* changelog updates

* adjust changelog ordering

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* Update CHANGELOG.md

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* Update CHANGELOG.md

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog

* changelog
2026-04-21 15:18:33 +09:00
Timothy Jaeryang Baek 7fd94b0e73 refac 2026-04-21 15:15:00 +09:00
Timothy Jaeryang Baek c4aac0415c refac 2026-04-21 14:58:28 +09:00
Timothy Jaeryang Baek a27916d1db refac 2026-04-21 14:31:04 +09:00
Timothy Jaeryang Baek f485309fd6 refac 2026-04-21 13:57:43 +09:00
Timothy Jaeryang Baek 65834432a3 refac 2026-04-21 13:51:39 +09:00
Timothy Jaeryang Baek 46d73c9dcd refac 2026-04-21 13:46:39 +09:00
Timothy Jaeryang Baek a2875f13c6 refac 2026-04-21 13:33:33 +09:00
Algorithm5838 81383a7df1 fix: always rAF-throttle markdown parsing during streaming (#23868) 2026-04-21 13:29:33 +09:00
G30 4790faba73 fix(ui): add shift+click to bypass message deletion confirmation (#23888) 2026-04-21 13:21:48 +09:00
Timothy Jaeryang Baek e88e565ab4 refac 2026-04-21 13:18:54 +09:00
Timothy Jaeryang Baek 7ddb9700ff refac 2026-04-21 13:15:21 +09:00
Timothy Jaeryang Baek b645b0dc23 refac 2026-04-20 18:47:53 +09:00
Tim Baek 51627555bf refac 2026-04-20 03:35:17 -04:00
Timothy Jaeryang Baek 1824e69a70 refac 2026-04-20 09:33:10 +09:00
Timothy Jaeryang Baek 5127354b3e refac 2026-04-20 09:21:30 +09:00
Timothy Jaeryang Baek dc6df52a91 refac 2026-04-20 09:11:38 +09:00
Timothy Jaeryang Baek f246a66810 refac 2026-04-20 09:10:48 +09:00
Timothy Jaeryang Baekandtwheel c3c857a3ec refac
Co-Authored-By: twheel <100867139+whatisdata@users.noreply.github.com>
2026-04-20 08:59:30 +09:00
Timothy Jaeryang Baek 47329b5032 refac 2026-04-20 08:55:34 +09:00
Timothy Jaeryang Baek d5e69f182c refac 2026-04-20 08:53:06 +09:00
Timothy Jaeryang Baek e29d145a1c refac 2026-04-20 08:48:35 +09:00
Classic298andClaude b3ca943da1 perf(channels): batch user lookup in model_response_handler thread history (#23795)
* perf(channels): batch user lookup in model_response_handler thread history

The thread-history builder in model_response_handler called
Users.get_user_by_id once per thread message (deduped via an intra-loop
dict), producing N individual SELECTs for a thread of N unique authors.

Replace with a single Users.get_users_by_user_ids call that returns all
authors in one WHERE id IN (...) query, matching the batch pattern
already used elsewhere in this file (lines 739, 804, 1320).

Behavior is preserved: deleted users still resolve to None and fall
through to the existing 'Unknown' fallback via .get().

* refac(channels): rename loop vars to full words per review

Address reviewer feedback to use descriptive names `message` and `user`
instead of single-letter `m` and `u` in the batch user-lookup
comprehensions.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 08:37:07 +09:00
Timothy Jaeryang Baek 56c5bc1d34 refac 2026-04-20 08:36:24 +09:00
Timothy Jaeryang Baek fd25152076 refac 2026-04-20 08:34:15 +09:00
Algorithm5838 51cd43229c perf: extend fast-deep-equal to remaining call sites (#23845) 2026-04-20 08:26:30 +09:00
joaoback 0d10b946f5 Merge pull request #23858 from joaoback/patch-36
i18n: add pt-BR translations for newly added UI items and consistency…
2026-04-20 08:15:54 +09:00
Timothy Jaeryang Baek 28963815d1 refac 2026-04-20 00:16:45 +09:00
Timothy Jaeryang Baek 3e3f138d93 refac 2026-04-20 00:14:53 +09:00
Timothy Jaeryang Baek 4e31fa4427 refac 2026-04-20 00:12:53 +09:00
Timothy Jaeryang Baek 24dd5b461e refac 2026-04-20 00:09:24 +09:00
Timothy Jaeryang Baek 1d501cfa3f refac 2026-04-20 00:07:34 +09:00
Timothy Jaeryang Baek f6d1969067 refac 2026-04-19 23:55:17 +09:00
Timothy Jaeryang Baek eb16ae92a5 chore: format 2026-04-19 23:49:45 +09:00
Timothy Jaeryang Baek 73e28c9393 refac 2026-04-19 23:49:23 +09:00
Timothy Jaeryang Baek 4198f36c01 refac 2026-04-19 23:46:32 +09:00
Timothy Jaeryang Baek ec9c066961 chore: format 2026-04-19 23:42:38 +09:00
Timothy Jaeryang Baek a05a769938 refac 2026-04-19 23:42:09 +09:00
Timothy Jaeryang Baek e5b5a17426 refac 2026-04-19 23:38:58 +09:00
Timothy Jaeryang Baek 29ee53aaa5 refac 2026-04-19 23:17:25 +09:00
Timothy Jaeryang Baek 60f3ba6b59 refac 2026-04-19 23:15:15 +09:00
Timothy Jaeryang Baek b7b7b64d31 refac 2026-04-19 22:50:08 +09:00
Timothy Jaeryang Baek 37eba1c5a6 chore: format 2026-04-19 22:45:54 +09:00
Timothy Jaeryang Baek 8c9f267ad2 refac 2026-04-19 22:40:59 +09:00
Timothy Jaeryang Baek 5afc258c5b refac 2026-04-19 22:37:10 +09:00
Timothy Jaeryang Baek 42694c7c0c refac 2026-04-19 22:33:32 +09:00
Tim Baek 98c4f264e4 feat: calendar (#23880)
* feat: calendar

* refac

* refac

* refac

* refac
2026-04-19 22:22:51 +09:00
Timothy Jaeryang Baek f45d0f130e refac 2026-04-19 22:22:15 +09:00
Timothy Jaeryang Baek 98627e42b4 refac 2026-04-19 22:13:47 +09:00
Timothy Jaeryang Baek f0ec5ee08f refac 2026-04-19 21:49:48 +09:00
Timothy Jaeryang Baek 4a5401b417 refac 2026-04-19 21:48:27 +09:00
Timothy Jaeryang Baek 8d739e2aba feat: calendar 2026-04-19 19:15:05 +09:00
Timothy Jaeryang Baek 5087492e25 refac 2026-04-19 15:49:42 +09:00
Timothy Jaeryang Baek a4d62253df refac 2026-04-18 06:23:50 +09:00
Timothy Jaeryang Baek 7cfb260b8a refac 2026-04-17 15:01:42 +09:00
Timothy Jaeryang Baek 49430de42d refac 2026-04-17 15:00:17 +09:00
Timothy Jaeryang Baek 1be9627dd2 refac 2026-04-17 14:57:49 +09:00
Classic298andClaude f0e0cfcf02 perf: avoid redundant knowledge re-fetch in update_knowledge_access_by_id (#23799)
After set_access_grants, the handler was reloading the same knowledge
record via get_knowledge_by_id, which triggers an extra SELECT plus a
nested fetch of access grants. set_access_grants already returns the
newly-written grants and the local knowledge object is otherwise
unchanged, so update it in place and reuse it for the response.

https://claude.ai/code/session_01S18Lgqbih7Ry2JZUUv8TxF

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-17 14:44:42 +09:00
Timothy Jaeryang Baek 55bfc7cbc2 refac 2026-04-17 14:35:13 +09:00
Timothy Jaeryang Baek 4113b15a60 chore: format 2026-04-17 14:28:18 +09:00
Timothy Jaeryang Baek e8e655d0de chore: dep bump 2026-04-17 14:24:34 +09:00
Algorithm5838 e5f31c2e14 perf: replace JSON.stringify equality with fast-deep-equal (#23370) 2026-04-17 14:22:06 +09:00
Timothy Jaeryang Baek c5276f62d0 refac 2026-04-17 14:18:45 +09:00
Timothy Jaeryang Baek 8acce144f9 refac 2026-04-17 14:15:36 +09:00
Timothy Jaeryang Baek e7e752f8e7 refac 2026-04-17 14:09:35 +09:00
Timothy Jaeryang Baek f44b7a01f5 refac 2026-04-17 13:59:46 +09:00
Timothy Jaeryang Baek 2c7acb9285 refac 2026-04-17 13:58:32 +09:00
Timothy Jaeryang Baek 50363ba66b refac 2026-04-17 13:52:11 +09:00
Timothy Jaeryang Baek 860b90fd17 refac 2026-04-17 13:47:21 +09:00
Timothy Jaeryang Baek 914ccf07ef refac 2026-04-17 13:37:52 +09:00
Timothy Jaeryang Baek ba83613ff2 refac 2026-04-17 13:35:35 +09:00
Timothy Jaeryang BaekandClassic298 499129625b refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-04-17 13:33:11 +09:00
Timothy Jaeryang Baek 3271b013a8 refac 2026-04-17 13:29:51 +09:00
Timothy Jaeryang Baek 638c7ab802 refac 2026-04-17 13:27:58 +09:00
Timothy Jaeryang Baek 4023f6722b refac 2026-04-17 13:16:56 +09:00
Timothy Jaeryang Baek e695d854f2 refac 2026-04-17 13:10:06 +09:00
Timothy Jaeryang Baek 34d569d564 refac 2026-04-17 13:04:07 +09:00
Timothy Jaeryang Baek e709d6812f refac 2026-04-17 12:55:56 +09:00
Timothy Jaeryang Baek 3dd8255816 refac 2026-04-17 12:37:44 +09:00
Classic298andClaude 32cfb5788a perf(chats): select only meta column in get_chat_tags_by_id_and_user_id (#23798)
Avoid loading the full Chat row (including the potentially large `chat`
JSON column) just to read tag IDs from `meta.tags`. Issue a narrow
SELECT on `Chat.meta` instead, which is much cheaper for chats with
large message histories.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-17 12:35:58 +09:00
Classic298andClaude 2dca850cee perf: use set for O(1) lookup in insert_chat_files dedupe (#23800)
Convert chat_message_file_ids from list to set so the membership test
in the comprehension is O(1) instead of O(m), turning the dedupe from
O(n*m) into O(n+m). Also replace the redundant set([...]) with a set
comprehension.

https://claude.ai/code/session_01Le3NnqNhcgaJvFrDZGqmwe

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-17 12:28:34 +09:00
Timothy Jaeryang Baek 349ea4ea9e refac 2026-04-17 12:25:43 +09:00
Timothy Jaeryang Baek 3c22afc5a6 refac 2026-04-17 12:07:23 +09:00
Timothy Jaeryang Baek 7e453de4f7 refac 2026-04-17 11:54:19 +09:00
Timothy Jaeryang Baek f1ef09ddc8 refac 2026-04-17 11:48:17 +09:00
Shirasawa 7b5880ab9e fix: clean up Mermaid temporary DOM nodes after render failures (#23727)
* fix: clean up Mermaid temporary DOM nodes after render failures

* fix: prevent Mermaid temp DOM artifacts from accumulating
2026-04-17 11:44:10 +09:00
Timothy Jaeryang Baek 3332878321 refac 2026-04-17 11:29:59 +09:00
Classic298andClaude e396af3cc8 perf: reuse request db session in get_model_profile_image (#23796)
Pass the request-scoped AsyncSession into Models.get_model_by_id so the
endpoint no longer opens a fresh DB session on every call, avoiding an
extra connection acquisition per profile image request.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-17 11:28:19 +09:00
Timothy Jaeryang Baek 7cc7b367dc refac 2026-04-17 11:27:58 +09:00
Classic298andClaude 5eae0a5cdd perf(users): drop redundant get_user_by_id refetch in session-user endpoints (#23794)
* perf(users): drop redundant get_user_by_id refetch in session-user endpoints

Five /user/* handlers refetched the user row via Users.get_user_by_id(user.id)
immediately after receiving an identical UserModel from Depends(get_verified_user).
Since get_verified_user already populated the user within the same request
microseconds earlier, the refetch is pure overhead. The dead else branches
(unreachable — get_verified_user raises 401 on missing user) are removed as
a natural consequence.

Affected endpoints:
- GET  /user/settings
- GET  /user/status
- POST /user/status/update
- GET  /user/info
- POST /user/info/update

Eliminates one SELECT per request to each of these endpoints with no behavioral
change.

* fix(users): preserve USER_NOT_FOUND error on status update failure

update_user_status_by_id returns None when the target user is missing or
the update raises. The previous commit removed the pre-update existence
gate (get_user_by_id) and returned the update result directly, which
turned not-found/failure cases into 200 OK with a null body instead of
the expected 400 USER_NOT_FOUND.

Guard the update result explicitly to preserve the original API contract,
matching the equivalent pattern already applied in /user/info/update.

* docs(users): note lost-update tradeoff on /user/info/update

Make the concurrency tradeoff explicit: merging against the auth-time
snapshot slightly widens the lost-update window compared to the previous
pre-merge refetch, but the refetch only narrowed (did not eliminate) that
window. Real safety requires row locking or a version column.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-17 11:23:08 +09:00
_00_ 0e50d2b741 upd:i18n Update es-ES Spanish translations v0.9.0 (#23757)
### Update es-ES Spanish translations v0.9.0

Updated new strings
2026-04-17 11:22:28 +09:00
Algorithm5838 7aed64be3c fix: chat:outlet update overwritten by stale write-back (#23829) 2026-04-17 11:19:27 +09:00
Timothy Jaeryang Baek 43e5905c13 refac 2026-04-17 11:18:48 +09:00
Timothy Jaeryang Baek 128cf41fce refac 2026-04-17 11:12:42 +09:00
Timothy Jaeryang Baek 398718d505 refac 2026-04-17 10:44:29 +09:00
Timothy Jaeryang Baek bd35809105 refac 2026-04-17 10:22:43 +09:00
Timothy Jaeryang Baek 2e52ad8ff2 refac: shared chat 2026-04-17 10:16:32 +09:00
Timothy Jaeryang Baek 4d2f189810 feat: add RAG_RERANKING_BATCH_SIZE configuration option
Add configurable reranker batch size (env var RAG_RERANKING_BATCH_SIZE,
default 32) following the same pattern as RAG_EMBEDDING_BATCH_SIZE.

- config.py: PersistentConfig for RAG_RERANKING_BATCH_SIZE
- main.py: import, state init, pass to get_reranking_function
- colbert.py: accept batch_size param in predict() (was hardcoded 32)
- utils.py: get_reranking_function passes batch_size at call time
- retrieval.py: expose in config GET/POST endpoints and ConfigForm
- Documents.svelte: add Reranking Batch Size input in admin settings

Closes #23730
2026-04-17 08:35:45 +09:00
Timothy Jaeryang Baek 70a6a24f14 refac 2026-04-15 10:37:59 -07:00
Timothy Jaeryang Baek 2f9e326dba refac 2026-04-15 10:26:47 -07:00
Classic298 a4251d7e45 Update translation.json (#23737) 2026-04-15 10:21:28 -07:00
Aleix Dorca 82755acdfd i18n: Update Catalan translation file (#23741)
* i18n: Update catalan translation.json

* i18n: Update Catalan translation.json
2026-04-15 10:20:13 -07:00
joaoback c27aa569c8 i18n: add pt-BR translations for newly added UI items and consistency pass (#23743)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-04-15 10:19:54 -07:00
ee1bc5f5db fix(i18n): correct erroneous Hindi translations in hi-IN (#23745)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-04-15 10:19:32 -07:00
Shirasawa 3f40d9da70 i18n: improve Chinese translation (#23753) 2026-04-15 10:19:08 -07:00
Aindriú Mac Giolla Eoin e6297cf414 i18n: Update Irish translation with new strings (#23748)
Update Irish translation
2026-04-15 10:18:55 -07:00
Timothy Jaeryang Baek 5944eda0ff refac 2026-04-15 10:17:40 -07:00
Shirasawa 1860874192 i18n: add missing i18n keys of ScheduleDropdown (#23726) 2026-04-14 17:30:18 -05:00
Timothy Jaeryang Baek 5dae600ce7 chore: format 2026-04-14 17:27:31 -05:00
Timothy Jaeryang Baek f1be85d997 refac 2026-04-14 17:25:42 -05:00
Timothy Jaeryang Baek ecd74f220c refac 2026-04-14 17:22:54 -05:00
Timothy Jaeryang Baek 8bd23b9145 refac 2026-04-14 16:47:43 -05:00
Timothy Jaeryang Baek a4ed16999e refac 2026-04-14 16:08:14 -05:00
Shirasawa f102060a6d fix: fix memory leaking of Drawer (#23724) 2026-04-14 12:20:47 -05:00
Timothy Jaeryang Baek 26a645f9e6 refac: license
wording
2026-04-14 12:18:24 -05:00
Timothy Jaeryang Baek fd93bd3414 refac 2026-04-14 11:03:36 -05:00
Classic298andClaude a3ea7bf043 fix(retrieval): offload Loader.load to a worker thread so file uploads stop blocking the event loop (#23705)
Loader.load() dispatches to the underlying langchain document loaders
(PyMuPDF, Unstructured, python-docx, Tika, …) which are all
synchronous and CPU/IO-bound. process_file() awaited it directly on
the event loop, so parsing a non-trivial PDF/DOCX would freeze the
entire FastAPI app for the duration of the parse — which is what users
experience as "the server hangs whenever I upload a file."

Add an `aload()` async wrapper on Loader that runs the sync load on a
worker thread via asyncio.to_thread, and update process_file() to
await it. The sync API is preserved so existing callers that already
run inside run_in_threadpool (e.g. save_docs_to_vector_db) are
unaffected.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-14 10:55:46 -05:00
Timothy Jaeryang Baek 4866bec0f2 refac 2026-04-14 10:55:11 -05:00
Classic298andClaude 804f9f3153 fix(retrieval): offload sync VECTOR_DB_CLIENT calls in async paths via AsyncVectorDBClient (#23706)
* fix(retrieval): offload sync VECTOR_DB_CLIENT calls in async paths via AsyncVectorDBClient

The vector DB backends (Chroma, pgvector, Qdrant, Milvus, Pinecone,
Weaviate, …) are uniformly synchronous and their methods perform
blocking network or disk I/O. Multiple async route handlers and helpers
were calling them directly on the event loop — file processing,
memories, knowledge bases, hybrid search bookkeeping — so a single
upsert/delete/search would freeze every other in-flight request for the
duration of the call.

Introduce `AsyncVectorDBClient`, a thin async facade that wraps the
existing sync client and dispatches each method through
`asyncio.to_thread`. It mirrors `VectorDBBase` exactly and forwards
*args/**kwargs so backend-specific extra parameters keep working.

Update every async-context call site (routers/retrieval, routers/files,
routers/memories, routers/knowledge, retrieval/utils,
tools/builtin) to await `ASYNC_VECTOR_DB_CLIENT` instead of calling the
sync client directly. Two helpers that were sync-only also acquire
async siblings or are awaited via `asyncio.to_thread` at their async
call site (`remove_knowledge_base_metadata_embedding`,
`get_all_items_from_collections`, `query_doc`).

The original sync `VECTOR_DB_CLIENT` is unchanged, so callers that
already run inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`
and the sync `query_doc`/`get_doc` helpers) are unaffected.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

* fix(retrieval): restore explicit AsyncVectorDBClient signatures matching VectorDBBase

Per PR review: the original *args/**kwargs forwarding lost type
safety and IDE/static-analysis support. Restore explicit signatures
that mirror VectorDBBase exactly, so:

  * Bad kwargs fail at the facade boundary instead of inside the
    worker thread (where the resulting TypeError tends to be
    swallowed by surrounding `try/except`).
  * IDE autocomplete and static analysis work as expected.
  * The stated intent ("mirror VectorDBBase exactly") now holds at
    the API contract level, not just behaviourally.

While doing this, surface a pre-existing bug in
`delete_entries_from_collection` that the stricter typing flagged:
the call passed `metadata={'hash': hash}` which is not a parameter
on `VectorDBBase.delete` nor any backend. The TypeError raised
inside the sync delete was silently swallowed by `except Exception`
so the endpoint always reported `{'status': False}` for every
request instead of actually deleting matching vectors. Replace with
`filter=...` to do what the endpoint name promises.

The thorough review's other note (no concurrency/backpressure on
the shared default threadpool) is intentionally not addressed here:
asyncio.to_thread on the shared executor is the right primitive for
this use case; per-domain bounded executors would add lifecycle
complexity disproportionate to the problem and the loop is no
longer blocked, which was the actual bug.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

* fix(retrieval): parallelize hybrid-search collection prefetch; document async facade contracts

Address PR review findings:

1. Hybrid-search prefetch was sequential
   `query_collection_with_hybrid_search` previously awaited
   `ASYNC_VECTOR_DB_CLIENT.get(name)` once per collection in a for
   loop. Each call already off-loaded to a worker thread, but
   awaiting them serially meant total prefetch latency scaled
   linearly with the number of collections. Run them concurrently
   with `asyncio.gather` so multi-collection queries actually
   benefit from the threadpool. Per-collection exception handling
   is preserved by wrapping each fetch in a small helper that
   logs and returns `(name, None)` on failure, so a single bad
   collection cannot poison the whole gather.

2. Document the thread-safety expectation explicitly
   The facade now formally states what was always implicit: the
   sync `VECTOR_DB_CLIENT` is shared across worker threads, so the
   underlying backend driver must be thread-safe. This is not a
   new exposure — `save_docs_to_vector_db` already called the sync
   client from `run_in_threadpool`. Adding a global lock here
   would defeat the responsiveness the facade exists to provide;
   backends that cannot tolerate concurrent access should grow
   their own internal serialization.

3. Document the API-surface choice and `.sync` escape hatch
   The strict `VectorDBBase` mirror was a deliberate choice (the
   previous `*args/**kwargs` revision let a `metadata=` typo
   silently break an endpoint). Document it, and call out the
   `.sync` escape hatch with an example for callers that genuinely
   need a backend-specific parameter not on `VectorDBBase`.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

* fix(retrieval): guard /delete against null file.hash and let HTTPException reach the client

Address PR review finding on the `metadata=` → `filter=` change in
`delete_entries_from_collection`.

The new `filter={'hash': hash}` query was correct for files that
have a hash, but did not handle `file.hash is None` (unprocessed,
failed, or legacy records). The match semantics of a null filter
value are backend-dependent — some ignore the key entirely, some
treat it as "metadata field absent" and match every such row — so
issuing the query risked deleting unrelated entries.

  * Reject `hash is None` up front with a 400 explaining the file
    has no hash to target.

  * Narrow the surrounding `except Exception` so it no longer
    swallows `HTTPException`. Without this fix the new 400 (and the
    pre-existing 404 for missing files) would be silently re-shaped
    into `{'status': False}` and the caller could not distinguish a
    bad-request input from a backend error.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-14 10:50:18 -05:00
Classic298andClaude ee28032fb9 fix(middleware): replace BaseHTTPMiddleware HTTP middlewares with pure ASGI implementations (#23709)
* fix(middleware): replace BaseHTTPMiddleware HTTP middlewares with pure ASGI implementations

Starlette's BaseHTTPMiddleware (and the @app.middleware('http')
decorator that uses it) wraps the downstream app in an anyio task
group whose cancel scope tears down the inner task on every exit —
client disconnect, response complete, or any outer middleware bailing.
That CancelledError gets injected into whatever the inner task was
awaiting, so DB queries, embedding calls, and other long awaits get
killed mid-flight. Under aiosqlite the cleanup path then logs a
multi-page `terminate_force_close() not implemented` traceback at
ERROR for every cancelled DB call.

Open WebUI had four such middlewares stacked
(`commit_session_after_request`, `check_url`, `inspect_websocket`,
`RedirectMiddleware`) so a single cancellation would compound through
all four.

Move the four middlewares to a new `open_webui.utils.asgi_middleware`
module as plain ASGI classes (`__call__(scope, receive, send)`):

  * `CommitSessionMiddleware`   — was `commit_session_after_request`;
                                  now also rolls back if commit fails
                                  before releasing the connection.
  * `AuthTokenMiddleware`       — was `check_url`; sets request.state
                                  token + enable_api_keys + stamps
                                  X-Process-Time via a wrapped send.
  * `WebsocketUpgradeGuardMiddleware`
                                — was `inspect_websocket`; rejects
                                  /ws/socket.io HTTP requests that
                                  claim transport=websocket without a
                                  proper Upgrade/Connection header.
  * `RedirectMiddleware`        — was the BaseHTTPMiddleware subclass;
                                  same /watch + share-target rewrites.

Pure ASGI does not introduce a cancel scope around the downstream app,
so client disconnects propagate via `receive()` (the way ASGI was
designed) instead of being injected as CancelledError. Middleware
ordering is preserved.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

* fix(middleware): CommitSessionMiddleware — rollback on downstream error, never commit failed requests

The first cut put commit() in a finally block, which meant that even
when a downstream handler raised, the middleware would still commit
whatever partial sync writes that handler had made before the
failure. That regressed the previous BaseHTTPMiddleware semantics
where commit only ran on the success path.

Restructure the failure handling:

* Downstream raised → rollback any pending sync work, release the
  connection, re-raise so the outer error middleware turns it into
  an error response. We never commit a request that did not complete.
* Downstream returned → commit. On commit failure, log loudly,
  rollback, and re-raise. ScopedSession.remove() always runs in
  finally so the connection cannot leak.

Document the inherent pure-ASGI limitation explicitly: by the time
`await self.app(...)` returns the response messages have already
been emitted, so a commit failure can no longer change what the
client sees on the wire. Buffering the response to gate it on commit
success would break streaming responses (chat completions, SSE) which
are core to Open WebUI; the trade-off is intentional. Routes that
need commit-before-send must manage the sync session explicitly.

Also drop unused `typing` imports flagged by review.

https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-14 10:47:48 -05:00
Timothy Jaeryang Baek 37658fd541 refac 2026-04-14 01:17:39 -05:00
Timothy Jaeryang Baek cced77b584 refac 2026-04-14 00:07:50 -05:00
Timothy Jaeryang Baek f685edd161 refac 2026-04-13 23:40:09 -05:00
Timothy Jaeryang Baek 18fe17127a refac 2026-04-13 23:33:58 -05:00
Timothy Jaeryang Baek a209f7f6e0 refac 2026-04-13 23:23:49 -05:00
Timothy Jaeryang Baek 45e49d33e5 refac 2026-04-13 21:52:19 -05:00
Timothy Jaeryang Baek 9a8c4da67d chore: deps bump 2026-04-13 21:46:32 -05:00
Timothy Jaeryang Baek 39ea7bf63d chore: dep bump 2026-04-13 21:45:17 -05:00
Timothy Jaeryang Baek 84ec43105c refac 2026-04-13 21:33:43 -05:00
Timothy Jaeryang Baek cf4218e688 refac 2026-04-13 21:29:03 -05:00
Algorithm5838 33a4d1b412 fix: image url to base64 conversion (#23685) 2026-04-13 19:15:22 -05:00
Timothy Jaeryang Baek c8ef7b0289 refac 2026-04-13 18:51:20 -05:00
Timothy Jaeryang Baek c767bcaa73 refac 2026-04-13 18:20:46 -05:00
Timothy Jaeryang Baek 2943955c52 refac 2026-04-13 17:54:08 -05:00
Timothy Jaeryang Baek 715cf9797a refac 2026-04-13 16:25:44 -05:00
Classic298 8979987eed fix: drop extra='allow' on FolderForm and FolderUpdateForm (#23648)
* fix: drop extra='allow' on FolderForm and FolderUpdateForm

These request models were configured to accept arbitrary extra fields,
which were then merged into the folder row via form_data.model_dump().
In insert_new_folder the server-assigned user_id is placed before the
form spread, so a client-supplied user_id in the request body would
override it and the folder would be persisted against another account.

Strictly typed inputs are the correct shape for these endpoints — the
client has no legitimate reason to send fields beyond the declared
ones, and dropping extra='allow' closes the mass-assignment sink at
the validation layer instead of relying on every callsite to merge
fields in the right order.

* fix: reject unknown fields on FolderForm and FolderUpdateForm

Address review feedback: dropping extra='allow' fell back to Pydantic
v2's default extra='ignore', which only silently drops unknown fields
instead of rejecting them. The intent for these request models is a
strict input contract — fail fast when a client sends anything the
server does not expect — so explicitly set extra='forbid'. This also
makes the hardening visible in the form definition rather than implicit
in the default.
2026-04-13 16:14:00 -05:00
Timothy Jaeryang Baek cd55c3e212 refac 2026-04-13 16:03:51 -05:00
Timothy Jaeryang Baek 8dba798cce refac 2026-04-13 16:03:36 -05:00
Timothy Jaeryang Baek 9dccd29c94 refac 2026-04-13 16:00:03 -05:00
Timothy Jaeryang Baek 026903399b refac 2026-04-13 15:58:33 -05:00
G30 2991d9f1f0 fix(ui): automatically close channel input more menu dropdown dynamically on file interactions (#23684) 2026-04-13 15:57:12 -05:00
Timothy Jaeryang Baek 611fe0c8a9 refac 2026-04-13 15:14:55 -05:00
Timothy Jaeryang Baek 31406caa79 refac 2026-04-13 15:13:14 -05:00
Timothy Jaeryang Baek 9c64d84ad9 refac 2026-04-13 15:03:22 -05:00
Timothy Jaeryang Baek 40f5b3d135 refac 2026-04-13 14:51:09 -05:00
Timothy Jaeryang Baek 869cf9e848 refac 2026-04-13 14:33:23 -05:00
Timothy Jaeryang Baek 2ddcb30b9a refac 2026-04-13 14:29:27 -05:00
Timothy Jaeryang Baek 96265cf042 refac 2026-04-13 14:19:15 -05:00
Timothy Jaeryang Baek 050c4b97a9 refac 2026-04-13 14:13:03 -05:00
Timothy Jaeryang Baek d0188f3fe1 refac 2026-04-13 14:08:58 -05:00
Timothy Jaeryang Baek 45f45f5bba chore: bump dep 2026-04-13 13:49:28 -05:00
Timothy Jaeryang Baek 8936721414 refac 2026-04-13 13:44:44 -05:00
Timothy Jaeryang Baek d1a0fbe292 refac 2026-04-13 13:36:54 -05:00
Timothy Jaeryang Baek 22cfb3c673 refac 2026-04-13 13:26:13 -05:00
Timothy Jaeryang Baek 51765b619c refac 2026-04-13 13:13:45 -05:00
Timothy Jaeryang Baek 20544d412e chore: format 2026-04-12 22:11:10 -05:00
Timothy Jaeryang Baek cb6e77be3e refac 2026-04-12 22:10:43 -05:00
Timothy Jaeryang Baek d4b90f93bd refac 2026-04-12 22:08:27 -05:00
Timothy Jaeryang Baek 26b8ca5b5e refac 2026-04-12 19:41:02 -05:00
Timothy Jaeryang Baek 57784706e4 refac 2026-04-12 19:34:45 -05:00
Timothy Jaeryang Baek fc98000aa8 refac 2026-04-12 19:15:54 -05:00
Timothy Jaeryang Baek 21cc828132 refac 2026-04-12 19:13:13 -05:00
Timothy Jaeryang Baek 8172c7e3d5 refac 2026-04-12 19:08:30 -05:00
Timothy Jaeryang Baek 498ff8cdc3 refac 2026-04-12 19:05:25 -05:00
Timothy Jaeryang Baek e10a00132e refac 2026-04-12 19:02:57 -05:00
Timothy Jaeryang Baek facb194a07 refac 2026-04-12 19:02:51 -05:00
Timothy Jaeryang Baek c3c8c605d7 refac 2026-04-12 18:56:04 -05:00
Timothy Jaeryang Baek 3c2c611ba9 refac 2026-04-12 18:49:34 -05:00
Timothy Jaeryang Baek a359262616 refac 2026-04-12 18:48:06 -05:00
Classic298 d59b933bf2 fix: apply REDIS_KEY_PREFIX to tool_servers and terminal_servers cache keys (#23649)
These four Redis calls were the only places in the backend still using
bare cache keys. Every other call already namespaces through
REDIS_KEY_PREFIX, which is how multiple Open WebUI instances sharing a
Redis database stay isolated. Two instances with different
TOOL_SERVER_CONNECTIONS or TERMINAL_SERVER_CONNECTIONS would otherwise
clobber each other's cached OpenAPI specs.
2026-04-12 18:29:21 -05:00
Timothy Jaeryang Baek a7d4c53f3a refac 2026-04-12 18:24:33 -05:00
Timothy Jaeryang Baek 25898116ea chore: format 2026-04-12 18:12:59 -05:00
Classic298 4292358bd5 feat: log provider errors to console for better insights (#23379)
* fix: log provider errors that were silently swallowed

* Update main.py

* fix: wrap non-JSON SSE error responses in JSON so middleware handles them
2026-04-12 18:07:20 -05:00
Classic298 67023037f8 fix: replace brittle profile_image_url allowlist with safe-scheme validation (#23389)
* fix: replace brittle profile_image_url allowlist with safe-scheme validation

The previous validation used a hardcoded allowlist of specific static
paths and a single Gravatar prefix. This rejected OWUI's own internal
API paths (e.g. /api/v1/users/{id}/profile/image) and external OAuth
avatar URLs, making it impossible to save user profiles from the admin
panel.

Replace with scheme-based validation that allows relative paths,
HTTP(S) URLs, and data:image URIs while blocking dangerous schemes
like javascript:, file:, and ftp:.

Fixes open-webui#23387

* fix: harden profile image URL validation per review feedback

- Restrict data URIs to safe raster formats (png/jpeg/gif/webp);
  SVG is excluded because it can carry embedded scripts.
- Block scheme-relative URLs (//host/path) which browsers resolve
  against the current protocol, bypassing the relative-path check.

* fix: use structural validation instead of prefix checks

- Use urlparse for HTTP(S) URLs: gives case-insensitive scheme
  matching and rejects bare schemes with no host (e.g. https://).
- Use a compiled regex for data URIs: enforces the ;base64, boundary,
  restricts to safe raster formats, and is case-insensitive per spec.
- Removes the startswith-based prefix tuple in favour of proper
  URL and data URI parsing.

* fix: validate hostname not netloc, fix misleading comment

- Use parsed.hostname instead of parsed.netloc so URLs like
  http://:80/path (non-empty netloc but no actual host) are rejected.
- Update data URI comment to accurately state we validate MIME type
  and structure, not base64 payload integrity.

* fix: constrain relative paths to known-safe prefixes

Accepting any relative path starting with / allowed a user to set
their profile_image_url to an arbitrary internal GET endpoint. When
another user (e.g. an admin) views that profile, the browser fires
the GET with the viewer's session cookies — an authenticated GET
trigger surface.

Constrain to known-safe prefixes (/api/v1/users/, /static/) and
exact matches (/user.png, /favicon.png) which are the only relative
paths OWUI itself generates.

* fix: use exact matches and anchored regex, eliminate all prefix wildcarding

Replace all startswith-based path checks with:
- frozenset exact matches for static assets (/user.png, /favicon.png,
  /static/favicon.png)
- Anchored regex for the OWUI profile image API route that accepts
  only /api/v1/users/{id}/profile/image (no trailing components,
  no path traversal across segments)

This eliminates every prefix-based attack surface:
- /api/v1/users/{id}/anything-else is rejected
- /static/../../etc/passwd is rejected
- /api/v1/users/../../admin/config is rejected
- Arbitrary internal GET triggers are no longer possible

* fix: exclude query/fragment delimiters from user-ID regex segment

Change [^/]+ to [^/?#]+ so that inputs like
/api/v1/users/alice?x=1/profile/image are rejected — the browser
would interpret ? as the query string start, making the actual
request target /api/v1/users/alice instead of the intended route.
2026-04-12 17:57:49 -05:00
Classic298andClaude e7ff4768f8 fix: Add ownership checks to global task endpoints (#23454)
* Add ownership checks to global task endpoints

- Restrict GET /api/tasks and POST /api/tasks/stop/{task_id} to admin-only
- Add new scoped POST /api/tasks/chat/{chat_id}/stop endpoint with ownership
  check so regular users can stop their own chat tasks
- Allow admins to access the scoped chat task endpoints alongside owners
- Update frontend to use the new scoped stop endpoint when a chatId is available

https://claude.ai/code/session_01K7zPDvvjRu8AxJ4Br2HhZc

* Handle temporary (local:) chat IDs in scoped task endpoints

Temporary chats use local:<socketId> as chat_id which doesn't exist in
the DB. The scoped endpoints now skip ownership checks for local: IDs
(they aren't enumerable) and use {chat_id:path} to handle the colon in
the URL path.

https://claude.ai/code/session_01K7zPDvvjRu8AxJ4Br2HhZc

* Verify session ownership for local: chat IDs and URL-encode chat_id

- For local:<socketId> chat IDs, look up the socket's owner in
  SESSION_POOL and verify it matches the requesting user (or admin)
- URL-encode chat_id in frontend fetch calls to handle special
  characters (colon in local: IDs) safely

https://claude.ai/code/session_01K7zPDvvjRu8AxJ4Br2HhZc

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-12 17:56:43 -05:00
Timothy Jaeryang Baek c47dd7b771 refac 2026-04-12 17:22:06 -05:00
Timothy Jaeryang Baek 4498e6faf2 refac 2026-04-12 16:59:05 -05:00
joaoback 674c1127e2 i18n: add pt-BR translations for newly added UI items and consistency pass (#23403)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-04-12 16:56:46 -05:00
Timothy Jaeryang Baek 15b89b9218 refac 2026-04-12 16:56:00 -05:00
G30 008f1dfbda fix(ui): prevent user added action icons from being dragged (#23412) 2026-04-12 16:49:14 -05:00
Timothy Jaeryang Baek 47d413ce7b refac 2026-04-12 16:47:23 -05:00
Classic298 0753409e7b fix: use ipaddress stdlib for IPv6 SSRF protection (#23453)
The validators.ipv6(ip, private=True) call always returns a falsy ValidationError because validators==0.35.0 does not support the private kwarg for IPv6. This means any hostname resolving to a private IPv6 address (::1, fd00::*, ::ffff:169.254.169.254) bypasses SSRF protection entirely, circumventing the fix for CVE-2025-65958.

Replace both the IPv4 and IPv6 validators-based private checks with Python's stdlib ipaddress module using an allowlist approach (not addr.is_global). This blocks all non-globally-routable addresses — private, loopback, link-local, reserved, multicast, and unspecified — for both IPv4 and IPv6, including IPv4-mapped IPv6 addresses.
2026-04-12 16:34:13 -05:00
Classic298 b78dabb442 fix: reject empty passwords in LDAP authentication to prevent unauthenticated binds (#23633)
Per RFC 4513, a Simple Bind with a non-empty DN but empty password is unauthenticated simple authentication. Many LDAP servers (OpenLDAP default, some AD configs) accept these binds, allowing account takeover without valid credentials.

Rejects empty and whitespace-only passwords before attempting the LDAP bind.
2026-04-12 16:33:57 -05:00
Classic298 83024d00bb fix: enforce API key endpoint restrictions at the auth layer, not middleware (#23637)
The APIKeyRestrictionMiddleware only inspected the Authorization header for sk- tokens, but get_current_user also reads API keys from cookies and x-api-key headers. This allowed complete bypass of endpoint restrictions by sending the key via an alternate transport.

Moves the restriction check into get_current_user_by_api_key so it runs regardless of how the API key was delivered. Removes the now-redundant middleware.
2026-04-12 16:33:41 -05:00
Classic298 4f94d21780 fix: enforce filter_allowed_access_grants on channel create and update (#23638)
Unlike all other resource routers (knowledge, models, notes, prompts, tools, skills), the channel router did not call filter_allowed_access_grants. This allowed any user to set wildcard access grants on group channels, bypassing the admin's public sharing permission framework.

Adds filter_allowed_access_grants with the sharing.public_channels permission key to both create and update endpoints, matching the pattern used by all other resource routers.
2026-04-12 16:27:44 -05:00
Timothy Jaeryang Baek 5ee791d5d2 refac 2026-04-12 16:25:01 -05:00
Classic298 fb5ef978bf fix: enforce OAUTH_ALLOWED_DOMAINS on token exchange endpoint (#23639)
The OAuth token exchange endpoint skipped the domain allowlist check that the normal OAuth callback enforces. An attacker with a valid OAuth token from a non-allowed domain (e.g. gmail.com) could bypass the admin's domain restriction policy entirely.

Adds the same domain validation check used in the OAuth callback, denying access when the email domain is not in the allowed list.
2026-04-12 16:19:58 -05:00
Classic298 977d638afe fix: invalidate stale Socket.IO sessions on role change and user deletion (#23642)
SESSION_POOL caches user.role at connection time and never refreshes it. When an admin demotes or deletes a user, their socket sessions retain the old cached role until voluntary disconnect, allowing continued use of admin-gated socket features (ydoc editing, channel access).

Adds disconnect_user_sessions() helper that disconnects all sockets for a user ID. Called from update_user_by_id (on role change) and delete_user_by_id. The client auto-reconnects and re-authenticates with fresh DB data.
2026-04-12 16:19:38 -05:00
Timothy Jaeryang Baek de27a12151 refac 2026-04-12 14:39:23 -05:00
Classic298 f6b85700ea fix: gate OpenAI catch-all proxy behind ENABLE_OPENAI_API_PASSTHROUGH toggle (#23640)
The catch-all /{path:path} proxy forwards any request to the upstream OpenAI-compatible API with the admin's API key and no access control. This is an intentional proxy but should be opt-in.

Adds ENABLE_OPENAI_API_PASSTHROUGH env var (defaults to False). When disabled, the catch-all returns 403. No other routers (Ollama, responses) have catch-all proxies.
2026-04-12 14:26:12 -05:00
Timothy Jaeryang Baek d40f31982b refac 2026-04-12 14:24:08 -05:00
Timothy Jaeryang Baek 27169124f2 refac: async db 2026-04-12 14:22:11 -05:00
Classic298 b618d84065 fix: add missing read-access check on channel members endpoint (#23625)
The GET /channels/{id}/members endpoint checked membership for group/dm channels but had no access gate for standard channels, allowing any authenticated user with channels permission to enumerate members of private standard channels by UUID.
2026-04-12 12:41:51 -05:00
Timothy Jaeryang Baek 15f9a8f3f1 refac 2026-04-12 12:36:21 -05:00
Classic298 a2a9a3a42a fix: prevent path traversal via model name in Azure deployment URLs (#23629)
The model name from user input was interpolated directly into Azure deployment URL paths without validation. A user could send a model name like '../../management/foo' to traverse the URL path and hit unintended Azure endpoints with the admin's API key.

Adds _sanitize_model_for_url that rejects path separators and traversal sequences, and percent-encodes the name. Applied at convert_to_azure_payload (covers chat completions + proxy) and the responses endpoint's direct URL construction.
2026-04-12 12:29:45 -05:00
Classic298 4498c21f4c fix: enforce model access control on Ollama generate, show, embed, embeddings endpoints (#23631)
These four endpoints checked model existence but never verified the user has read access via AccessGrants, allowing any authenticated user to use restricted models.

Uses the canonical check_model_access helper from utils.access_control.
2026-04-12 12:29:26 -05:00
Timothy Jaeryang Baek d3df8f1f37 refac 2026-04-12 12:28:38 -05:00
Classic298 96a0b3239b fix: prevent first-user admin race in LDAP and OAuth registration (#23626)
Both LDAP and OAuth registration checked user count before insert to determine whether to assign admin role.  With multiple workers, concurrent first-user registrations could each see zero users and both create admin accounts.

Applies the insert-first-check-after pattern already used by signup_handler: insert with DEFAULT_USER_ROLE, then atomically check get_num_users()==1 and promote only the sole user to admin.
2026-04-12 11:28:41 -05:00
Timothy Jaeryang Baek 36a81ad43b refac 2026-04-12 11:15:38 -05:00
Classic298 71a39dbac1 fix: filter on is_active in channel membership checks (#23623)
is_user_channel_member and is_user_channel_manager did not filter on is_active, allowing deactivated members to retain read/write access to group channels via direct API calls.
2026-04-12 11:13:51 -05:00
Classic298 5eab125f13 fix: sanitize model description HTML with DOMPurify in chat placeholders (#23621) 2026-04-12 11:12:49 -05:00
Classic298 e790e7be7a fix: enforce model access control on /responses endpoint (#23481)
The /responses proxy endpoint only required authentication via
get_verified_user but did not check per-model access grants. This
allowed any authenticated user to access any model through this
endpoint, bypassing the access control system.

Extract a shared check_model_access helper into utils/access_control
and replace all inline access control blocks across openai.py and
ollama.py (7 locations) with calls to this helper. This eliminates
code duplication and prevents future policy drift between endpoints.

CWE-862: Missing Authorization
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:H (6.5 Medium)
2026-04-12 11:06:33 -05:00
Toru Suzuki b0df527224 i18n: Update Japanese translation (#23617) 2026-04-12 11:04:35 -05:00
G30 92dfa3f2f2 fix(backend): provide fallback strings for webhook UserNameResponse to prevent Pydantic validation error (#23414) 2026-04-11 17:10:11 -06:00
Timothy Jaeryang Baek 406251c2f3 enh: automation 2026-04-11 17:06:58 -06:00
Timothy Jaeryang Baek ee9db91df0 refac 2026-04-11 17:06:49 -06:00
Timothy Jaeryang Baek 09f6d7ba57 refac 2026-04-11 16:55:20 -06:00
Timothy Jaeryang Baek 674695918e refac 2026-04-11 16:44:12 -06:00
Classic298andClaude 588b81eeda fix(redis): add opt-in health_check_interval for stale pooled connections (#23573)
Introduces REDIS_HEALTH_CHECK_INTERVAL and wires it through to every
Redis client created by get_redis_connection (plain, cluster and
sentinel paths, sync and async). When set, redis-py will PING any
connection idle longer than the interval on checkout, so dead sockets
are surfaced as reconnectable errors before a real command lands on
them.

Defaults to unset (empty string) so existing deployments see no
behavioural change. Operators who want the protection should set it
shorter than the Redis server `timeout` setting and any firewall/LB
idle timeout on the path to Redis.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 16:17:19 -06:00
Classic298andClaude db7f122cb0 fix(redis): add opt-in TCP socket keepalive on all client connections (#23571)
Introduces REDIS_SOCKET_KEEPALIVE and wires socket_keepalive=True
through to every Redis client created by get_redis_connection
(plain, cluster and sentinel paths, sync and async). When enabled,
the kernel sends TCP keepalive probes on idle connections so
half-closed sockets (e.g. after a silent firewall/LB reset or a NIC
flap) are detected before the next command lands on them and the
request never sees a "Connection reset by peer" error.

Defaults to off so existing deployments see no behavioural change.
Operators who want the protection set REDIS_SOCKET_KEEPALIVE=true
in their environment.

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 16:09:12 -06:00
Timothy Jaeryang Baek aacf95cf76 refac 2026-04-11 16:08:16 -06:00
Wang Weixuan 008cd8e6b9 refac: import fastapi instrumentor directly (#23530) 2026-04-11 15:36:22 -06:00
Classic298andClaude c0ac10d5db fix: honor REDIS_SOCKET_CONNECT_TIMEOUT on non-sentinel clients (#23572)
* fix(redis): honor REDIS_SOCKET_CONNECT_TIMEOUT on non-sentinel clients

Previously only the sentinel path passed REDIS_SOCKET_CONNECT_TIMEOUT
through to the Redis client. Plain redis:// and cluster URLs fell back
to redis-py's default (no explicit connect timeout), so a hung Redis
or a black-holed network path could stall the whole worker until the
kernel gave up. Forwarding the same env var to from_url()/RedisCluster
keeps the behavior consistent across all deployment topologies.

* fix(redis): gate socket_connect_timeout on is-not-None, not truthiness

Addresses review feedback: the truthiness check on REDIS_SOCKET_CONNECT_TIMEOUT
silently dropped an explicit 0 value and was inconsistent with the sentinel
construction path, which forwards the value directly. Switch to `is not None`
so any user-configured value (including 0) is passed through to from_url()
and RedisCluster.from_url().

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 15:32:05 -06:00
Classic298 faf935ef52 auths: match JWT expiry on /auths/add with other sign-in paths (#23576) 2026-04-11 15:31:34 -06:00
Timothy Jaeryang Baek 6acaaea59a refac 2026-04-11 15:23:37 -06:00
Algorithm5838 b6db719758 perf: build mention regex once in factory closure (#23551) 2026-04-11 15:02:17 -06:00
Algorithm5838 bf49358185 refactor: use shared unescapeHtml in CodeBlock (#23553) 2026-04-11 15:00:33 -06:00
Classic298 69b3ec3511 scim: use hmac.compare_digest for bearer token check (#23577) 2026-04-11 14:57:47 -06:00
Colin Chen a600f67d6b i18n: fix Chinese translation for Web Upload permission (#23596)
Differentiate between "Allow File Upload" and "Allow Web Upload"
in Chinese translations to help administrators understand the
distinction:
- "Allow File Upload" = local file, cloud storage uploads
- "Allow Web Upload" = URL, YouTube, web content uploads
2026-04-11 14:55:18 -06:00
Skyzi000 09dccdd42f fix: use unique client_id per ComfyUI WebSocket connection (#23592)
Using user.id as client_id causes WebSocket deadlocks when the same
user generates images concurrently (e.g., multi-model chat). ComfyUI
routes messages by clientId, so shared IDs mean only one connection
receives the completion — others hang forever.

Generate a unique UUID per request, matching ComfyUI's own examples.
2026-04-11 14:55:04 -06:00
Timothy Jaeryang Baek be38ca8e81 refac 2026-04-11 14:44:05 -06:00
Timothy Jaeryang Baek bd3a3635ee refac 2026-04-10 10:15:55 -07:00
Timothy Jaeryang Baek 1dcbfd47fb refac 2026-04-09 11:36:06 -07:00
Timothy Jaeryang Baek 4b8e331333 refac 2026-04-09 11:13:09 -07:00
Timothy Jaeryang Baek cb0fd6ed41 refac 2026-04-08 14:55:04 -07:00
Timothy Jaeryang Baek e51b661af0 refac: ollama 2026-04-08 14:32:34 -07:00
Timothy Jaeryang Baek a775fc9b50 refac 2026-04-08 13:34:23 -07:00
Algorithm5838 f9ceb7fa89 perf: skip torch import on non-macOS (#23438) 2026-04-08 13:21:55 -07:00
Timothy Jaeryang Baek 2112a99b36 refac 2026-04-08 13:21:01 -07:00
Timothy Jaeryang Baek 736a800c5f refac 2026-04-08 13:20:11 -07:00
Classic298 fcedeb9034 feat: add /v1/responses proxy endpoint for Ollama (#23483)
Ollama recently added Responses API support via its OpenAI-compatible
endpoint (/v1/responses). This adds a proxy endpoint to the Ollama
router that forwards requests to Ollama's /v1/responses, applying
the same model resolution, access control, and prefix_id handling
used by the existing /v1/chat/completions and /v1/messages proxies.

Uses a typed ResponsesForm Pydantic model with required model field
and extra='allow' for forward compatibility, consistent with other
endpoint schemas in the file.

This allows API consumers (Codex, Claude Code, etc.) to use the
Responses API directly with Ollama-hosted models without requiring
a separate OpenAI-compatible connection.
2026-04-08 13:15:21 -07:00
Classic298 99f3c554c8 feat: support Azure v1 endpoint format (/openai/v1) (#23484)
Azure offers two URL formats: the legacy deployment-based format
(/openai/deployments/{model}/...) and the newer v1 format
(/openai/v1/...) where the model stays in the payload body and no
api-version query parameter is needed.

Previously, the code always ran convert_to_azure_payload which
rewrites the URL to the deployment format, causing 404 errors for
users with v1-style base URLs. Now, when the base URL contains
'/openai/v1', we skip deployment URL construction and route
directly.

Applied consistently across all three Azure routing paths:
generate_chat_completion, /responses proxy, and generic proxy.
2026-04-08 13:14:46 -07:00
Classic298 e7e006e781 fix: use admin-configured WEB_SEARCH_RESULT_COUNT as default (#23488)
The built-in search_web tool hardcoded count=5 as the default,
ignoring the admin-configured WEB_SEARCH_RESULT_COUNT setting.
When the LLM did not specify a count, the tool always returned 5
results regardless of admin configuration.

Now the tool defaults to the admin-configured value when the LLM
omits the count parameter, while still capping LLM-requested
values at the admin maximum to prevent abuse.

Closes #23485
2026-04-08 13:13:44 -07:00
Aleix Dorca 803d833908 i18n: Update catalan translation.json (#23506) 2026-04-08 13:11:25 -07:00
Classic298 435efa31ce fix: add SSRF protection to OAuth profile picture URL fetching (#23356) 2026-04-08 13:10:35 -07:00
G30 8977789177 fix(ui): remove capitalize class from terminal menu selected label (#23518) 2026-04-08 12:48:51 -07:00
Timothy Jaeryang Baek 9f1b279e88 refac 2026-04-07 15:04:57 -06:00
Timothy Jaeryang Baek 8e82f0d239 refac 2026-04-07 14:30:05 -06:00
Timothy Jaeryang Baek c40ea7f29d refac 2026-04-02 23:00:00 -05:00
Timothy Jaeryang Baek 253f416de3 refac 2026-04-02 22:54:17 -05:00
Timothy Jaeryang Baek 53eadb7df7 refac 2026-04-02 22:34:51 -05:00
Timothy Jaeryang Baek 6c243664bc refac 2026-04-02 20:56:00 -05:00
Timothy Jaeryang Baek 4cee67e2be feat: mistral tts 2026-04-02 19:31:15 -05:00
Timothy Jaeryang Baek 730e52a431 refac 2026-04-02 19:29:23 -05:00
Timothy Jaeryang Baek 8c2afb8157 refac 2026-04-02 17:58:11 -05:00
Timothy Jaeryang Baek ae0316a30e refac 2026-04-02 17:53:11 -05:00
Classic298 a72e8e0223 Update translation.json (#23352) 2026-04-02 17:48:38 -05:00
Timothy Jaeryang Baek f66b67c8b8 refac 2026-04-02 17:17:01 -05:00
Timothy Jaeryang Baek b89019a8e1 refac 2026-04-02 08:57:49 -05:00
Timothy Jaeryang Baek 0dd9f462ff feat: oauth backchannel logout 2026-04-02 08:46:34 -05:00
Timothy Jaeryang Baek 4dea4fdf54 refac 2026-04-02 08:34:49 -05:00
_00_ 2863e9f8c4 upd:i18n es-ES Translation v0.8.12 (#23350)
Udded new strings and minor corrections
2026-04-02 08:24:18 -05:00
Timothy Jaeryang Baek a71d927a0c chore: format 2026-04-02 08:11:06 -05:00
Timothy Jaeryang Baek 640dbb6a28 refac 2026-04-02 08:09:57 -05:00
Timothy Jaeryang Baek 366454e812 refac 2026-04-02 08:04:43 -05:00
Timothy Jaeryang Baek 4578bf52ee refac 2026-04-02 08:02:46 -05:00
Timothy Jaeryang Baek 9190d4b542 refac 2026-04-02 07:48:24 -05:00
Shirasawa 6fdd19bf14 I18n: improve Chinese translation (#23347)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-04-02 06:44:24 -05:00
Timothy Jaeryang Baek 6d6dfbf02c refac 2026-04-02 06:41:10 -05:00
Timothy Jaeryang Baek 342582676a refac 2026-04-02 02:57:45 -05:00
Timothy Jaeryang Baek 60e4d75174 refac 2026-04-02 02:50:05 -05:00
Timothy Jaeryang Baek 4632f200a9 refac 2026-04-02 02:06:21 -05:00
Timothy Jaeryang Baek 9d3e0637c8 refac 2026-04-02 02:05:35 -05:00
Timothy Jaeryang Baek 65ee771fd0 refac 2026-04-02 01:40:50 -05:00
Timothy Jaeryang Baek 0c5399ca53 refac 2026-04-01 18:26:46 -05:00
Timothy Jaeryang Baek 584a9a0920 refac 2026-04-01 07:42:11 -05:00
Timothy Jaeryang Baek 124b7e9154 refac 2026-04-01 07:35:38 -05:00
Timothy Jaeryang Baek 4764dd5d37 refac 2026-04-01 07:34:01 -05:00
Timothy Jaeryang Baek 86472bb445 refac 2026-04-01 07:31:54 -05:00
Timothy Jaeryang Baek 07262fa62c refac 2026-04-01 07:14:18 -05:00
Algorithm5838 a28ea36657 perf: inline update_chat_title_by_id into single DB context (#23214) 2026-04-01 07:06:16 -05:00
Shirasawa 3f7fc1a75a fix: Fix the error that occurs when the task model does not exist (#23169) 2026-04-01 07:01:25 -05:00
Timothy Jaeryang Baek 60676bfdcf refac 2026-04-01 06:40:49 -05:00
Shirasawa 2734fcad63 fix(sidebar): ChatItem document listener teardown and shared drag image (#23209) 2026-04-01 06:40:02 -05:00
Timothy Jaeryang Baek 3d6e5ff8f9 refac 2026-04-01 06:37:57 -05:00
Timothy Jaeryang Baek 1554be9da6 refac 2026-04-01 06:29:21 -05:00
Timothy Jaeryang Baek d6a9efca68 refac 2026-04-01 06:13:50 -05:00
Timothy Jaeryang Baek b3e56e0c92 refac 2026-04-01 06:02:37 -05:00
Timothy Jaeryang Baek 15883e5229 refac 2026-04-01 06:00:53 -05:00
Timothy Jaeryang Baek 0e5696de74 refac 2026-04-01 05:55:48 -05:00
Timothy Jaeryang Baek 70c87a1ed1 refac 2026-04-01 05:54:58 -05:00
Timothy Jaeryang Baek 51b200c67b refac 2026-04-01 05:52:03 -05:00
Timothy Jaeryang Baek e24683d09c refac 2026-04-01 05:48:21 -05:00
Timothy Jaeryang Baek 61558d59c3 refac 2026-04-01 05:41:26 -05:00
Timothy Jaeryang Baek 0f8d7982e1 refac 2026-04-01 05:40:32 -05:00
Shirasawa d7a5a903a6 fix: guard completed API with message id (#23184) 2026-04-01 05:39:34 -05:00
Algorithm5838 16e6cb458f perf: skip replaceTokens when no {{ present (#23161) 2026-04-01 05:37:44 -05:00
Classic298 04919757e4 i18n: German (#23160)
* Update translation.json

* Update translation.json
2026-04-01 05:36:56 -05:00
Denis Khachyan 7025cc94cc feat(config): add environment variable support for DEFAULT_MODEL_PARAMS (#23223) 2026-04-01 05:36:29 -05:00
Algorithm5838 2d83d0f950 perf: replace unescapeHtml DOMParser with html-entities decode (#23165) 2026-04-01 05:35:20 -05:00
Algorithm5838 354a179f6d perf: skip processChineseContent for non-Chinese content (#23162) 2026-04-01 05:34:50 -05:00
Timothy Jaeryang Baek b898fc0258 fix: gravatar profile image 2026-04-01 05:33:41 -05:00
Timothy Jaeryang Baek eb5c95ef8e refac 2026-04-01 05:30:07 -05:00
KRATOSandClaude Sonnet 4.6 512d090f6c i18n: add Tamil (ta-IN) translation (#23311)
Adds Tamil (India) translations for all existing keys including
Automations feature strings (create, schedule, run, logs, etc.).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 05:25:26 -05:00
Timothy Jaeryang Baek b8d4577292 refac 2026-04-01 05:23:19 -05:00
Timothy Jaeryang Baek 18f6ec68b9 refac 2026-04-01 05:11:48 -05:00
Timothy Jaeryang Baek 7bfd9ac8ac fix: expand details ui settings 2026-04-01 05:08:28 -05:00
Timothy Jaeryang Baek 7c52382c90 refac 2026-04-01 05:03:53 -05:00
Timothy Jaeryang Baek ebc9ccbe1c refac 2026-04-01 04:51:51 -05:00
Timothy Jaeryang Baek c8ef5a4f38 chore: format 2026-04-01 04:36:02 -05:00
Timothy Jaeryang Baek 53583f8d83 refac 2026-04-01 04:33:30 -05:00
Timothy Jaeryang Baek bae5ff938a refac 2026-04-01 04:09:00 -05:00
Timothy Jaeryang Baek 0638b9f56c refac 2026-04-01 04:00:18 -05:00
Timothy Jaeryang Baek acaf9ab50c refac 2026-04-01 03:36:55 -05:00
Timothy Jaeryang Baek d30a0531d4 refac 2026-04-01 03:23:01 -05:00
Timothy Jaeryang Baek 5a2ff8b2e5 refac 2026-04-01 01:21:21 -05:00
Algorithm5838 0d3d824274 Merge pull request #23257 from Algorithm5838/perf/text-token-css-animation
perf: replace JS transition with CSS animation in TextToken
2026-04-01 01:03:03 -05:00
Tim Baek 8e67bf67e1 Merge pull request #23303 from open-webui/automations
feat: automations
2026-04-01 10:02:36 +04:00
KRATOS beb938a9ab i18n: add Tamil (ta-IN) translation (#23304) 2026-04-01 01:02:19 -05:00
Timothy Jaeryang Baek 40a7b65695 refac 2026-04-01 01:00:32 -05:00
Timothy Jaeryang Baek 6de9a2af97 refac 2026-04-01 00:59:09 -05:00
Timothy Jaeryang Baek fe8a3d9f83 refac 2026-04-01 00:55:52 -05:00
Timothy Jaeryang Baek c6b1c56e9e refac 2026-04-01 00:46:24 -05:00
Timothy Jaeryang Baek 48288e9ce7 refac 2026-04-01 00:41:08 -05:00
Timothy Jaeryang Baek 90319593d0 refac 2026-04-01 00:35:11 -05:00
Timothy Jaeryang Baek f984c6e79a refac 2026-04-01 00:17:04 -05:00
Timothy Jaeryang Baek 8a0794958d refac 2026-04-01 00:11:11 -05:00
Timothy Jaeryang Baek 378673408e refac 2026-03-31 23:39:54 -05:00
Timothy Jaeryang Baek e6f38f52c8 feat: automation 2026-03-31 23:36:01 -05:00
Timothy Jaeryang Baek 36d02aa147 refac 2026-03-31 23:12:23 -05:00
Algorithm5838 98570d3547 perf: single yield per line in stream chunks handler (#23266) 2026-03-31 18:42:52 -05:00
Algorithm5838 5fd9db8739 perf: replace JS transition with CSS animation in CodespanToken (#23258) 2026-03-31 17:19:47 -05:00
Shirasawa b10c70cfcf feat: Save error messages to the database (#23231) 2026-03-31 04:11:47 -05:00
Shirasawa eba2b2cd72 refactor(SVGPanZoom): Fix memory leaking - consolidate zoom lifecycle into PanzoomContainer (#23236)
* refactor(SVGPanZoom): attach panzoom via use: action and remove unused parent bind:this

* refactor: centralize panzoom in createPanzoomAction and align ImagePreview cleanup

* refactor(panzoom): consolidate zoom lifecycle into PanzoomContainer and remove action-based wiring
2026-03-30 20:54:28 -05:00
Algorithm5838 1c5e84ddf2 perf: project only Chat.id in delete_shared_chats_by_user_id (#23216) 2026-03-30 05:15:12 -05:00
Timothy Jaeryang Baek 10b4b86ada refac 2026-03-30 05:10:24 -05:00
Algorithm5838 9cc3ffb4a9 perf: update_last_active_by_id via single UPDATE (#23215) 2026-03-30 04:14:33 -05:00
Artem Netsvetaev 3fda6e9eed i18n: Add missing Russian (ru-RU) translations (#23200) 2026-03-30 04:09:44 -05:00
Timothy Jaeryang Baek 4b35d70078 refac 2026-03-29 21:59:51 -05:00
Timothy Jaeryang Baek 6512e085c4 refac 2026-03-29 21:50:54 -05:00
Timothy Jaeryang Baek 0ad397c048 refac 2026-03-29 21:29:00 -05:00
Timothy Jaeryang Baek b1e8c7d2aa refac 2026-03-29 21:25:39 -05:00
Timothy Jaeryang Baek b794d61626 refac 2026-03-29 21:15:20 -05:00
Timothy Jaeryang Baek a06685a47b refac 2026-03-29 21:01:10 -05:00
Timothy Jaeryang Baek 4777f4fa32 refac 2026-03-29 19:48:23 -05:00
Timothy Jaeryang Baek 1b1d85fe2e refac 2026-03-29 19:42:10 -05:00
Timothy Jaeryang Baek edb8971c7d refac 2026-03-29 19:30:38 -05:00
Timothy Jaeryang Baek 64da99a322 refac 2026-03-29 19:17:12 -05:00
Timothy Jaeryang Baek 012ce95f27 enh: swipe to reply 2026-03-29 19:09:13 -05:00
Timothy Jaeryang Baek 6c2b2f2c3e refac 2026-03-29 19:01:53 -05:00
Timothy Jaeryang Baek 2040095050 enh: shortcode emojis 2026-03-29 19:01:50 -05:00
Timothy Jaeryang Baek 2388dd7dc3 refac 2026-03-29 18:40:09 -05:00
Timothy Jaeryang Baek bcb71bb520 feat: tasks 2026-03-29 18:01:04 -05:00
Tim Baek 9bd84258d0 Merge pull request #23120 from open-webui/dev
0.8.12
2026-03-27 04:26:39 +04:00
Timothy Jaeryang Baek 66c9bf57da refac 2026-03-26 19:10:30 -05:00
Timothy Jaeryang Baek abc3bac44a chore: bump 2026-03-26 19:05:17 -05:00
Timothy Jaeryang Baek 2b0cccd5be chore: format 2026-03-26 19:02:13 -05:00
Timothy Jaeryang Baek 6d736d3c59 refac 2026-03-26 19:01:33 -05:00
Classic298 9ba97890a5 chore: changelog (#23119)
* Add changelog entry for v0.8.12

* changelog: translation updates for zh-CN, ca, pt-BR, fi

* changelog: fix beautifulsoup4 missing dependency

* changelog: add Lithuanian to translation updates

* changelog: api files list, beautifulsoup4

* changelog: api files, license loading fixes

* changelog: terminal server connection verification security fix

* changelog: terminal server security

* changelog: terminal tools exception handling fix

* changelog: admin model visibility fix

* changelog: tool call embed visibility
2026-03-26 18:52:16 -05:00
Timothy Jaeryang Baek 58ca51364c refac 2026-03-26 18:49:06 -05:00
Timothy Jaeryang Baek 308fa924a5 refac 2026-03-26 18:42:42 -05:00
Timothy Jaeryang Baek 4c872a8d12 refac 2026-03-26 18:36:22 -05:00
Timothy Jaeryang Baek 925f77f0f5 chore: format 2026-03-26 18:25:19 -05:00
Timothy Jaeryang Baek f3f8f9874f refac 2026-03-26 18:24:17 -05:00
Timothy Jaeryang Baek 52a06bd48a refac 2026-03-26 18:22:14 -05:00
Timothy Jaeryang Baek 4567cdc0d9 refac 2026-03-26 18:17:49 -05:00
Timothy Jaeryang Baek a641325707 refac 2026-03-26 18:13:18 -05:00
Timothy Jaeryang Baek 11f52921dc refac 2026-03-26 17:40:56 -05:00
Timothy Jaeryang Baek c6ed0b0788 fix 2026-03-26 17:40:50 -05:00
Timothy Jaeryang Baek 16335f866e refac 2026-03-26 17:38:23 -05:00
Aleix Dorca 0842acad53 i18n: Update catalan translation.json (#23069) 2026-03-26 17:31:42 -05:00
Shirasawa 0472017cab i18n: improve zh-CN translation (#23102)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation

* i18n: improve zh-CN translation
2026-03-26 17:31:19 -05:00
joaoback 0bdcb5a337 Merge pull request #23110 from joaoback/patch-33
i18n: add pt-BR translations for newly added UI items and consistency pass
2026-03-26 17:31:12 -05:00
Timothy Jaeryang Baek 1994d65306 fix: bs4 pip install 2026-03-26 17:26:06 -05:00
Tim Baek 19274672f9 Merge pull request #23097 from Kylapaallikko/dev
i18n: Update fi-FI translation
2026-03-27 02:21:21 +04:00
Kylapaallikko d1df748de3 Update fi-FI translation.json
Added missing translations and fixed typos.
2026-03-26 18:06:43 +02:00
Tim Baek 4d058a125b Merge pull request #23035 from open-webui/dev
0.8.11
2026-03-26 02:49:59 +04:00
Timothy Jaeryang Baek f122525310 chore: bump 2026-03-25 17:48:58 -05:00
Timothy Jaeryang Baek f5f128620a chore: ci 2026-03-25 17:33:46 -05:00
Timothy Jaeryang Baek 06635898d0 refac 2026-03-25 17:29:57 -05:00
Timothy Jaeryang Baek 0ded0b7069 chore: ci 2026-03-25 17:14:13 -05:00
Timothy Jaeryang Baek 0fa246a1c3 refac 2026-03-25 17:09:36 -05:00
Timothy Jaeryang Baek 8cb47aebae refac 2026-03-25 16:58:02 -05:00
Classic298 bfc606a9e3 fix: align file context injection by user-role messages for native FC (#22776)
The add_file_context function used a positional zip() to pair API
payload messages with DB-stored messages. After
process_messages_with_output() expands assistant messages containing
tool calls into multiple OpenAI-format messages (assistant + tool
results), the payload list becomes longer than the stored list. This
caused the zip to misalign, so subsequent user messages never received
their attached_files tags -- the model could see uploaded images via
vision but had no file URL to pass to edit_image.

Fix: filter both lists to user-role messages only before zipping.
User messages maintain the same order in both lists regardless of
assistant message expansion, restoring correct file context injection.

Fixes #21878
2026-03-25 16:56:43 -05:00
Classic298 7a21933d10 chore: changelog (#22569)
* changelog: smoother loading animation

* changelog: add Finnish translations to 0.8.11

* changelog: shimmer animation, general improvements, finnish translation

* changelog: add migration reliability fix for issue #22568

* changelog: update Finnish, Portuguese (Portugal), and Catalan translations

* changelog: fix channel @mentions for direct connection models

* changelog: web search result limit control

* changelog: migration reliability, channel mentions, docker build, web search

* changelog: logit bias parameter handling fix

* changelog: add Turkish translations

* changelog: add terminal connection verification feature

* changelog: tool server index bounds checking fix

* changelog: add chat folder emoji reset feature

* changelog: add terminal access user visibility fix

* changelog: metrics export interval config

* changelog: knowledge base file previews

* changelog: user groups display, admin panel

* changelog: terminal server, connection confirmations, document loaders, memory search, SBOM

* changelog: remove docker build entry

* changelog: update date to 2026-03-12

* changelog: update date to 2026-03-15

* changelog: fix public note access 500 error

* changelog: mcp integration visibility, web search limit

* changelog: add Japanese to translations

* changelog: security, terminal proxy, path sanitization

* changelog: add ruff linter and formatter entry

* changelog: tool configuration access control security fix

* changelog: markdown file editing, file navigator

* changelog: add Simplified Chinese and Traditional Chinese to translations

* changelog: model profile image fallbacks fix

* changelog: model bulk actions menu

* changelog: files list pagination support

* changelog: temp chat tool calling fix

* changelog: add readiness probe endpoint for Kubernetes

* changelog: update svelte upgrade performance entry to clarify markdown rendering

* changelog: message queue responsiveness fix

* changelog: profile image fallbacks

* changelog: fix model drag-and-drop in admin settings

* changelog: reorder performance entry to top of Added section

* changelog: add Estonian translations

* changelog: security, model access control

* changelog: channel message attachments fix

* changelog: add collaborative document authorization security fix

* changelog: web fetch content length config

* changelog: oracle-23ai, hybrid-search, bugfix

* changelog: tool server connection persistence fix

* changelog: fix paginated list duplicates

* changelog: temporary chat, folder support, knowledge files

* changelog: temporary chat, folder support

* changelog: add Spanish to translations

* changelog: thread sidebar cleanup, channel attachments

* changelog: chat suggestion modal re-trigger fix

* changelog: dynamic HTML lang attribute fix

* changelog: file upload deduplication

* changelog: ollama anthropic endpoint support

* changelog: chat action button timing fix

* changelog: fix serper dev search results display

* changelog: markdown task list checkbox styling

* changelog: memory modal event bubbling fix

* changelog: webhook, channel, console-errors

* changelog: artifacts sidebar, URL indexing fixes

* changelog: emoji removal normalization fix

* changelog: prompts, pagination, search

* changelog: add Azerbaijani to translations

* changelog: search modal avatars fix

* changelog: filter icon display fix

* changelog: channel, messages, API

* changelog: skeleton loader fix

* changelog: knowledge, hybrid-search, reranking

* changelog: date update

* changelog: writing blocks, rendering, UI

* changelog: reasoning block, copy, text

* changelog: add commit links for writing blocks and copy cleanup

* changelog: sidebar menu, memory modal, oracle search

* changelog: task cancellation status tracking

* changelog: shared chat crash, public note access, filter icons

* changelog: plugin ID sanitization for Functions and Tools

* changelog: memory deletion confirmation dialog

* changelog: oauth session persistence

* changelog: chat, redirect, navigation

* changelog: functions list API optimization

* changelog: embed prompt, cross-origin, confirmation

* changelog: chat title preservation fix

* changelog: oauth sub claim fix

* changelog: read aloud crash fix, search modal TTS

* changelog: image-only message handling for Gemini Claude

* changelog: input variables modal crash fix

* changelog: functions API crash fix, input variables modal crash

* changelog: admin OAuth group sync

* changelog: trusted role header authentication config

* changelog: redis, model, race-condition

* changelog: user menu navigation, SPA routing fix

* changelog: input variables modal crash fix

* changelog: tools, embeds, context

* changelog: responses-api-streaming, rag-thinking-models

* changelog: responses API stateful sessions

* changelog: reorder Added entries - Responses API to top, major improvements highlighted

* changelog: restore duplicate Temporary chat folder support entry

* changelog: remove duplicate Temporary chat folder support entry

* changelog: fix Responses API entry link format

* changelog: remove unrelated issue link from Responses API entry

* changelog: add commit links to Responses API streaming improvements

* changelog: deduplicate 💬 emoji in Fixed section

* changelog: add multi-artifact HTML, drag chats, terminal prompts, terminal state entries

* changelog: tool, html, embed

* changelog: add file viewing pagination and knowledge search scoping entries

* changelog: update date to 2026-03-24

* changelog: tool binary response handling

* changelog: terminal file navigator, port previews, file rename

* changelog: tool server, timeout, configuration

* changelog: heartbeat, websocket, event loop

* changelog: password, security, validation

* changelog: oauth, mcp, discovery

* changelog: message upsert API crash fix

* changelog: oidc, authorization, oauth

* changelog: forward auth proxy compatibility

* changelog: remove duplicate Tool HTML embed context entry from Changed section

* changelog: terminal, file navigator, downloads

* changelog: reorder Responses API entries to top of Added section

* changelog: terminal folder downloads

* changelog: add German to localization entry

* changelog: chat render throttling performance

* changelog: oauth, security, access control

* changelog: tool call streaming display fix

* changelog: pyodide, file refresh, code execution

* changelog: google-oauth, session-persistence, refresh-tokens

* changelog: reasoning spinner, tool call display

* changelog: tool valves access control

* changelog: webhook, configuration, security

* changelog: pyodide, file list, refresh

* changelog: model lookup optimization

* changelog: offline code formatting, black formatter

* changelog: ollama, embedding, rag

* changelog: dropdown menu, submenu hover stability

* changelog: skill model mention persistence

* changelog: emoji audit, duplicate removal, positioning fixes for v0.8.11

* changelog: rag, thinking models, json extraction

* changelog: multi-system message merging fix

* changelog: MCP OAuth 2.1 static credentials support

* changelog: bump date to 2026-03-25

* changelog: qwen, system-messages, pipeline

* changelog: scim, group filtering, authentication

* changelog: chat, sidebar, refresh

* changelog: collapsible tool groups, chat UI

* changelog: forwarded-allow-ips, configuration, startup-scripts

* changelog: memory tool registration fix

* changelog: tool server, timeout, frontend

* changelog: ollama embedding retry logic

* changelog: model list auth cookie forwarding

* changelog: model tags, normalization, filter crash

* changelog: model tags, normalization, issue #20819

* changelog: model editor default metadata

* changelog: Microsoft Entra ID OAuth role claim preservation

* changelog: editor, paste, sanitization

* changelog: arena model sub-model settings

* changelog: chat history save optimization

* changelog: MCP OAuth tool auto-selection

* changelog: bulk model action reliability fix
2026-03-25 16:51:01 -05:00
Timothy Jaeryang Baek 9364e2fb74 refac 2026-03-25 16:48:57 -05:00
Timothy Jaeryang Baek 350d52f515 chore: format 2026-03-25 16:43:06 -05:00
Jacob Leksan 05252e19b5 refactor: streamline logging and permission checks in auths.py (#22960) 2026-03-25 16:34:45 -05:00
Algorithm5838 abe42eaf09 perf: fix double-RAF streaming display lag (#23016) 2026-03-25 16:13:31 -05:00
Timothy Jaeryang Baek f2f4baa89a refac 2026-03-25 05:13:41 -05:00
Timothy Jaeryang Baek 15ae3f588b refac 2026-03-25 04:57:02 -05:00
Timothy Jaeryang Baek 75932be880 refac 2026-03-25 04:47:52 -05:00
Algorithm5838 98c7ed965b perf: remove getChatList from saveChatHandler (#22983) 2026-03-25 04:35:55 -05:00
Timothy Jaeryang BaekandTaylor Wilsdon 4d50001c41 refac
Co-Authored-By: Taylor Wilsdon <6508528+taylorwilsdon@users.noreply.github.com>
2026-03-25 04:32:55 -05:00
Timothy Jaeryang Baek 857d7e6f37 refac 2026-03-25 02:49:34 -05:00
Timothy Jaeryang Baek 08ff3bd30f refac 2026-03-25 02:20:33 -05:00
Timothy Jaeryang Baek debcc3a652 refac 2026-03-24 23:50:27 -05:00
Timothy Jaeryang Baek 7b78c641fe refac 2026-03-24 22:30:57 -05:00
Timothy Jaeryang Baek 94f877ff32 refac 2026-03-24 22:30:51 -05:00
Timothy Jaeryang Baek aa2f7fbe52 refac 2026-03-24 21:32:04 -05:00
Timothy Jaeryang Baek cdc2b3bf85 refac 2026-03-24 21:00:43 -05:00
Timothy Jaeryang Baek 90ca2e9b0f refac 2026-03-24 20:48:10 -05:00
Timothy Jaeryang Baek 76ece4049e refac 2026-03-24 20:32:23 -05:00
Timothy Jaeryang Baek 1cf1b2ca17 refac 2026-03-24 20:25:47 -05:00
Timothy Jaeryang Baek 8b6fa1f4ab refac 2026-03-24 20:14:28 -05:00
Timothy Jaeryang Baek 58dcc1b33f refac 2026-03-24 20:06:43 -05:00
Timothy Jaeryang Baek cb83831041 chore: pin deps 2026-03-24 20:04:12 -05:00
Timothy Jaeryang Baek adf7af34ff refac 2026-03-24 19:56:59 -05:00
Timothy Jaeryang Baek d933991904 refac 2026-03-24 19:50:46 -05:00
Algorithm5838 440b64088b perf: remove redundant getChatList call from sendMessage (#22982) 2026-03-24 19:47:30 -05:00
Timothy Jaeryang Baek 0aebdd5f83 refac 2026-03-24 19:43:30 -05:00
Timothy Jaeryang Baek 261aec8c86 enh: consecutive details rendering 2026-03-24 19:26:14 -05:00
Timothy Jaeryang Baek 61cfaab915 refac 2026-03-24 18:42:58 -05:00
Timothy Jaeryang Baek 968462609f refac 2026-03-24 18:05:19 -05:00
Timothy Jaeryang BaekandJoost Mul 601bb78358 feat: oauth 2.1 static mcp
Co-Authored-By: Joost Mul <6438009+donmul@users.noreply.github.com>
2026-03-24 17:58:21 -05:00
Timothy Jaeryang Baek 631bd20c35 refac 2026-03-24 17:46:33 -05:00
Timothy Jaeryang Baek c0fcbc5b4c refac 2026-03-24 17:31:09 -05:00
Timothy Jaeryang Baek be21db7069 refac 2026-03-24 17:25:52 -05:00
Timothy Jaeryang Baek 8507e5eb0d refac: include black 2026-03-24 17:18:52 -05:00
Timothy Jaeryang Baek cffbc3558e refac 2026-03-24 17:10:27 -05:00
Timothy Jaeryang Baek d738044f47 refac 2026-03-24 17:03:08 -05:00
Algorithm5838 e1cdd7e4fe fix: use full_output() in reasoning content delta emit (#23001) 2026-03-24 17:00:12 -05:00
Timothy Jaeryang Baek 94145c99ae enh: GOOGLE_OAUTH_AUTHORIZE_PARAMS 2026-03-24 16:27:26 -05:00
Timothy Jaeryang Baekandalifurkanstahl 7eae377c01 perf: lookup
Co-Authored-By: alifurkanstahl <180474740+alifurkanstahl@users.noreply.github.com>
2026-03-24 16:24:46 -05:00
Timothy Jaeryang Baek 2376258f02 refac 2026-03-24 16:23:12 -05:00
Timothy Jaeryang Baek 5c4062c648 refac 2026-03-24 16:20:29 -05:00
joaoback cb7154cedf i18n: add pt-BR translations for newly added UI items and consistency pass (#22989)
New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.
2026-03-24 15:59:15 -05:00
Shamil e4de5c5ad1 refac (#22987) 2026-03-24 15:41:26 -05:00
Tim Baek c24a4da17d refac 2026-03-24 14:54:29 -05:00
Tim Baek f949d17db1 refac 2026-03-24 14:37:03 -05:00
Timothy Jaeryang Baek 6d7744c219 refac 2026-03-24 06:21:37 -05:00
Classic298 6f06b3d5ed Update translation.json (#22985) 2026-03-24 06:13:26 -05:00
Algorithm5838 7ce1e9415a perf: batch history reactive triggers to rAF in chatEventHandler (#22947) 2026-03-24 06:12:37 -05:00
Timothy Jaeryang Baek a9c5c787b9 refac 2026-03-24 06:12:07 -05:00
Timothy Jaeryang Baek f7e07f3ca1 chore: format 2026-03-24 06:07:20 -05:00
Timothy Jaeryang Baek 9f946dec60 refac 2026-03-24 06:04:29 -05:00
Timothy Jaeryang Baek 7bcfafa6c5 refac 2026-03-24 06:01:45 -05:00
Timothy Jaeryang Baek 4a70aaa162 refac 2026-03-24 05:53:34 -05:00
Timothy Jaeryang Baek 12f0ad28bf refac 2026-03-24 05:48:43 -05:00
Timothy Jaeryang Baek cf60b1882f refac 2026-03-24 05:43:22 -05:00
Timothy Jaeryang Baek c479c22438 refac 2026-03-24 05:38:45 -05:00
Timothy Jaeryang Baek 3841e85abb refac 2026-03-24 05:34:36 -05:00
Timothy Jaeryang Baek 24370d5c40 refac 2026-03-24 05:21:42 -05:00
Timothy Jaeryang Baek 69171a4c8b refac 2026-03-24 05:13:31 -05:00
Timothy Jaeryang Baek bd8aa3b6a0 fix: new password validation 2026-03-24 05:00:54 -05:00
Timothy Jaeryang Baek fe7e002fea refac 2026-03-24 04:58:48 -05:00
Timothy Jaeryang Baek 16ee4ac7a2 refac 2026-03-24 04:57:02 -05:00
Timothy Jaeryang Baek 70285fb6ca refac 2026-03-24 04:53:23 -05:00
Timothy Jaeryang Baek ade617efa8 refac 2026-03-24 04:49:48 -05:00
Algorithm5838 f0d48a4295 perf: use asyncio.to_thread for heartbeat DB write (#22980) 2026-03-24 04:48:06 -05:00
Timothy Jaeryang Baek 139e764b2f refac 2026-03-23 23:39:52 -05:00
Timothy Jaeryang Baek 3a4b862e81 refac 2026-03-23 22:35:26 -05:00
Timothy Jaeryang Baek 637cd136c2 refac 2026-03-23 22:28:17 -05:00
Timothy Jaeryang Baek 1dc647f43b refac 2026-03-23 22:00:37 -05:00
Timothy Jaeryang Baek 6890618221 refac 2026-03-23 21:14:22 -05:00
Timothy Jaeryang Baek a3238aa79f refac 2026-03-23 20:42:48 -05:00
Timothy Jaeryang Baek 108a019cb8 refac 2026-03-23 19:58:32 -05:00
Timothy Jaeryang Baek 1c25b06dca refac 2026-03-23 19:46:24 -05:00
Timothy Jaeryang Baek 36c3fc58b5 refac 2026-03-23 19:29:34 -05:00
Timothy Jaeryang Baek 0f0ba7dadd refac 2026-03-23 16:56:50 -05:00
Timothy Jaeryang Baek 5d7766e1b6 refac 2026-03-23 16:46:54 -05:00
Timothy Jaeryang Baek eca51269bb refac 2026-03-23 16:21:21 -05:00
Timothy Jaeryang Baek d577ff1e4a reafc 2026-03-22 22:50:44 -05:00
Timothy Jaeryang Baek 6a9d67b5bb refac 2026-03-22 22:10:04 -05:00
Timothy Jaeryang Baek ebb7ce2092 refac 2026-03-22 22:01:38 -05:00
Timothy Jaeryang Baek 9a6bf78e14 refac 2026-03-22 21:36:45 -05:00
Timothy Jaeryang Baek 59171daa35 refac 2026-03-22 06:59:56 -05:00
Timothy Jaeryang Baek 945275faae refac 2026-03-22 06:58:58 -05:00
Timothy Jaeryang Baek 2e165926de refac 2026-03-22 06:40:39 -05:00
Timothy Jaeryang Baek dfc2dc2c0b refac 2026-03-22 06:29:31 -05:00
Timothy Jaeryang Baek d784eb1e9b refac 2026-03-22 06:14:05 -05:00
Timothy Jaeryang Baek 6a004205d8 enh: inline html result context 2026-03-22 06:06:02 -05:00
Timothy Jaeryang Baek ee9099cab9 refac 2026-03-22 06:00:41 -05:00
Timothy Jaeryang Baek ee901fcd2c refac 2026-03-22 05:48:05 -05:00
Timothy Jaeryang Baek 7ffcd3908e refac 2026-03-22 05:44:13 -05:00
Timothy Jaeryang Baek 0dcd6ac983 refac 2026-03-22 05:39:53 -05:00
Timothy Jaeryang Baek 93415a48e8 refac 2026-03-21 20:46:25 -05:00
Timothy Jaeryang Baek f8b3a32caf refac 2026-03-21 20:25:52 -05:00
Timothy Jaeryang Baek 2ae47cf200 refac 2026-03-21 20:20:20 -05:00
Timothy Jaeryang Baek adcbba34f8 refac 2026-03-21 20:03:02 -05:00
Timothy Jaeryang Baek 218bd7a402 refac 2026-03-21 19:53:45 -05:00
Timothy Jaeryang Baek 464462b22e enh: WEBUI_AUTH_TRUSTED_ROLE_HEADER 2026-03-21 19:50:50 -05:00
Timothy Jaeryang Baek a1aceb5f87 refac 2026-03-21 19:42:48 -05:00
Algorithm5838 52e227f425 fix: add from_attributes to FunctionResponse (#22924) 2026-03-21 19:38:41 -05:00
Timothy Jaeryang Baek ea515fa26e refac 2026-03-21 19:17:21 -05:00
Timothy Jaeryang Baek cc8b5055f2 refac 2026-03-21 19:04:39 -05:00
Timothy Jaeryang Baek d8fa0f426a refac 2026-03-21 18:59:25 -05:00
Timothy Jaeryang Baek aa59b32374 refac 2026-03-21 18:49:22 -05:00
Timothy Jaeryang Baek 877bc23afc refac 2026-03-21 18:42:55 -05:00
Classic298 0ad448b8a4 enh: allow iframe postMessage prompts without same-origin (with HITL confirmation) (#22908)
* Update Chat.svelte

* Update Chat.svelte
2026-03-21 18:24:24 -05:00
Timothy Jaeryang Baek 93407ba316 refac 2026-03-21 18:21:25 -05:00
Timothy Jaeryang Baek 78dbad5e1e refac 2026-03-21 18:11:08 -05:00
Timothy Jaeryang Baek 5b026b2a3e refac 2026-03-21 18:10:01 -05:00
Timothy Jaeryang Baek bb3526f4e4 refac 2026-03-21 17:59:44 -05:00
G30 17c819a3c2 feat: add confirmation dialog for single memory entry deletion (#22888)
* feat(ui): add confirmation dialog for memory deletion

* fix
2026-03-21 17:48:30 -05:00
Timothy Jaeryang Baek 4c8615f01c refac 2026-03-21 17:45:36 -05:00
Shamil 85411e4867 chore: align black with Ruff backend formatting (#22766) 2026-03-21 17:42:37 -05:00
Timothy Jaeryang Baek 4d67c817ec refac 2026-03-21 17:41:22 -05:00
Algorithm5838 8b4ea5bb78 fix: guard chat:tasks:cancel handler with message_id check (#22743) 2026-03-21 17:37:23 -05:00
Timothy Jaeryang Baek b44eacbc5a refac 2026-03-21 17:35:41 -05:00
Timothy Jaeryang Baek 4f0e574201 refac 2026-03-21 17:26:30 -05:00
Timothy Jaeryang Baek 53b8a1f71b enh: colon fence md 2026-03-21 17:23:38 -05:00
Timothy Jaeryang Baek 9a2c60d595 refac 2026-03-21 17:12:33 -05:00
Timothy Jaeryang Baek 5df4277216 refac 2026-03-21 16:58:54 -05:00
G30 6769b1967c fix(ui): show model avatars in chat search preview modal (#22782) 2026-03-21 16:53:50 -05:00
Algorithm5838 2c80d95c53 fix: wrong icon shown after removing a toggled filter (#22862) 2026-03-21 16:50:54 -05:00
G30 124e1ea4a7 fix(ui): refine message data lazy loading to prevent 404s (#22894) 2026-03-21 16:50:21 -05:00
7674e4f093 i18n: add Azerbaijani (az-AZ) translation (#22792)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: m.shabanov <m.shabanov@asan.local>
2026-03-21 16:45:43 -05:00
G30 0afb8f681b fix(ui): reset page to 1 when searching prompts (#22912) 2026-03-21 16:43:39 -05:00
157ae917eb fix: replace legacy surrogate-pair emoji regex with Unicode property escape (#22915)
The previous regular expression used manual surrogate-pair ranges to
match emojis and missed a large category of commonly used symbols:

  /[\uD800-\uDBFF][\uDC00-\uDFFF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDE4F]/g

This approach only covers emojis encoded as surrogate pairs (U+1F000 –
U+1F4FF range), but silently skips BMP emojis that use a text-
presentation code point followed by the variation selector U+FE0F,
such as ❤️ (U+2764 U+FE0F), ☀️, ✅, ⚡, ⭐, and keycap sequences
like 1️⃣, as well as ZWJ family sequences (👨‍👩‍👧‍👦) and flag sequences.

Replace with the Unicode property escape \p{RGI_Emoji} using the 'v'
(unicodeSets) flag introduced in ES2024. This single pattern covers
every standardised emoji sequence defined by Unicode, including all
the cases above.

Browser support: Chrome 112+, Firefox 116+, Safari 17+, Node.js 20+.
All browsers targeted by open-webui already support this syntax.

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: yoloni <yoloni@tencent.com>
2026-03-21 16:43:09 -05:00
biebiep f593f92f18 FIX: serper.dev API - Change snippet key from 'description' to 'snippet' (#22869)
This has apparently been broken since forever and native tool calling made it a lot more apparent.
2026-03-20 18:54:16 -05:00
Shirasawa af0b7d4683 fix: Prevent task list checkboxes from shrinking for consistent alignment (#22886) 2026-03-20 18:54:03 -05:00
Timothy Jaeryang Baek 00cb7f5104 refac 2026-03-20 18:53:35 -05:00
Timothy Jaeryang Baek 068e52f877 refac 2026-03-20 18:53:26 -05:00
Timothy Jaeryang Baek fe772d95e2 refac 2026-03-20 18:43:17 -05:00
Timothy Jaeryang Baek ecba37070d refac 2026-03-20 17:05:47 -05:00
Timothy Jaeryang Baek f23296b22d refac 2026-03-20 16:30:41 -05:00
Timothy Jaeryang Baek 10f06a64fe refac 2026-03-20 16:10:00 -05:00
G30 8f3144adb5 fix(ui): close thread sidebar on parent message deletion (#22890) 2026-03-20 15:48:50 -05:00
G30 6089a55da6 fix(ui): chat input triggers correctly re-trigger on backspace (#22899) 2026-03-20 15:48:25 -05:00
Classic298 c81b3ef9ce sec (#22897) 2026-03-20 15:47:50 -05:00
Timothy Jaeryang Baek de5e0fbc00 refac 2026-03-20 15:47:22 -05:00
Timothy Jaeryang Baek c53cd78dcb refac 2026-03-19 18:06:43 -05:00
9793315e0b i18n: correct Spanish (es-ES) translation errors (#22870)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-03-19 17:57:53 -05:00
Timothy Jaeryang Baek 694fb3776f refac 2026-03-19 17:56:05 -05:00
Timothy Jaeryang Baek adcc50d337 refac 2026-03-19 15:56:43 -05:00
Timothy Jaeryang Baek 6b66cb5ef6 refac 2026-03-17 20:34:38 -05:00
Timothy Jaeryang Baek 58e78e8946 refac 2026-03-17 18:52:02 -05:00
Timothy Jaeryang Baek b8ea267f8e refac 2026-03-17 18:01:18 -05:00
Timothy Jaeryang Baek de3317e26b refac 2026-03-17 17:58:01 -05:00
Timothy Jaeryang Baek fcf7208352 refac 2026-03-17 17:56:15 -05:00
Timothy Jaeryang Baek b171b0216b refac 2026-03-17 17:54:59 -05:00
G30 b062235d0c fix(channel): allow sending image-only messages without text (#22752) 2026-03-17 17:37:41 -05:00
Timothy Jaeryang Baek 3107a5363d refac 2026-03-17 17:37:20 -05:00
Timothy Jaeryang Baek c0385f60ba refac 2026-03-17 16:52:14 -05:00
IllimarRandIllimar Reinbusch 30068afd78 i18n(et-EE): update Estonian translations (#22731)
Added missing translations and fixed incorrect ones.

Co-authored-by: Illimar Reinbusch <illimar.reinbusch@tootukassa.ee>
2026-03-16 14:00:24 -05:00
Timothy Jaeryang Baek e3f3929198 refac 2026-03-16 01:23:02 -05:00
Timothy Jaeryang Baek 54f7861b2e refac 2026-03-16 01:04:17 -05:00
Timothy Jaeryang Baek 68973f9b39 refac 2026-03-15 23:48:44 -05:00
Timothy Jaeryang Baek a361df840e refac 2026-03-15 23:37:45 -05:00
Timothy Jaeryang Baek b99f8dabdd refac 2026-03-15 23:03:30 -05:00
Timothy Jaeryang Baek be6bf76105 refac 2026-03-15 22:33:26 -05:00
Timothy Jaeryang Baek 38cc6e4762 refac 2026-03-15 22:25:22 -05:00
Timothy Jaeryang Baek e53152123f refac 2026-03-15 21:54:35 -05:00
Timothy Jaeryang Baek 07e650c787 refac 2026-03-15 21:29:56 -05:00
Timothy Jaeryang Baek 753589e51c refac 2026-03-15 21:09:55 -05:00
Timothy Jaeryang Baek 3dea69f658 refac 2026-03-15 20:41:32 -05:00
G30 bef5ec2cea fix: add profile image fallback handlers for model and user avatars in remaining areas (#22486)
* fix(ui): add profile image fallback handlers for models and users

* Update UserList.svelte

* fix(ui): add profile image fallback handlers for models on New Chat page
2026-03-15 20:04:56 -05:00
Eliot GODARD 9465e2918b i18n(fr-FR): complete French translation (#22714)
Translate all missing keys in the fr-FR locale file across all components.
2026-03-15 20:04:21 -05:00
Timothy Jaeryang Baek 486c004cbb refac 2026-03-15 18:49:49 -05:00
Jacob Leksan 14d876c259 Added Readiness probe (#22507) 2026-03-15 18:17:57 -05:00
Timothy Jaeryang Baek 5787c969f6 refac 2026-03-15 18:03:50 -05:00
Ethan T. a229f9ea42 fix: replace bare except with except Exception (#22473)
Replace bare except clauses with except Exception to follow Python best practices and avoid catching unexpected system exceptions like KeyboardInterrupt and SystemExit.
2026-03-15 17:48:23 -05:00
Timothy Jaeryang Baek bcd313c363 refac 2026-03-15 17:42:57 -05:00
Shirasawa 02d9d07900 i18n: improve Chinese translation (#22480) 2026-03-15 17:37:19 -05:00
Timothy Jaeryang Baek bdb7d48cff chore: svelte bump 2026-03-15 17:36:57 -05:00
G30 bc9ee7b4d9 feat(ui): add bulk actions hamburger menu to workspace Models page (#22484)
Add hamburger menu with bulk actions (Enable All, Disable All, Show All,
Hide All) to the workspace Models page filter bar, matching the
functionality from the admin Settings Models page.
2026-03-15 17:36:45 -05:00
G30 b535275b17 fix(ui): add icon fallback for missing model icons on Models pages (#22485) 2026-03-15 17:36:04 -05:00
Timothy Jaeryang Baek f9756de693 refac 2026-03-15 17:35:06 -05:00
Shamil b73010bb36 feat: add ruff linter & formatter (#22576)
* chore: add ruff linter with base config

* ci: add ruff pre-commit hook

* ci: add ruff github actions workflow

* ci: run ruff only on changed files
2026-03-15 17:22:27 -05:00
Timothy Jaeryang Baek bc5b3ec6b8 refac 2026-03-15 17:21:14 -05:00
Timothy Jaeryang Baek 7611762e04 doc: sec 2026-03-15 17:16:18 -05:00
Timothy Jaeryang Baek 1eef5b4f6a refac 2026-03-15 17:08:24 -05:00
Toru Suzuki a43d98965b i18n: Update Japanese translation (#22696) 2026-03-15 17:01:26 -05:00
Timothy Jaeryang Baek 566e25569e refac 2026-03-15 16:58:01 -05:00
Timothy Jaeryang Baek 47e47e42af refac 2026-03-15 16:54:54 -05:00
Timothy Jaeryang Baek f9d38a073f refac 2026-03-15 16:51:51 -05:00
Timothy Jaeryang Baek 47ab4c71d5 refac 2026-03-14 02:48:30 -05:00
Timothy Jaeryang Baek 39100eca49 Create generate-sbom.sh 2026-03-13 22:09:10 -05:00
Timothy Jaeryang Baek 6862d618ee refac 2026-03-13 20:57:12 -05:00
Timothy Jaeryang Baek 157ff57c40 refac 2026-03-13 16:28:01 -05:00
Timothy Jaeryang Baek d85b52bfc2 refac 2026-03-12 20:37:41 -05:00
jqqinandQinQin 05f314bae4 fix: prevent user groups overflow in profile preview (#22547)
Add flex-wrap, max-h-20, and overflow-y-auto to the groups container
in UserStatus.svelte to prevent horizontal overflow when users have
many groups. Groups now wrap to multiple rows with a scrollbar when
exceeding the max height.

Co-authored-by: QinQin <qinqin@geotab.com>
2026-03-12 17:04:13 -05:00
Athanasios Oikonomou ee0d9b7915 feat: knowledge - clickable file icon opens content (#22629)
Replaces the static document icon with an interactive button that opens
the file content in a new tab via the files API endpoint.
2026-03-12 16:58:02 -05:00
Timothy Jaeryang Baek f3402d3f1f refac 2026-03-11 18:54:18 -05:00
Timothy Jaeryang Baek dbd0d7d742 refac 2026-03-11 17:17:35 -05:00
Timothy Jaeryang Baek afa0609ece feat: support whitelist filtering in AuditLoggingMiddleware (#22515)
Add AUDIT_INCLUDED_PATHS env var for whitelist-based audit filtering.
When set, only matching paths are audited and AUDIT_EXCLUDED_PATHS is
ignored. Auth endpoints (signin/signout/signup) are always logged
regardless of filtering mode.
2026-03-11 15:41:42 -05:00
Jan Kessler 1b1abdd30c avoid silent failure of pip/uv install in Dockerfile (#22598) 2026-03-11 15:40:54 -05:00
Timothy Jaeryang Baek 4a8f995c3f refac 2026-03-11 15:35:49 -05:00
Steve-Li-1998 7ea1e9cbd0 fix: Prefer model-provided web search result count over admin default (#22577)
* Prefer model-provided web search result count over admin default

Update `search_web` to prioritize the model-provided `count` parameter before falling back to the admin-configured `WEB_SEARCH_RESULT_COUNT`, and finally defaulting to 5.

Changes:
- Set `count` default to `None` instead of `5`.
- Adjust fallback order to: model-provided `count` → admin-configured value → `5`.
- Update comment to reflect the new precedence logic.

This ensures explicit model requests for result count are respected while preserving sensible defaults.

* Enforce maximum web search result count from config

Update `search_web` to cap the model-provided `count` parameter at the admin-configured `WEB_SEARCH_RESULT_COUNT` to prevent excessive result requests.

Changes:
- Set default `count` parameter to `5`.
- Replace fallback logic with enforcement logic that limits `count` to the configured maximum.
- Update comment to reflect that the result count is now capped to prevent abuse.

This ensures web search requests cannot exceed the configured limit while maintaining a sensible default.
2026-03-11 15:34:24 -05:00
Timothy Jaeryang Baek e34ed72e1e refac 2026-03-11 15:33:24 -05:00
Timothy Jaeryang Baek 865880a0b1 refac: allow emoji unselect from folder 2026-03-11 15:30:57 -05:00
Shamil 3a6b5ebb5f refac: modernize type hints and imports in access_control module (#22594) 2026-03-11 15:28:39 -05:00
alifurkanstahlandMSI I9 12900KS RTX cee645017d i18n(tr-TR): improve Turkish translations - increase coverage and fix typos/terminology errors (#22544)
* i18n: complete Turkish translations for {{}} placeholder strings

* i18n(tr-TR): fix multiple translation errors

* i18n(tr-TR): add missing translations for Y section

* i18n(tr-TR): add missing translations for Model section

* i18n(tr-TR): add missing Turkish translations for Image section

* i18n(tr-TR): translate Knowledge section strings in Turkish

* i18n(tr-TR): translate upload-related strings in Turkish

* 18n: translate Invalid error messages to Turkish.

* i18n(tr-TR):add Turkish translations for Enter input fields

* i18n(tr-TR): add missing translations for "No" strings

* i18n(tr-TR): replace "istem" with "prompt" for consistency

* i18n(tr-TR): standardize "ID" terminology in Turkish translations

* i18n(tr-TR): add missing translations for "S" strings

* i18n(tr-TR): add missing translations for basic UI and navigation element

* i18n(tr-TR): add missing translations for C-D sections

* i18n(tr-TR): add missing translations for E-F sections

* i18n(tr-TR): standardize "Chunk" terminology to "Parça"

---------

Co-authored-by: MSI I9 12900KS RTX <alifurkanstahl@users.noreply.github.com>
2026-03-11 15:27:24 -05:00
Lorenzo Maffioli 035b981e11 feat(otel): introduce an environment variable to control the export interval of otel metrics (#22529) 2026-03-11 15:27:06 -05:00
Timothy Jaeryang Baek 8da29566a1 refac: safer tool server handling 2026-03-11 15:22:51 -05:00
Timothy Jaeryang Baek f2217da94e enh: verify terminal connection 2026-03-11 15:19:41 -05:00
Timothy Jaeryang Baek b312318a99 refac: rm mariadb dep 2026-03-11 14:43:48 -05:00
Timothy Jaeryang Baek 0a87c1ecd0 refac 2026-03-11 14:41:33 -05:00
Aleix Dorca a87f015246 i18n: Update Catalan translation.json (#22570) 2026-03-11 14:25:41 -05:00
Timothy Jaeryang Baek f1c1004225 fix: ddgs 2026-03-11 14:25:18 -05:00
Classic298 06657b8109 fix: handle non-dict history/messages in chat_message migration (#22588)
Some databases contain chat records where 'history' or 'messages' are
stored as lists instead of dicts. This causes an AttributeError
('list' object has no attribute 'items') during the
8452d01d26d7_add_chat_message_table migration.

Add isinstance checks to skip chat records with unexpected data shapes
gracefully, matching the existing pattern used for individual message
validation.
2026-03-11 14:24:21 -05:00
a407a7f1c0 Updated pt-PT Translation (#22587)
Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-03-11 14:24:08 -05:00
Jannik S. 418bd05ae0 refac: refined shimmer effect (#22516)
Rework shimmer animation timing and gradient for a smoother sweep.
Slower start with acceleration through the text using a custom
cubic-bezier curve. Softer highlight contrast in both light and
dark modes.
2026-03-10 20:05:51 -05:00
Kylapaallikko 86cce2cd88 Update fi-FI translation.json (#22542)
Added missing translations, minor fixes and translation consistency updates.
2026-03-10 20:05:08 -05:00
Timothy Jaeryang Baek 8970923940 refac 2026-03-09 19:08:32 -05:00
Timothy Jaeryang Baek 83fad5e9f7 refac 2026-03-09 18:45:33 -05:00
557 changed files with 87859 additions and 46646 deletions
+6
View File
@@ -10,6 +10,12 @@
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
+3 -6
View File
@@ -40,10 +40,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install black
pip install "ruff>=0.15.5"
- name: Format backend
run: npm run format:backend
- name: Check for changes after format
run: git diff --exit-code
- name: Ruff format check
run: ruff format --check . --exclude .venv --exclude venv
+8
View File
@@ -0,0 +1,8 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.5
hooks:
- id: ruff
args: [--fix, backend]
- id: ruff-format
args: [backend]
+537 -1
View File
@@ -5,6 +5,543 @@ 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.9.3] - 2026-05-09
### Added
- 🔇 **Voice Mode mute control.** Voice Mode now includes a dedicated mute toggle with an "M" shortcut and auto-unmute after assistant playback, so you can prevent accidental interruptions from background noise without leaving the call overlay. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c), [#23832](https://github.com/open-webui/open-webui/issues/23832)
- 🚀 **Faster prompt list loading.** Prompt and prompt-tag pages now load much faster for non-admin users, even with large prompt libraries, because accessible prompts are filtered efficiently in a single database query. [#24288](https://github.com/open-webui/open-webui/pull/24288), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
- ⚡ **Faster chat history loading.** Chat history maps now load from normalized message records when available, reducing overhead for large conversations while preserving fallback behavior for legacy chats. [Commit](https://github.com/open-webui/open-webui/commit/485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f), [#23159](https://github.com/open-webui/open-webui/pull/23159)
- 🗑️ **Delete from conversation menu.** You can now delete the current conversation directly from the chat menu with a confirmation step, so cleanup is faster without searching through the full chat list. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4), [#24329](https://github.com/open-webui/open-webui/issues/24329)
- ⬆️ **Scroll to Top shortcut.** Long conversations now include a Scroll to Top action in the chat menu when you are away from the top, making it much faster to jump back to the beginning of a chat. [Commit](https://github.com/open-webui/open-webui/commit/cdfcbc4af6e9aec835b88dc1806a2a46711e6947), [#24133](https://github.com/open-webui/open-webui/issues/24133)
- 📅 **Calendar creation flow.** Users can now create calendars from a dedicated modal and a quick-add action in the calendar sidebar, making calendar setup faster from the calendar workspace. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840)
- 🧭 **Unified model unload controls.** Administrators can now unload running models from the model selector across supported providers, with loaded-state indicators shown for Ollama and llama.cpp models. [Commit](https://github.com/open-webui/open-webui/commit/4fe2de78643c2213652190d2820f4e8d9f4f89cc)
- ⚡ **Health check responsiveness.** Health and readiness probes now avoid blocking database calls and skip sync session commit handling on probe paths, improving responsiveness and reducing false unready transitions during database pressure. [#24380](https://github.com/open-webui/open-webui/pull/24380), [#24384](https://github.com/open-webui/open-webui/pull/24384)
- 🎛️ **Playground controls panel.** The Playground now includes a dedicated Controls toggle so you can adjust parameters like temperature and related settings per chat run without changing model-level defaults. [Commit](https://github.com/open-webui/open-webui/commit/c6763521c00f042a28829e33fb6f1b7355054046), [#24103](https://github.com/open-webui/open-webui/issues/24103)
- 🎙️ **STT file extension controls.** Administrators can now configure which audio file extensions are accepted for speech-to-text uploads, helping enforce safer and more predictable upload policies. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
- 📷 **Remembered call camera selection.** Voice call overlay now remembers your last selected camera and restores it automatically when available, so you do not need to reselect it every time you start voice mode. [Commit](https://github.com/open-webui/open-webui/commit/5c3edc2539ac4d92c4cc2d37079549995203238a), [#24416](https://github.com/open-webui/open-webui/issues/24416)
- 👥 **User group prompt variable.** System and template prompts now support the "{{USER_GROUPS}}" variable, which expands to the user’s group memberships so prompts can adapt to role- or access-based context automatically. [Commit](https://github.com/open-webui/open-webui/commit/c1202a23277abb8e7080271a929dcc9d29b67e66), [#24462](https://github.com/open-webui/open-webui/issues/24462)
- 🔐 **Public chat sharing permission control.** Administrators can now control whether users are allowed to create publicly shareable chats through a dedicated permission setting. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4)
- 🔐 **Profile image forwarding control.** Administrators can now disable external profile image URL forwarding with the "ENABLE_PROFILE_IMAGE_URL_FORWARDING" setting to prevent browser metadata leaks to third-party servers. [#24420](https://github.com/open-webui/open-webui/pull/24420)
- 🏷️ **Dynamic header template variables.** Administrators can now use chat, message, and user template variables in custom connection and tool server headers so each request can carry per-conversation context automatically. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
- 🛂 **MCP OAuth server URL setting.** Static OAuth tool server setups can now define a separate OAuth server URL, making discovery and client registration work when authentication endpoints are hosted separately from the tool server URL. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164), [#24216](https://github.com/open-webui/open-webui/issues/24216)
- ⚡ **Faster memory query performance.** Per-user memory lookups and deletions now run much faster at scale because the memory user filter is indexed for existing and new installations. [Commit](https://github.com/open-webui/open-webui/commit/38a382ef888685650135d61dcc8ec0e29eb65573), [#23836](https://github.com/open-webui/open-webui/pull/23836)
- 🚀 **Smarter function dependency installs.** Function dependencies are now skipped when they were already preinstalled and unchanged, reducing first-load delays and repeated package installation churn after startup. [Commit](https://github.com/open-webui/open-webui/commit/ae43562b869b24699408e5ab107261a0a8bdb4bc), [#24166](https://github.com/open-webui/open-webui/pull/24166)
- 🔎 **Brave LLM Context web search.** Administrators can now choose Brave LLM Context as a web search provider to retrieve richer grounded passages with a configurable context token budget. [Commit](https://github.com/open-webui/open-webui/commit/6700f7bb72d14a3f8dbb72dfa064cae3b3dc29ac), [#24120](https://github.com/open-webui/open-webui/issues/24120)
- 🗂️ **Open Terminal date sorting.** Open Terminal now includes sort controls for name and date, with directory-first ordering and modified-time visibility to make file browsing faster. [Commit](https://github.com/open-webui/open-webui/commit/6bdc2ffa79d72daf78981209c9c5292c697cbfe5), [#24425](https://github.com/open-webui/open-webui/issues/24425)
- 🎤 **Voice mode prompt toggle.** Administrators can now explicitly enable or disable the Voice Mode custom prompt behavior from Interface settings, giving finer control over how voice replies are guided. [Commit](https://github.com/open-webui/open-webui/commit/17893038869e3a763a8b34457f723b9666804e27)
- 🧮 **LaTeX copy shortcut.** You can now click rendered LaTeX expressions to copy the raw formula to your clipboard, making it easier to reuse equations outside chat. [Commit](https://github.com/open-webui/open-webui/commit/064fdecb675c176a04b024c16ce179f4dda45236), [#24244](https://github.com/open-webui/open-webui/pull/24244)
- ✨ **Smoother rich text editing.** The message composer now defers formatting toolbar refresh work to the next animation frame, reducing typing jank while formatting controls stay accurate. [Commit](https://github.com/open-webui/open-webui/commit/794b97025d4c56f91d49c9d1ec4775d2ea07b53a), [#24013](https://github.com/open-webui/open-webui/pull/24013)
- 🖼️ **Arena model profile images.** Arena models can now reliably display configured profile images instead of falling back to the default icon. [Commit](https://github.com/open-webui/open-webui/commit/1dee67b64d0b34e70bac949682b216c0aaec8152), [#24412](https://github.com/open-webui/open-webui/issues/24412)
- 🔄 **Replaceable tool embed updates.** Pipes and Tools can now overwrite previously emitted rich-UI embeds in-place by passing a `replace` flag on the `embeds` event, enabling live dashboards and progress panels that update without stacking duplicate entries.
- ✏️ **Assistant response editing and continuation.** You can now edit and restructure assistant output items — including reasoning blocks, tool calls, and text content — from a dedicated editor view, and continue generating from the edited state so the model receives full prior context.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Chinese, Catalan, Filipino, and Korean were enhanced and expanded.
### Fixed
- 🧵 **Background code execution reliability.** Code execution no longer hangs indefinitely when you switch conversations or browser tabs during a run, and disconnected or inactive sessions now fail with a clear timeout error instead of endless processing. [Commit](https://github.com/open-webui/open-webui/commit/552bbcecfae5ae273ab98e2ce3e540d0771aa964), [#24089](https://github.com/open-webui/open-webui/issues/24089)
- 🎙️ **Voice recording MIME fallback support.** Voice recording now tries a broader set of browser-supported audio formats and resets halted audio playback cleanly, improving microphone capture reliability in browsers where recording previously failed to start. [Commit](https://github.com/open-webui/open-webui/commit/8ffc3d746f20007e9eb4e3ae4f152f383bc371e1), [#24162](https://github.com/open-webui/open-webui/issues/24162)
- 🧠 **Direct-connection task generation reliability.** Title, tags, follow-up, emoji, query, and related task-generation endpoints now work correctly when chats use direct-connection models instead of failing with model-not-found errors. [Commit](https://github.com/open-webui/open-webui/commit/1b4cd705d0b9a51a5e3a7851ec012fb3141eb0a9), [Commit](https://github.com/open-webui/open-webui/commit/005df577fec16733a64edbec8a1b46f42f4e9a43), [#24092](https://github.com/open-webui/open-webui/issues/24092)
- 🔧 **Parameterized URL tool readiness.** New chats now wait for model defaults to finish applying before auto-submit, preventing early requests that can miss configured external tools. [Commit](https://github.com/open-webui/open-webui/commit/212bb68a66435dc1803a6d67cb4ea584d3455fb7), [#24176](https://github.com/open-webui/open-webui/issues/24176)
- 🚦 **MCP cleanup response reliability.** Successful native MCP tool calls no longer get replaced by a 500 "No response returned" error during cleanup, so valid chat responses are now returned consistently. [#24105](https://github.com/open-webui/open-webui/pull/24105)
- 🧵 **Active task state recovery.** Chat input no longer stays blocked by unrelated background tasks after a response is already complete, and interrupted assistant replies are now marked done more reliably. [Commit](https://github.com/open-webui/open-webui/commit/04bd0425ead28185bcd124e77892e31209a6e15b), [#23264](https://github.com/open-webui/open-webui/pull/23264)
- 📌 **Per-user note pinning behavior.** Pinned notes are now tracked per user instead of with a shared note-level flag, so one person’s pin changes no longer affect everyone else. [Commit](https://github.com/open-webui/open-webui/commit/33e588cf09b294f0abe08b9566efa8545a7dbf92)
- 🧱 **Custom header value coercion.** Custom header values are now converted to text before requests are sent, preventing request failures when non-text values are configured. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
- 🔗 **HTTP share link copy fallback.** Copy Link now works reliably on HTTP deployments by using a selection-based fallback when secure clipboard APIs are unavailable. [Commit](https://github.com/open-webui/open-webui/commit/f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4), [#24135](https://github.com/open-webui/open-webui/issues/24135)
- 🧵 **Regeneration loading lock recovery.** Chats no longer get stuck in a permanent loading state after failed regenerations because invalid message-tree references are repaired before rendering. [Commit](https://github.com/open-webui/open-webui/commit/ee3b82926b37843f2771c6a8d432781a557ea96a), [#24424](https://github.com/open-webui/open-webui/issues/24424)
- 📸 **Complete chat image capture.** Downloaded chat snapshots now include all messages more reliably through visibility overrides and layout timing improvements during capture. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840), [#24088](https://github.com/open-webui/open-webui/issues/24088)
- 🗓️ **Calendar deletion lock handling.** Calendar deletion now avoids SQLite write-lock contention by revoking calendar access grants in a separate transaction after calendar and event removal. [Commit](https://github.com/open-webui/open-webui/commit/1d892ce2c513c4d933c902de5e5d76c317a06dd2)
- 🧩 **Filter and internal tool coexistence.** Internal tools now remain available when filters add provider-native tools, so filter-added tools no longer replace the built-in tool set during request processing. [Commit](https://github.com/open-webui/open-webui/commit/02f9fe78907c2ecf6f1d93646cbfa2173409bbe8), [#24237](https://github.com/open-webui/open-webui/issues/24237)
- 🛠️ **OpenAPI tool spec compatibility.** OpenAPI tool integrations now handle null or non-operation path entries more safely and parse path-level parameters consistently, preventing crashes and improving tool execution reliability across imperfect OpenAPI specs. [Commit](https://github.com/open-webui/open-webui/commit/2ba6b423aa0c9c800bd96cb638c6ade867cac0f6), [Commit](https://github.com/open-webui/open-webui/commit/5b80932e5951786bb348b91589e8d87753f18905), [#24376](https://github.com/open-webui/open-webui/pull/24376)
- 🧰 **OpenAPI tool schema parsing.** OpenAPI tool imports now ignore non-method path item fields and correctly resolve nested composition schemas, preventing invalid tool parsing for compatible specs. [Commit](https://github.com/open-webui/open-webui/commit/85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc), [#23254](https://github.com/open-webui/open-webui/pull/23254)
- 🌍 **Web search proxy compatibility.** DuckDuckGo search now respects configured proxy environments more reliably, and trust-env behavior defaults to enabled so proxied web loading does not fail unexpectedly. [Commit](https://github.com/open-webui/open-webui/commit/bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297), [#23810](https://github.com/open-webui/open-webui/pull/23810)
- 🧾 **Final markdown render flush.** Streaming markdown now forces an immediate final parse when generation completes, preventing stale or partially rendered final output. [Commit](https://github.com/open-webui/open-webui/commit/29f6c72e879d67f23021938e24a21914cc9fb120), [#24088](https://github.com/open-webui/open-webui/issues/24088)
- 🛡️ **Webhook avatar URL validation.** Channel webhook profile image URLs are now validated before saving, preventing invalid or unsafe avatar URLs from being accepted. [#24370](https://github.com/open-webui/open-webui/pull/24370)
- 📝 **System prompt editor scroll stability.** Editing large system prompts no longer jumps the page back to the top, so you can continue editing long model prompts without losing your place. [Commit](https://github.com/open-webui/open-webui/commit/c978a788c8315e37357c93c1b605a2831fc77485), [#23999](https://github.com/open-webui/open-webui/issues/23999)
- 🔎 **Knowledge content search matching.** Knowledge file search now matches both file titles and file content, so relevant files are easier to find even when the keyword is not in the filename. [Commit](https://github.com/open-webui/open-webui/commit/11e076817ae5db34621ce03136353248f7377d97), [#24297](https://github.com/open-webui/open-webui/pull/24297)
- ⚡ **Faster prompt tag loading.** Prompt tag filters now load much faster for non-admin users by fetching only accessible tags directly, avoiding per-prompt permission checks and unnecessary prompt data loading. [#24287](https://github.com/open-webui/open-webui/pull/24287), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
- 🧾 **Citation overflow badge readability.** Citation overflow badges now keep multi-digit counts readable in a single compact bubble, preventing wrapped or cramped display when many sources are attached. [Commit](https://github.com/open-webui/open-webui/commit/23ff9943a9fc8c314100fa074157853fbece1a55), [#24391](https://github.com/open-webui/open-webui/pull/24391)
- 🌐 **Yandex result parsing guard.** Yandex web search no longer fails when some XML fields are missing in individual results, so valid search responses continue to return usable sources instead of dropping to no results. [Commit](https://github.com/open-webui/open-webui/commit/9386fc83a3eff3e55cc157ac8c15c337e3d822c1), [#24243](https://github.com/open-webui/open-webui/issues/24243)
- 🎧 **Safer voice transcription uploads.** Empty or failed voice conversions are now rejected with a clear error instead of continuing as malformed audio, reducing failed transcription attempts from corrupted or near-empty recordings. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c)
- 🎚️ **Safer chunked STT processing.** Chunked transcription now limits worker concurrency when no external STT engine is configured, reducing failed transcription behavior caused by overly parallel local processing. [Commit](https://github.com/open-webui/open-webui/commit/55e7c7854bba5182803239c903a0ac2d14426a4c)
- 📈 **Imported chat analytics coverage.** Imported ChatGPT conversations now carry proper model and timestamp metadata and reliably write imported messages into analytics-backed storage, so imported chats are reflected correctly in Admin Analytics totals and model usage views. [Commit](https://github.com/open-webui/open-webui/commit/4d766a3edfa116abcefe7168f1d1284683b860b2), [#24263](https://github.com/open-webui/open-webui/issues/24263)
- 📎 **Knowledge collection persistence.** Knowledge collections selected with the chat input selector now remain attached after reloads and chat switches, so attached context no longer disappears between sessions. [Commit](https://github.com/open-webui/open-webui/commit/7c398a625a8d51f79d80217f6d329fc30c72b782), [#24142](https://github.com/open-webui/open-webui/issues/24142)
- 🧹 **Embedding model name trimming.** Embedding model names entered in Documents settings now automatically trim surrounding whitespace, preventing silent embedding failures caused by accidental trailing spaces. [Commit](https://github.com/open-webui/open-webui/commit/6082e1adaebc8aa3e7f55265c8dc2dbe130c0446), [#24090](https://github.com/open-webui/open-webui/issues/24090)
- 🔊 **PCM TTS playback compatibility.** Text-to-speech audio returned as PCM is now converted to MP3 before delivery, so speech playback works correctly with providers that return raw PCM audio. [Commit](https://github.com/open-webui/open-webui/commit/ff791b4814fc1453df2235ea78016d7015aa6806), [#24143](https://github.com/open-webui/open-webui/issues/24143)
- 🪟 **Windows PostgreSQL startup compatibility.** Windows pip installs using PostgreSQL now start reliably with psycopg async by using a compatible event loop policy instead of the default Proactor loop. [Commit](https://github.com/open-webui/open-webui/commit/7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9), [#24152](https://github.com/open-webui/open-webui/issues/24152)
- ⏱️ **MCP OAuth timeout control.** OAuth token exchanges for MCP tool server connections now respect the configurable client timeout setting, reducing callback failures with slower providers. [Commit](https://github.com/open-webui/open-webui/commit/cde72dab71671645e119564ca9747ce25dd590ad), [#24138](https://github.com/open-webui/open-webui/issues/24138)
- 📄 **PDF text search restoration.** PDF previews now include a proper text layer so browser text selection and find-in-page search work again instead of rendering only image-like pages. [Commit](https://github.com/open-webui/open-webui/commit/bc4d6eef33dcb92719b07483cdb1d63ebf250721), [#24149](https://github.com/open-webui/open-webui/issues/24149)
- 🔑 **Android password autofill support.** Password inputs now expose the expected field name metadata, improving password manager autofill reliability on Android login pages. [Commit](https://github.com/open-webui/open-webui/commit/60ea4214aa42f1ad22142f1a43535007a2293d16), [#24137](https://github.com/open-webui/open-webui/issues/24137)
- 🎤 **Non-blocking STT processing.** Speech-to-text transcription no longer blocks the server event loop during both live transcription and uploaded audio file processing, so other users can continue using chats and live connections under concurrent load. [#24338](https://github.com/open-webui/open-webui/pull/24338), [#24379](https://github.com/open-webui/open-webui/pull/24379), [#24169](https://github.com/open-webui/open-webui/issues/24169)
- 🌐 **SearXNG language parameter handling.** Web searches now send clean multi-language values without trailing separators, so SearXNG requests no longer fail when multiple languages are selected. [Commit](https://github.com/open-webui/open-webui/commit/6dff85b9d205cfc4bc2845dac40909b8d859910c), [#24198](https://github.com/open-webui/open-webui/issues/24198)
- 📂 **File modal open-link behavior.** Clicking a file name in the file details modal now opens the correct file content in a new tab for uploaded file items instead of failing to open. [#24125](https://github.com/open-webui/open-webui/pull/24125)
- 📎 **Chat attachment display recovery.** Files attached by chat tools now appear reliably in assistant responses, including non-image file attachments that were previously hidden. [Commit](https://github.com/open-webui/open-webui/commit/7eeff2fdf945024585a01b72071a61971afc844d), [#24332](https://github.com/open-webui/open-webui/pull/24332)
- 🧱 **Channel embed rendering guard.** Channel message embeds now appear only for model-generated messages and are suppressed in reply previews, preventing unintended embed expansion in regular user posts. [Commit](https://github.com/open-webui/open-webui/commit/e1dce9914745de9b4d2c67b1deddde3472ce4dfa)
- 🛡️ **Safer image URL handling.** Untrusted external image URLs are now blocked in profile and rich-text image rendering paths, preventing unintended client-side requests to attacker-controlled domains. [#24420](https://github.com/open-webui/open-webui/pull/24420)
- 🛡️ **Sanitized spreadsheet HTML previews.** Spreadsheet previews now sanitize generated HTML before rendering, reducing the risk of unsafe content being executed when opening office files in chat and file modals. [#24468](https://github.com/open-webui/open-webui/pull/24468)
- 🧰 **Multi-worker tool update consistency.** Updated tool code now refreshes correctly across workers without requiring a full service restart, so chats no longer run stale tool versions after edits. [Commit](https://github.com/open-webui/open-webui/commit/3309f5d9f11f521c0ee97b64c59a83e3cf390bde), [#24400](https://github.com/open-webui/open-webui/issues/24400), [#24433](https://github.com/open-webui/open-webui/pull/24433)
- 🧩 **Default model metadata env parsing.** The "DEFAULT_MODEL_METADATA" environment setting is now parsed and applied correctly, including when persistent config is disabled, so configured model capability defaults are no longer ignored at startup. [Commit](https://github.com/open-webui/open-webui/commit/0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97), [#24319](https://github.com/open-webui/open-webui/issues/24319)
- 🔄 **Config import and Redis consistency.** Imported settings now remain effective after import because configuration values are immediately synchronized to Redis, preventing stale cached values from overriding imported permissions and settings. [Commit](https://github.com/open-webui/open-webui/commit/55a572cd398c9b4e6118728f8f129941437aa225), [Commit](https://github.com/open-webui/open-webui/commit/1c1c8b18e5cc90ca3c6961a4c193a4363febbc83), [#24346](https://github.com/open-webui/open-webui/issues/24346)
- 🔔 **LDAP signup webhook parity.** New accounts created through LDAP now trigger the same signup webhook notifications as password and OAuth signups, so downstream provisioning and audit automations receive consistent events. [Commit](https://github.com/open-webui/open-webui/commit/fd3368c0bff168417e3c49ffd73491c344702339), [#24377](https://github.com/open-webui/open-webui/issues/24377)
- 🦆 **DDGS auto-backend compatibility.** Web search now handles DDGS automatic backend selection correctly and safely falls back on empty or rate-limited responses, preventing search failures in newer DDGS versions. [Commit](https://github.com/open-webui/open-webui/commit/9adc0c442a57eaa88a5f30c2b2cb393623154e20), [#24188](https://github.com/open-webui/open-webui/issues/24188)
- 🤖 **Automation update tool reliability.** Updating existing automations in chat now works correctly instead of failing with a missing method error. [Commit](https://github.com/open-webui/open-webui/commit/f39f4a86aedc2769d8268670a020b1f3c16776dd), [#24405](https://github.com/open-webui/open-webui/issues/24405#issuecomment-4408011166)
- 📅 **Calendar event permission checks.** Calendar event update and delete actions now handle ownership and access checks more reliably, returning clean access-denied results when appropriate. [Commit](https://github.com/open-webui/open-webui/commit/2977910ffd9d2369dfa504aa6ab12745b3dbd19a)
- 🛡️ **Safer cached file delivery.** Cached files that are not recognized as image, audio, or video now download as attachments instead of rendering inline, reducing the risk of unsafe browser content handling. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
- 📊 **Streaming token analytics accuracy.** Admin Analytics now records and aggregates token usage correctly for streaming chats across Responses API and OpenAI-compatible providers, including fallback handling for provider usage formats that use prompt and completion token keys. [Commit](https://github.com/open-webui/open-webui/commit/989d5fd4e2ce285edf4475a1e13f0981a78d3821), [Commit](https://github.com/open-webui/open-webui/commit/a32d26e61d24d9f63650faed5cb8909ed90af661), [#24217](https://github.com/open-webui/open-webui/issues/24217), [#24294](https://github.com/open-webui/open-webui/issues/24294), [#24241](https://github.com/open-webui/open-webui/issues/24241)
- 🔗 **Admin shared chat links.** Admin users can now open and clone shared chat links reliably without 401 errors because shared links are now resolved by share ID first, with safe fallback behavior for direct chat ID access. [Commit](https://github.com/open-webui/open-webui/commit/cde21b9f6dc11575a668484f42440824ec5a4fae), [#24311](https://github.com/open-webui/open-webui/issues/24311), [#24096](https://github.com/open-webui/open-webui/issues/24096)
- 💾 **Chat settings persistence.** System prompts and other chat-level settings now persist correctly after creating a new chat and reloading, preventing prompt loss in affected conversations. [Commit](https://github.com/open-webui/open-webui/commit/86df8bf27e1b84abbe2eeedcc8650df59c7d23d6), [#24193](https://github.com/open-webui/open-webui/issues/24193), [#24270](https://github.com/open-webui/open-webui/issues/24270)
- 💾 **Chat control autosave persistence.** Changes to chat controls like system prompt, parameters, and attached files are now autosaved on existing chats, so edits are no longer lost when you refresh or navigate away before sending a message. [Commit](https://github.com/open-webui/open-webui/commit/a938c8ae2e45a00d2f06151fdaeaee94e54a8095), [#23897](https://github.com/open-webui/open-webui/pull/23897)
- ☁️ **OneDrive option visibility.** OneDrive personal and business upload options now appear only when their respective client IDs are configured, preventing unavailable options from showing in attachment menus. [Commit](https://github.com/open-webui/open-webui/commit/b72019db393a658ca0ceecdcc59b70f6cc5dcd40), [#24411](https://github.com/open-webui/open-webui/issues/24411)
- 🧠 **Reasoning content leakage prevention.** Tool-call round-trip messages no longer wrap reasoning text in `<think>` tags inside the content field, preventing raw markup from leaking into chat output for models whose templates don't strip think tags (e.g. Gemma 4). [#23844](https://github.com/open-webui/open-webui/issues/23844)
- 🖥️ **Terminal sidebar auto-open guard.** The terminal sidebar no longer auto-opens on chat load when OpenTerminal is disabled, because stale terminal IDs saved on models or in localStorage are now validated against available terminal servers before use.
- 🔁 **Single-confirmation connection deletion.** Deleting OpenAI, Ollama, tool server, and terminal server connections now shows exactly one confirmation dialog instead of two, because redundant outer confirmation wrappers were removed from all connection components.
- 🧵 **Reliable background task cleanup.** The chat task lifecycle now deregisters completed tasks before checking for remaining siblings, eliminating the off-by-one timing issue that could leave the stop button stuck or dismiss the sidebar activity spinner too early.
### 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.
- 🚪 **Signout request method.** The signout endpoint now requires POST instead of GET, so custom clients and integrations must update logout calls accordingly. [#24420](https://github.com/open-webui/open-webui/pull/24420)
## [0.9.2] - 2026-04-24
### Added
- 🧠 **PaddleOCR-vl document extraction.** Administrators can now use PaddleOCR-vl as a content extraction engine for document processing, with configurable API URL and token settings in document retrieval configuration. [#23945](https://github.com/open-webui/open-webui/pull/23945)
- 🔥 **Firecrawl v2 API.** Firecrawl web loading now uses the v2 API directly with proper retry logic, exponential backoff on rate limits, and configurable timeout handling, improving reliability for both cloud and self-hosted Firecrawl setups. [#23934](https://github.com/open-webui/open-webui/pull/23934)
- ⏰ **Calendar event reminder customization.** Calendar events now support a configurable `reminder_minutes` parameter, allowing models to set custom reminder durations instead of the default 10-minute notification.
- 🔑 **Custom API key header.** Administrators can now configure a custom header name for API key authentication via the `CUSTOM_API_KEY_HEADER` environment variable, enabling compatibility with reverse proxies that use the `Authorization` header for their own authentication.
- 🔌 **OAuth session disconnection.** Users can now disconnect OAuth sessions for specific providers (e.g., MCP connections) through a new API endpoint, enabling cleaner re-authentication workflows.
- 📚 **Source overflow indicator.** The Sources button now shows a +N badge when more than three sources are available, so hidden sources are clearly indicated in chat responses. [#23918](https://github.com/open-webui/open-webui/pull/23918)
- ⚡ **Model list performance.** Model list API responses now strip base64 profile image data from paginated results, and model tags are fetched via a dedicated efficient query instead of loading all models. This significantly reduces payload sizes and improves workspace Models page responsiveness.
- ⚡ **Model avatar cache reuse.** Default model profile images now redirect to a shared static path instead of reading files from disk per-request, reducing repeated I/O and improving loading efficiency when multiple models use the fallback icon. [#24015](https://github.com/open-webui/open-webui/pull/24015)
- 🚀 **Faster splash image loading.** Splash screen images are now prioritized earlier during page load with preload links, improving first-load LCP behavior and reducing delayed image discovery. [#24011](https://github.com/open-webui/open-webui/pull/24011)
- 🧵 **Streaming markdown performance stability.** Streaming responses now stay more memory-efficient by preventing repeated cleanup callback registration during markdown updates. [#24048](https://github.com/open-webui/open-webui/pull/24048)
- 📊 **Telemetry gauge reliability.** OpenTelemetry user gauge callbacks now use synchronous database queries directly, eliminating cross-thread async bridging issues that could cause silent failures in metric collection.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Finnish, Korean, Portuguese (Brazil), and Dutch were enhanced and expanded.
### Fixed
- 🔧 **MCP task cancellation stability.** Interrupted MCP tool calls no longer cause CPU spikes or runaway cleanup behavior. MCP client disconnection now runs in the same asyncio task as connection, respecting cancel scope constraints, and chat-active events are properly shielded during cancellation.
- 🧠 **Persistent chat skill injection.** Skills mentioned in persisted chats now inject into the system prompt reliably. Skill ID extraction from `<$skillId|label>` message tags is now handled server-side, and tags are stripped before messages reach the model.
- 🗄️ **Async database driver migration.** The async database backend now uses psycopg (v3) instead of asyncpg, eliminating brittle SSL parameter translation and supporting native libpq connection strings including `sslmode`, `options`, and `target_session_attrs` without any stripping or conversion.
- 🐳 **Docker ARM64 reliability.** Docker images built for arm64 via QEMU cross-compilation no longer produce 0-byte corrupted Python dependencies. `UV_LINK_MODE=copy` is now set in the Dockerfile to force reliable file installation.
- 🛠️ **Throttle request handling.** Request handling no longer fails when user activity status updates are throttled with a non-zero interval. [#23979](https://github.com/open-webui/open-webui/pull/23979)
- ✍️ **Rich text extension conflicts.** Rich text editing no longer triggers duplicate extension conflicts for lists and code blocks, improving editor stability. [#24009](https://github.com/open-webui/open-webui/pull/24009)
- 🔇 **Fetch URL null content guard.** The `fetch_url` built-in tool now safely handles `None` content returned by web loaders instead of crashing with a `TypeError`.
- 🌐 **OAuth discovery fallback.** OAuth protected resource discovery now falls back to well-known RFC 9728 URIs when the `WWW-Authenticate` header doesn't contain a `resource_metadata` link, improving compatibility with more MCP server implementations.
- 🔐 **Session token resolution.** Session user endpoints now gracefully handle missing `Authorization` headers by falling back to cookie and request state tokens, preventing errors when used behind forward-auth proxies.
- 🚫 **Direct API error responses.** Chat completion requests without a WebSocket channel (direct API calls) now return proper HTTP error responses instead of silently returning null on failure.
- 📡 **Cancelled response stream cleanup.** Cancelled chat generation now explicitly closes the upstream response body iterator, preventing orphaned async generators from spinning in anyio internals.
- 🔒 **Model profile image path safety.** Model profile image endpoints now validate and sanitize static asset redirect paths, preventing path traversal through encoded dots or malicious URL patterns.
- 📊 **RAG template validation UI.** The Documents settings page now displays a warning when RAG templates contain multiple `[context]` or `{{CONTEXT}}` placeholders, helping administrators avoid accidental redundant context injection.
- 🧩 **Automation model detection.** The `create_automation` tool now correctly detects the current model ID even when `model_id` is not yet set in metadata, falling back to the model dict.
- 🔄 **MCP resource content handling.** MCP tool results with the `resource` content type are now correctly detected and their `resource.text` payload is extracted, instead of being silently ignored.
- 🔄 **Ollama and OpenAI metadata forwarding.** Ollama and OpenAI proxy routes now forward request metadata to downstream handlers, ensuring consistent context propagation.
- 🧹 **Browser-native message virtualization.** The custom JavaScript-based message culling system (spacers, height caching, scroll listeners) was replaced with CSS `content-visibility: auto`, letting the browser natively skip rendering of off-screen messages without destroying component trees. This eliminates scroll jump artifacts and mount/destroy thrashing while preserving memory efficiency in long conversations.
- 📻 **Redis notification compatibility.** Redis pub/sub now handles missing or incompatible `client_name` support more gracefully, preventing connection errors with certain Redis configurations.
### Changed
- ⚙️ **psycopg v3 async driver.** The async database driver has been migrated from `asyncpg` to `psycopg` (v3). This is a transparent change for most deployments, but custom connection strings with `asyncpg`-specific parameters may need adjustment.
- 🔑 **Brotli dependency update.** Brotli has been updated to address CVE-2025-6176.
- 🖥️ **Windows startup script.** The Windows startup batch script has been updated for improved compatibility.
## [0.9.1] - 2026-04-21
### Fixed
- 🐛 **Missing `aiosqlite` dependency.** Fixed a startup crash (`ModuleNotFoundError: No module named 'aiosqlite'`) when installing Open WebUI via `pip` or `uv` by adding the missing `aiosqlite` package to `pyproject.toml`. The dependency was listed in `requirements.txt` but not in the published package metadata, so it was not installed automatically. [#23916](https://github.com/open-webui/open-webui/issues/23916)
- 🐛 **Missing `asyncpg` dependency.** Added the missing `asyncpg` package to `pyproject.toml` to prevent the same startup crash for PostgreSQL users. Like `aiosqlite`, it was present in `requirements.txt` but absent from the published package dependencies.
## [0.9.0] - 2026-04-20
### Added
- 🖥️ **Official Open WebUI Desktop App.** Open WebUI is now available as a native desktop app for Mac, Windows, and Linux. No Docker, no terminal, no setup. Runs Open WebUI locally without any server setup, or connects to your existing remote Open WebUI instances. Switch between multiple servers instantly from the sidebar. Comes with a system-wide floating chat bar (Shift+Cmd+I on macOS, Shift+Ctrl+I on Windows/Linux), system-wide push-to-talk, offline support after first launch, automatic updates, and zero telemetry. [#8262](https://github.com/open-webui/open-webui/issues/8262), [Desktop](https://github.com/open-webui/desktop)
- 🤖 **Scheduled chat automations.** You can now schedule the AI to run tasks automatically on a recurring basis: daily digests, periodic reports, anything you'd otherwise need to remember to ask for. Create and manage automations from the Automations page or directly in chat, with full run history and manual trigger controls. [#23303](https://github.com/open-webui/open-webui/pull/23303), [Commit](https://github.com/open-webui/open-webui/commit/5a2ff8b2e5b6f55a20f7ed491f818490eb535ea7), [Commit](https://github.com/open-webui/open-webui/commit/d30a0531d4add045c21a2368d6321a9b1906865f), [Commit](https://github.com/open-webui/open-webui/commit/bae5ff938ac88a3a647cc31ca8db1101015ae18b), [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
- 🧰 **Automation tools in chat.** Built-in chat tools can now create, update, list, pause, and delete scheduled automations directly in conversation when automation access is enabled. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
- ⏱️ **Automation scheduling limits.** Administrators can now set "AUTOMATION_MAX_COUNT" and "AUTOMATION_MIN_INTERVAL" to limit how many automations each non-admin user can create and prevent overly frequent schedules that could overload the system. [Commit](https://github.com/open-webui/open-webui/commit/406251c2f358ffabce4d631c98c6f2c879feae5c)
- 📋 **Task management tool.** AI models can now create, update, and track tasks within a chat conversation, breaking down complex requests into manageable steps with real-time status updates. [Commit](https://github.com/open-webui/open-webui/commit/bcb71bb5206ac01d97a39fde8ecf0e0541dde636)
- 🗓️ **Calendar workspace and event management.** Open WebUI now has a full Calendar workspace. Create and manage events, set up recurring schedules, get reminders via in-app toasts or browser notifications, and see your scheduled automations alongside your calendar. [#23880](https://github.com/open-webui/open-webui/pull/23880)
- 🔔 **Calendar reminders and alerts.** Calendar events now support reminder options from no alert up to one hour before start time, with upcoming alerts delivered through in-app toasts, browser notifications, and optional webhooks while avoiding duplicate sends. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be)
- ⚙️ **Scheduler reminder configuration.** Administrators can now configure calendar reminder processing with "SCHEDULER_POLL_INTERVAL" and "CALENDAR_ALERT_LOOKAHEAD_MINUTES", while existing "AUTOMATION_POLL_INTERVAL" setups continue to work as a legacy fallback. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be)
- ☁️ **Azure responses support.** Azure OpenAI connections now support the newer "/openai/v1" format, enabling chat, responses, and proxy calls to work correctly with that endpoint style. [#23484](https://github.com/open-webui/open-webui/pull/23484)
- 🤖 **Ollama responses support.** The Ollama proxy now supports the Responses API, letting clients use "/v1/responses" directly with Ollama-hosted models through Open WebUI. [#23483](https://github.com/open-webui/open-webui/pull/23483)
- 🧩 **Responses tool output rendering.** Built-in tool outputs in Responses API flows now render more consistently so downstream chat output is easier to interpret. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23482](https://github.com/open-webui/open-webui/pull/23482)
- 🔎 **Responses citation visibility.** Responses API flows now emit citation sources more consistently, making linked references easier to preserve and display in chat output. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23774](https://github.com/open-webui/open-webui/issues/23774)
- 📎 **Attach previously uploaded files.** The chat input menu now includes a Files tab for browsing and attaching previously uploaded files, eliminating the need to re-upload files you have already shared. [Commit](https://github.com/open-webui/open-webui/commit/edb8971c7dbd974322c3207c4655ff66479c3ee2)
- 🧷 **Default model terminal selection.** Workspace model editors can now preselect an Open Terminal connection, so new chats automatically start with the model’s configured terminal ready to use. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d), [#23605](https://github.com/open-webui/open-webui/issues/23605)
- 🎙️ **Mistral TTS support.** Mistral can now be used as a text-to-speech provider, with admin settings for the API key, base URL, voices, and model selection. [Commit](https://github.com/open-webui/open-webui/commit/4cee67e2be0c80a0b501073ea49a80d13efd1c41)
- 🎧 **STT preprocessing bypass option.** Administrators can now enable "AUDIO_STT_SKIP_PREPROCESSING" to send audio files directly to the speech-to-text backend, reducing memory and CPU consumption during large uploads for better transcription performance and stability on constrained deployments. [#23661](https://github.com/open-webui/open-webui/pull/23661)
- 🗑️ **Admin model deletion.** Administrators can now delete Ollama models directly from the model selector menu, making it easier to clean up unused or unwanted models. [Commit](https://github.com/open-webui/open-webui/commit/2388dd7dc3530b5dd5419c5d0bb1bcdcb7544099)
- 🔌 **Backend outlet filters for local and persisted chats.** Pipeline and function outlet filters now run reliably in backend completion flows for persisted chats and temporary local chats. [#3237](https://github.com/open-webui/open-webui/issues/3237), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
- 🎨 **Emoji shortcode support.** Typing a colon in the chat input now opens an emoji suggestion menu, making it easier to insert emojis using shortcodes like :wave:. [Commit](https://github.com/open-webui/open-webui/commit/2040095050056d01c61aa597c5010445449a42c7)
- 📌 **Recently used emojis.** The emoji picker now shows your most recently used emojis at the top, making it faster to find emojis you use often. [Commit](https://github.com/open-webui/open-webui/commit/64da99a32218171d41b3af5acc14783de8dbdf49)
- 👆 **Swipe to reply on mobile.** Swiping right on a message now triggers a reply, making it easier to respond on touch devices with a natural gesture. [Commit](https://github.com/open-webui/open-webui/commit/012ce95f27d57bea8911bd63bfb923443c5797ae)
- 📱 **Screen-awake voice recording.** Voice recording now keeps the screen awake during active dictation and safely re-acquires wake lock after visibility changes, helping prevent long transcriptions from being cut off on mobile devices. [#23145](https://github.com/open-webui/open-webui/issues/23145)
- 🔔 **Unread chat indicators.** Sidebar chats now show unread status and are marked as read when opened, making it easier to spot conversations with new activity. [Commit](https://github.com/open-webui/open-webui/commit/0638b9f56ce1ba8a496d0e84da2e7fa178b01a3f)
- 🔌 **WebSocket reconnect status feedback.** Open WebUI now warns when the real-time connection drops and confirms when it reconnects, while avoiding a reconnect message on the initial page load. [Commit](https://github.com/open-webui/open-webui/commit/1824e69a70e756cfcf543a9fbe4b0780d9b57292)
- 📍 **Pinned notes in sidebar.** Notes can now be pinned to the sidebar for quick access, and you can also create a new note directly from the pinned notes section. [Commit](https://github.com/open-webui/open-webui/commit/ecd74f220c7dd671d5705189a3f4493a3868c8bf), [Commit](https://github.com/open-webui/open-webui/commit/f1be85d997439b49fc143d2bcd2dc710f44446c8)
- 🗂️ **Model selector focus.** The model selector now resets its search only when it opens, making the popup feel more predictable while still focusing the search field automatically. [Commit](https://github.com/open-webui/open-webui/commit/b89019a8e1f96e01dc8e19a81ef8fb4f4eae3eef)
- 🗂️ **Model selector layout.** The model selector now behaves more predictably as a custom popup, and the completions playground uses a simpler model picker for easier selection. [Commit](https://github.com/open-webui/open-webui/commit/c40ea7f29d34fa9535cdf9ffe599f4429ff3f455)
- 🎚️ **Active filter valve shortcut.** Active filter badges now expose valve configuration directly in the chat input area, so filter tuning is faster during conversations. [Commit](https://github.com/open-webui/open-webui/commit/3c22afc5a67404047797921185aca984b10b45cd), [#23811](https://github.com/open-webui/open-webui/issues/23811), [#23813](https://github.com/open-webui/open-webui/pull/23813)
- 🎨 **Theme updates.** Other windows can now update the app theme directly, keeping the interface in sync when theme changes are triggered externally. [Commit](https://github.com/open-webui/open-webui/commit/9f1b279e88bd22dfff4d2531209536dea6a2f65e)
- 🚀 **Async performance and responsiveness improvements.** The core backend database and request paths now run asynchronously across the application, massively improving responsiveness and performance under concurrent load and reducing request blocking during heavy activity. [Commit](https://github.com/open-webui/open-webui/commit/27169124f220e5cea21c88601c731c3749496ab0), [Commit](https://github.com/open-webui/open-webui/commit/8936721414a17832852a90f3ee592af5a8b7232d)
- ⚡ **Drawer performance and memory optimization.** Drawer interactions now stay smoother over long sessions by removing stale keyboard listeners on teardown, which reduces memory growth and avoids accumulated event handling overhead. [#23724](https://github.com/open-webui/open-webui/pull/23724#issuecomment-4245840810)
- 🚀 **Chat history memory culling.** Long conversations now stay responsive no matter how many messages they contain. Off-screen messages are unloaded automatically and reloaded as you scroll, keeping memory usage low and the UI smooth on both desktop and mobile. [#23067](https://github.com/open-webui/open-webui/issues/23067), [Commit](https://github.com/open-webui/open-webui/commit/026903399be73ac4b6c226647110e5662d043a50), [Commit](https://github.com/open-webui/open-webui/commit/9dccd29c94875e6f0ac373c5802cb183296e47ff)
- 🧵 **Async file and knowledge processing performance.** File processing, knowledge reindexing, and channel message helper paths now consistently await async operations, preventing skipped processing steps and improving reliability and performance of indexing and tool responses. [Commit](https://github.com/open-webui/open-webui/commit/de27a121511a31606f250ba4033490797216a0eb)
- 🚀 **Persistent chat payload efficiency.** Persisted chats now use server-side history loading instead of repeatedly resending full message payloads, improving multimodal performance and reducing stale-history overwrite risk across devices. [#19064](https://github.com/open-webui/open-webui/issues/19064), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
- 🧵 **Non-blocking file storage operations.** Uploading, reading, transcribing, and deleting files now offloads storage I/O to background threads, keeping the application responsive during file-heavy workflows. [Commit](https://github.com/open-webui/open-webui/commit/4866bec0f238198a721c952fe18dd04ba643be33)
- 🏎️ **Streaming response performance.** Streaming responses now process each output line in a single step instead of two separate yields, reducing async overhead and improving responsiveness during long-running generations. [#23266](https://github.com/open-webui/open-webui/pull/23266)
- 🔎 **Faster mention parsing.** Chat text with HTML-like content, file paths, or tool output now parses mentions more efficiently, which helps keep typing and rendering responsive in messages that contain many '<' characters. [#23551](https://github.com/open-webui/open-webui/pull/23551)
- 🧪 **Code block rendering performance.** Code blocks now reuse a shared HTML unescape helper, reducing extra browser work when displaying encoded output in chat. [#23553](https://github.com/open-webui/open-webui/pull/23553)
- 🚀 **Inline code rendering performance.** Inline code tokens in streaming responses now fade in with a lightweight CSS animation, making chat output feel smoother while reducing interface overhead during rapid token updates. [#23258](https://github.com/open-webui/open-webui/pull/23258)
- 🎞️ **Streaming text token animation performance.** Streaming text tokens now use a lightweight CSS intro animation, making output feel smoother while reducing transition overhead and preventing tokens from fading out when generation completes. [#23257](https://github.com/open-webui/open-webui/pull/23257)
- 🎯 **Template token scan optimization.** Streaming responses now skip unnecessary token-replacement processing when no template markers are present, reducing per-update overhead and keeping chat output smoother during rapid generation. [#23161](https://github.com/open-webui/open-webui/pull/23161)
- 🔬 **Chinese text processing guard performance.** Streaming responses without Chinese characters now skip unnecessary Chinese-format processing checks, reducing per-update overhead and keeping output smoother during rapid generation. [#23162](https://github.com/open-webui/open-webui/pull/23162)
- 🧠 **HTML entity decode performance.** Streaming text decoding now avoids repeated document parsing for HTML entity handling, reducing memory churn and improving responsiveness in token-heavy chat output. [#23165](https://github.com/open-webui/open-webui/pull/23165)
- 🏷️ **Chat title update performance.** Chat title updates now run in a single database operation instead of multiple round trips, improving responsiveness and reducing overhead when titles are generated or renamed. [#23214](https://github.com/open-webui/open-webui/pull/23214)
- 📂 **Faster chat list queries performance.** Chat and folder lists now load more efficiently by fetching only the fields needed for sidebar views, improving responsiveness when browsing large conversation histories. [Commit](https://github.com/open-webui/open-webui/commit/0e5696de74cc0ba55b24cfc3d02efa83f08d7d3f)
- 📈 **Sidebar memory optimization.** Sidebar chat items now use shared drag-preview resources and safer listener cleanup, reducing memory growth and keeping large chat lists more responsive during long sessions. [#23209](https://github.com/open-webui/open-webui/pull/23209)
- 🧠 **Image viewer memory optimization.** Viewing images and SVGs now uses significantly less memory and performs faster, keeping the application snappy and responsive even when browsing through many media files during extended sessions. [#23236](https://github.com/open-webui/open-webui/pull/23236)
- 📡 **Optimized user activity tracking performance.** User activity updates now use a single database query instead of multiple operations, improving response times across all authenticated requests. [#23215](https://github.com/open-webui/open-webui/pull/23215)
- 👥 **Faster channel thread author loading.** Channel thread responses now load author details in a single batch query, reducing database overhead and improving responsiveness in threads with many participants. [#23795](https://github.com/open-webui/open-webui/pull/23795)
- 💨 **Optimized shared chat deletion.** Deleting shared chats by user is now faster and more memory-efficient by only loading necessary data. [#23216](https://github.com/open-webui/open-webui/pull/23216)
- 🗃️ **Faster chat tag loading.** Chat tag lookups now load only the metadata needed instead of full chat payloads, improving responsiveness for chats with large histories. [#23798](https://github.com/open-webui/open-webui/pull/23798)
- 📎 **Faster chat file deduplication.** Attaching files to chat messages now checks duplicates more efficiently, reducing overhead when handling larger file lists. [#23800](https://github.com/open-webui/open-webui/pull/23800)
- 📈 **Faster message diff checks.** Chat message and status updates now compare content more efficiently during streaming, making active conversations feel smoother and more responsive. [#23370](https://github.com/open-webui/open-webui/pull/23370)
- ⚖️ **Faster deep equality checks.** Chat message updates, model selection, note editing, code block refreshes, and rich text state comparisons now use deep equality checks that reduce unnecessary UI work and improve responsiveness in active sessions. [#23845](https://github.com/open-webui/open-webui/pull/23845)
- 🏃 **Faster knowledge access updates.** Updating access grants for knowledge items now completes with less backend overhead, making permission changes apply more quickly. [#23799](https://github.com/open-webui/open-webui/pull/23799)
- 🧹 **Mermaid render cleanup performance.** Mermaid diagrams now always clean up temporary render elements after failures, reducing DOM buildup and keeping repeated rendering more stable over time. [#23727](https://github.com/open-webui/open-webui/pull/23727)
- 🖼️ **Model image lookup efficiency.** Model profile image requests now reuse the current request database session, reducing per-request overhead and improving response efficiency. [#23796](https://github.com/open-webui/open-webui/pull/23796)
- 👤 **User endpoint query reduction.** Session-based user settings and status endpoints now avoid redundant user re-fetches, reducing unnecessary database load while preserving behavior. [#23794](https://github.com/open-webui/open-webui/pull/23794)
- 🚦 **Faster startup performance.** Open WebUI now checks for Torch MPS support only on macOS, avoiding unnecessary startup work on other platforms. [#23438](https://github.com/open-webui/open-webui/pull/23438)
- 🛡️ **Redis timeout consistency.** Redis connections now honor the "REDIS_SOCKET_CONNECT_TIMEOUT" setting across standard and cluster setups, helping workers fail faster when Redis is unreachable. [#23572](https://github.com/open-webui/open-webui/pull/23572)
- 🧰 **AIOHTTP pool controls.** Administrators can now tune shared outbound HTTP connection behavior with "AIOHTTP_POOL_CONNECTIONS", "AIOHTTP_POOL_CONNECTIONS_PER_HOST", and "AIOHTTP_POOL_DNS_TTL" for better control under high concurrency. [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135)
- ⏱️ **MCP tool server timeout configuration.** Administrators can now configure request timeouts for MCP tool server connections via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable. [Commit](https://github.com/open-webui/open-webui/commit/10b4b86ada93cd62d994c3179ff14dfd1a6e56f0)
- 🎫 **Static OAuth tool authentication.** Tool server authentication now works reliably for both "oauth_2.1" and "oauth_2.1_static" connection types, so OAuth-backed tool access is correctly detected and forwarded during chat requests. [Commit](https://github.com/open-webui/open-webui/commit/60676bfdcfbce1a69b3e97f2013f0cfd63371737)
- 🗄️ **Configurable storage local cache.** Administrators can now disable persistent local caching for cloud-backed uploads with the "STORAGE_LOCAL_CACHE" setting, reducing local disk usage by cleaning temporary upload copies after processing. [Commit](https://github.com/open-webui/open-webui/commit/8172c7e3d56918d1372be06b9369b58a3a88f6b1)
- 🚪 **Back-channel logout.** OpenID Connect providers can now trigger centralized logout through the "ENABLE_OAUTH_BACKCHANNEL_LOGOUT" setting, helping administrators invalidate user sessions more reliably across connected devices. [Commit](https://github.com/open-webui/open-webui/commit/0dd9f462ffb2f160bc4aebad182047f41874d250)
- 🛡️ **Expanded security header controls.** Administrators can now configure additional browser security headers, including "CONTENT_SECURITY_POLICY_REPORT_ONLY", "CROSS_ORIGIN_EMBEDDER_POLICY", "CROSS_ORIGIN_OPENER_POLICY", and "CROSS_ORIGIN_RESOURCE_POLICY", for stricter and more flexible deployment hardening. [Commit](https://github.com/open-webui/open-webui/commit/f246a66810fa4995d9494da3599c0fb297fb0213)
- 🖼️ **Image MIME fallback option.** Administrators can now enable "ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK" so image-to-base64 conversion can still detect common image types by file extension when MIME metadata is missing, improving compatibility on minimal container images and older file records. [Commit](https://github.com/open-webui/open-webui/commit/5127354b3eb4eaa71bc4ad68da69729e2196e7a4)
- 🛡️ **Public sharing permissions.** Public channels, models, notes, prompts, and tools now respect allowed access grants more consistently, helping administrators control who can share content more safely. [Commit](https://github.com/open-webui/open-webui/commit/9d3e0637c86292b8b92e7607097a83f1075d7cd8)
- 🆔 **Skill lookup by ID.** Skill instructions now include each skill’s ID, and the skill viewer now finds skills by ID in a case-insensitive way so attached skills are identified more reliably in chats. [Commit](https://github.com/open-webui/open-webui/commit/65ee771fd0d62d785ecbcf189e3f5b63858c11e6)
- 🏷️ **Source context metadata.** Retrieval source context now includes each source’s resource type and resource ID metadata, helping downstream model workflows preserve richer source identity during processing. [Commit](https://github.com/open-webui/open-webui/commit/c3c8c605d76a3b0ee067307f9cef6d081658e287)
- 🗂️ **Feedback filtering.** Administrators can now filter feedback history by model and export only the feedback they need. [Commit](https://github.com/open-webui/open-webui/commit/60e4d7517463690b3a87de38babc9ac561897c61)
- 📤 **CSV feedback export.** Feedback history can now be exported as either JSON or CSV, making it easier to analyze feedback in spreadsheet tools. [Commit](https://github.com/open-webui/open-webui/commit/342582676a5212bf196a69d11825cb407992f257)
- 📝 **Optional GET audit logging.** Administrators can now enable auditing for GET requests with the "ENABLE_AUDIT_GET_REQUESTS" setting when they need fuller request visibility. [Commit](https://github.com/open-webui/open-webui/commit/5ee791d5d28f236755243cb7d16d8737bb69ce36)
- 🕒 **Model access updates.** Changing a model’s access grants now updates its timestamp, so recently modified models stay easier to find and sort correctly. [Commit](https://github.com/open-webui/open-webui/commit/53eadb7df7281f5661cbe22c8b26b5aedaba3083)
- 💬 **Queued message handling.** Queued chat messages now send more reliably without advancing the queue too early, keeping follow-up prompts in the intended order. [Commit](https://github.com/open-webui/open-webui/commit/730e52a431d157dc62d72260668087437f1d52f4)
- 🔒 **Rendered content safety.** Placeholder descriptions and the pending account notice now render markdown with safer sanitization ordering, reducing the risk of unsafe HTML appearing in these views. [Commit](https://github.com/open-webui/open-webui/commit/253f416de3f2d3a939a6feef2a56413fd61cc70b)
- 🛡️ **Safer placeholder rendering.** Chat placeholder descriptions and the pending account notice now sanitize rendered markdown more consistently, reducing the risk of unsafe content being shown in these views. [Commit](https://github.com/open-webui/open-webui/commit/ae0316a30e01a2e5ff3f9d2f9f759c1cd6410f34)
- 🧮 **Usage analytics accuracy.** Token usage is now normalized before chat messages are saved, so model and user usage reports stay accurate across OpenAI-compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/4dea4fdf54e00ebaba8e3178128bf8709453d2a2)
- 🧩 **Richer Anthropic tool results.** Anthropic-compatible tool calls now preserve more tool result content types, including images and structured search or document outputs, so models can use fuller tool context instead of receiving only plain text fragments. [#23188](https://github.com/open-webui/open-webui/issues/23188), [Commit](https://github.com/open-webui/open-webui/commit/40f5b3d135190dc9a2d8e94dbb1b2cbcbd829132)
- 🖼️ **ComfyUI request reliability.** ComfyUI image generation and editing now use shared async connections with consistent SSL handling, making image uploads and workflow runs more reliable under concurrent load. [Commit](https://github.com/open-webui/open-webui/commit/5944eda0ff25a284f7157252683bccede741cbe7)
- 🎛️ **Reranking batch size control.** Administrators can now set "RAG_RERANKING_BATCH_SIZE" in Documents settings to control reranking workload size, helping balance retrieval speed and resource usage for their deployment. [Commit](https://github.com/open-webui/open-webui/commit/4d2f18981051205016bd24d39521e25a33581225)
- 🔗 **Shared chat access controls.** You can now control who has access to a shared chat by granting access to specific users or groups, instead of sharing with anyone who has the link.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Irish, Catalan, German, Simplified Chinese, Hindi, and Portuguese (Brazil) were enhanced and expanded.
### Fixed
- 🛡️ **Model description XSS protection.** Model descriptions shown in chat placeholders are now sanitized before rendering, preventing malicious links from executing scripts and helping protect user sessions from takeover. [#23621](https://github.com/open-webui/open-webui/pull/23621)
- 🧠 **Memory search filtering.** Memory search now correctly filters by the query text instead of returning unrelated results. [Commit](https://github.com/open-webui/open-webui/commit/43e5905c133049036353978704b0abd179716749), [#23826](https://github.com/open-webui/open-webui/issues/23826)
- 📊 **Shared chat analytics consistency.** Usage and message-count analytics now count assistant activity consistently across regular and shared chats, improving accuracy in model, user, chat, and time-based reporting views. [Commit](https://github.com/open-webui/open-webui/commit/e29d145a1cff23122de16123a4cfda1b84abffbb)
- 🧭 **Safer in-flight chat navigation.** Sending a message no longer overwrites your active chat or causes duplicate background notifications when you switch conversations before a response finishes. [Commit](https://github.com/open-webui/open-webui/commit/dc6df52a917b49fa1264ac81a8cc74603f6155b3)
- 🗣️ **Pipeline error detail visibility.** Pipeline inlet and outlet failures now preserve and surface provider error details more reliably in chat error messages, making troubleshooting failed requests much clearer. [Commit](https://github.com/open-webui/open-webui/commit/d5e69f182cd7a6371ab25248f6432b277f83ef23)
- 📨 **Shared chat event routing.** Message update and send events now target the chat owner’s event channel, so shared chats receive the correct real-time updates instead of routing events to the acting user. [Commit](https://github.com/open-webui/open-webui/commit/47329b5032ba29716a7e7e973b07c6d9894968e0)
- 🔐 **Consistent outbound SSL handling.** External requests for tools, functions, terminals, webhooks, retrieval loaders, audio provider discovery, and OpenAI-compatible embedding calls now consistently apply the configured SSL client setting, improving reliability for deployments that require custom certificate or verification behavior. [Commit](https://github.com/open-webui/open-webui/commit/fd25152076ea7c310e42c9bacc5cd2b544eeae48), [Commit](https://github.com/open-webui/open-webui/commit/56c5bc1d3487020ab886d3332aacc1644c1d6123)
- 🖼️ **Image SSL setting support.** Image generation now respects the configured SSL session setting, preventing avoidable connection failures in strict certificate environments. [Commit](https://github.com/open-webui/open-webui/commit/128cf41fcedf2638fc8a6acd850d8b0409be1c4e), [#23777](https://github.com/open-webui/open-webui/issues/23777)
- 🗂️ **Folder ownership assignment hardening.** Folder create and update inputs now reject unexpected extra fields, preventing clients from overriding protected values like ownership through mass-assignment payloads. [#23648](https://github.com/open-webui/open-webui/pull/23648)
- 🔐 **Knowledge file deletion ownership checks.** Collaborators with knowledge base write access can no longer permanently delete files they do not own, preventing unintended file removal across other linked chats and knowledge bases. [Commit](https://github.com/open-webui/open-webui/commit/914ccf07ef158afe5588b97ed42778c93c439938), [#23636](https://github.com/open-webui/open-webui/pull/23636#issuecomment-4232439454)
- 🗑️ **Knowledge deletion reliability.** Deleting a knowledge base by ID now completes reliably without unexpected failures. [Commit](https://github.com/open-webui/open-webui/commit/7e453de4f7794ff386e285aa5951b94e926ec273), [#23776](https://github.com/open-webui/open-webui/issues/23776), [#23814](https://github.com/open-webui/open-webui/pull/23814)
- 🔐 **OAuth 2.1 PKCE enforcement.** OAuth 2.1 providers now default to S256 PKCE even when discovery metadata omits supported challenge methods, preventing login failures with providers that require PKCE by default. [#23667](https://github.com/open-webui/open-webui/issues/23667), [Commit](https://github.com/open-webui/open-webui/commit/050c4b97a95addc5eaeef86ba00631673a90dec4)
- 🔐 **Static OAuth scope handling.** Static OAuth credential flows now prioritize administrator-defined scopes and handle OAuth 2.1 static flow behavior more reliably. [Commit](https://github.com/open-webui/open-webui/commit/349ea4ea9e577f2cbfb4917ef5f52e5ac53c5b70), [#23668](https://github.com/open-webui/open-webui/issues/23668), [#23696](https://github.com/open-webui/open-webui/pull/23696), [#23783](https://github.com/open-webui/open-webui/pull/23783)
- 🔐 **Static OAuth tool registration reliability.** Static OAuth tool server registration now resolves and uses saved admin credentials more reliably, preventing registration failures when valid client credentials are provided. [#23670](https://github.com/open-webui/open-webui/issues/23670), [Commit](https://github.com/open-webui/open-webui/commit/2943955c529138c0e530fd07b6333a0052e3684e), [Commit](https://github.com/open-webui/open-webui/commit/c767bcaa739f76b1a4337dfd9d6be47adb504825)
- ⏳ **OAuth token expiry fallback.** OAuth sessions now always store a safe expiry value even when providers omit "expires_in" or "expires_at", so token refresh checks continue working and tool calls are less likely to fail later with unexpected authorization errors. [#23669](https://github.com/open-webui/open-webui/issues/23669), [Commit](https://github.com/open-webui/open-webui/commit/31406caa795173a59d5843d3601b891bf617cbaa)
- 🔑 **Anthropic x-api-key model access.** Anthropic-compatible clients can now authenticate with the "x-api-key" header across all relevant API routes, so model listing requests like GET "/api/v1/models" no longer fail with unauthorized errors. [#23319](https://github.com/open-webui/open-webui/issues/23319), [Commit](https://github.com/open-webui/open-webui/commit/611fe0c8a938539b73b559e84964f40c30bf436d)
- 🔑 **SSO password option visibility.** Account settings now hide password change controls when password-change access is disabled, avoiding misleading password options for SSO-focused setups. [#15292](https://github.com/open-webui/open-webui/issues/15292), [Commit](https://github.com/open-webui/open-webui/commit/cced77b584d6ea46c58fecddb2b3dd5e955c8417)
- 🔑 **Open Terminal MCP authentication.** Open Terminal MCP tool calls now include the configured API key when calling internal routes, preventing unauthorized errors for commands like file reads and command execution. [#106](https://github.com/open-webui/open-terminal/pull/106)
- 🧯 **Provider error freeze recovery.** Task-based chat requests now surface provider HTTP errors through normal failure handling, so content-filter and other upstream 4xx responses no longer leave chats stuck in a perpetual loading state. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5)
- 🔄 **Immediate outlet filter updates.** Assistant messages modified by outlet filters now appear correctly as soon as streaming completes, without requiring a page refresh. [#23829](https://github.com/open-webui/open-webui/pull/23829)
- 🌊 **Middleware cancellation reliability.** Long-running requests now complete more reliably by preventing middleware-level cancellations from interrupting in-flight database and embedding work, reducing unexpected failures and noisy error logs when connections close early. [#23709](https://github.com/open-webui/open-webui/pull/23709)
- 🚦 **Async vector search responsiveness.** File processing, memory updates, and knowledge retrieval no longer block the server event loop during vector database operations, so other chats and requests stay responsive while indexing or search is running. [#23706](https://github.com/open-webui/open-webui/pull/23706)
- 🗒️ **Notes chat llama.cpp compatibility.** Notes AI chat no longer sends empty assistant prefill messages that can conflict with reasoning-enabled llama.cpp responses, preventing immediate 400 errors in Notes conversations. [Commit](https://github.com/open-webui/open-webui/commit/fd93bd3414a1725219e14561bc5640b62f9fd4a1), [#23703](https://github.com/open-webui/open-webui/issues/23703#issuecomment-4243907629)
- 🧩 **Ollama thinking field preservation.** Messages modified by filters now keep the Ollama "thinking" field when sent to the model, so reasoning-aware workflows and custom filter-based passthrough setups work reliably. [Commit](https://github.com/open-webui/open-webui/commit/8bd23b91459914eb7df5b5a66567d3544e0da168), [#22508](https://github.com/open-webui/open-webui/issues/22508)
- 🧾 **Reasoning content preservation.** Assistant tool-call messages now retain reasoning content across turns, improving reliability for reasoning-heavy model workflows. [Commit](https://github.com/open-webui/open-webui/commit/3dd8255816898467246c81cba3c9bc48bc18d86d), [#23175](https://github.com/open-webui/open-webui/issues/23175), [#23742](https://github.com/open-webui/open-webui/pull/23742)
- 🧭 **Background task scoping for new chats.** Chat title and auto-tag generation now run only for the first message of a new conversation and only once in multi-model responses, preventing duplicate or incorrectly triggered background tasks in follow-up flows. [Commit](https://github.com/open-webui/open-webui/commit/f102060a6d85db4acd3d0bf5c25e976f36cd5533..a4ed16999eec9a654a37c2bb4c15ba5ecd1fa3b7)
- 📚 **Channel document context retention.** Channel conversations now preserve and load the correct stored message history so model responses can use uploaded and retrieved document context more reliably. [#23686](https://github.com/open-webui/open-webui/issues/23686), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6)
- ⏳ **Interrupted response recovery.** Assistant placeholder messages now start as incomplete and recover more safely after interrupted generations, preventing silent empty replies after refreshes or dropped requests. [#23176](https://github.com/open-webui/open-webui/issues/23176), [Commit](https://github.com/open-webui/open-webui/commit/c8ef7b028931263e8773cb60a7111d80d9572d26), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
- 🧰 **Large tool result rendering.** Tool call details now display large result payloads reliably in chat instead of intermittently showing empty output for bigger tool responses. [#18743](https://github.com/open-webui/open-webui/issues/18743), [Commit](https://github.com/open-webui/open-webui/commit/45e49d33e51f7720c00b564215484aff9b48b20c)
- 🧼 **Null-byte document sanitization.** PDF and other document ingests now sanitize null bytes and invalid surrogate characters before pgvector writes, preventing PostgreSQL upload failures and allowing affected files to index successfully. [#22992](https://github.com/open-webui/open-webui/issues/22992), [Commit](https://github.com/open-webui/open-webui/commit/8dba798cce9fb1efc5f6acc5f37b152662db78d7)
- 📝 **Knowledge text editor stability.** The Knowledge "Add Text Content" modal now uses a plain text editor, avoiding current rich text editor issues and keeping drafting behavior consistent with existing knowledge editing flows. [Commit](https://github.com/open-webui/open-webui/commit/cd55c3e21237e000c13c6f396bb95b261f3bda82)
- 🎤 **STT SSL setting consistency.** Speech and related outbound media requests now consistently use shared async HTTP sessions and honor the configured SSL verification setting, improving compatibility with self-signed deployments. [#23672](https://github.com/open-webui/open-webui/issues/23672), [Commit](https://github.com/open-webui/open-webui/commit/2ddcb30b9a519885422ba1f36cc3485a7d897bf8)
- 🎙️ **Mistral speech input format.** Mistral speech-to-text requests now use the correct chat-completions audio input format for better compatibility. [Commit](https://github.com/open-webui/open-webui/commit/34d569d564a8ef2702c647dbad83eac840b76b2e), [#23822](https://github.com/open-webui/open-webui/issues/23822)
- 🖼️ **Optional image size parameter.** Image generation no longer sends the "size" field when no size is configured, improving compatibility with providers that reject unsupported size arguments. [#23611](https://github.com/open-webui/open-webui/issues/23611), [Commit](https://github.com/open-webui/open-webui/commit/869cf9e848b741705dc058550fa1b3f70db47fe8)
- 🔎 **FireCrawl timeout reliability.** FireCrawl web loading now uses direct scrape requests and improved timeout handling for single-URL fetches, reducing empty results and premature timeout failures with local FireCrawl setups. [#23411](https://github.com/open-webui/open-webui/issues/23411), [Commit](https://github.com/open-webui/open-webui/commit/9c64d84ad90804bf7d891e4a5097c03c4d7044c3)
- 🖱️ **Custom action icon drag prevention.** Custom user-added action icons in chat responses are no longer accidentally draggable, so clicks and hover interactions behave consistently with built-in action icons. [#23412](https://github.com/open-webui/open-webui/pull/23412)
- 🖼️ **Image URL conversion reliability.** Sending image URLs to AI models no longer fails with "cannot pickle 'coroutine' object" errors, so image inputs now convert to base64 reliably during request processing. [#23685](https://github.com/open-webui/open-webui/pull/23685#issuecomment-4240424635)
- 📂 **Channel input menu dismissal.** In Workspace Channels, the message input dropdown now closes immediately after selecting "Upload Files" or "Capture", matching normal chat input behavior and preventing the menu from staying open unnecessarily. [#23684](https://github.com/open-webui/open-webui/pull/23684)
- 📋 **Clipboard copy scroll stability.** Copying content with the fallback clipboard method no longer triggers unwanted page scrolling during focus, keeping your current reading position stable. [Commit](https://github.com/open-webui/open-webui/commit/fc98000aa8d439bbff21a70370f5e962bf23f4bc)
- 🖼️ **Profile image URL validation.** Profile saves now accept valid Open WebUI profile-image paths, trusted external HTTP(S) avatar URLs, and safe raster data-image formats while rejecting unsafe URL patterns that could be abused. [#23389](https://github.com/open-webui/open-webui/pull/23389)
- 👤 **Partial user profile updates.** User update API requests can now modify only the fields you provide, so administrators no longer need to resubmit unchanged name, email, and profile image values when changing a single setting like role. [#23424](https://github.com/open-webui/open-webui/issues/23424), [Commit](https://github.com/open-webui/open-webui/commit/3c2c611ba91d794a1e73134ec41b0de2b3927677)
- 🚨 **Provider SSE error visibility.** Provider failures returned with streaming content types are now surfaced as proper API errors and logged clearly, so issues like context-window limits no longer fail silently during chat generation. [#23379](https://github.com/open-webui/open-webui/pull/23379)
- 🧵 **Queued prompt race prevention.** Chat request queues now prevent overlapping processing for the same chat, avoiding duplicate queue handling when multiple queue-processing triggers fire close together. [#23181](https://github.com/open-webui/open-webui/issues/23181), [Commit](https://github.com/open-webui/open-webui/commit/e10a00132eed54a0108fb6ac120e8229deef3656)
- 🛑 **Cancellation event delivery reliability.** Cancelled chat processing now safely emits task-cancel and error events only when an event emitter is available, while provider HTTP errors now also route through task-cancel handling so chats recover from blocked-loading states more reliably. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/51765b619c8584b042af68c3a5c87525a105ccd8), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5)
- 🔑 **OIDC key-rotation recovery.** OIDC login now retries token authorization with refreshed provider signing keys after a bad-signature failure, so logins recover automatically after identity-provider key rotation without requiring a service restart. [#23582](https://github.com/open-webui/open-webui/issues/23582), [Commit](https://github.com/open-webui/open-webui/commit/facb194a07486e847f0725a0a839e99b5864d37b)
- 🌍 **Non-ASCII tag filtering.** Prompt and model tag filters now handle non-Latin tags more reliably across SQLite and PostgreSQL, so tags like Cyrillic values return the expected items in Workspace lists. [#23381](https://github.com/open-webui/open-webui/issues/23381), [#23427](https://github.com/open-webui/open-webui/pull/23427), [Commit](https://github.com/open-webui/open-webui/commit/57784706e4fee75dec67e20b0d89a97351ac6256)
- 🏷️ **Prompt tag query accuracy.** Prompt tag filtering now uses JSON-element-aware queries so tag-based lookups return the correct prompts. [Commit](https://github.com/open-webui/open-webui/commit/e7e752f8e74e7b01fe2e6cb56f06e99312e1afe7), [#23386](https://github.com/open-webui/open-webui/pull/23386)
- 🗃️ **SQLite async pool compatibility.** SQLite async database setup no longer forces an explicit queue pool class, avoiding pool configuration conflicts in SQLite deployments. [Commit](https://github.com/open-webui/open-webui/commit/26b8ca5b5eeb144fae3fe6eaeae826150d8af826)
- 🧠 **Knowledge embedding deadlock prevention.** Knowledge file processing now runs blocking vector-save work in a worker thread while keeping async status updates reliable, preventing file processing from stalling during long embedding operations. [Commit](https://github.com/open-webui/open-webui/commit/d4b90f93bda2413ec8f040e61959acdb7b242061), [Commit](https://github.com/open-webui/open-webui/commit/22cfb3c673cbfa4a6bce26fde8e2e2754ce4963b)
- 🤖 **Automation worker async DB handling.** Automation claiming and run recording now use async database sessions consistently, improving worker stability for scheduled automations. [Commit](https://github.com/open-webui/open-webui/commit/cb6e77be3ec6ce00dd1f5b9ce3a655e6f65bc5da)
- 🕒 **Automation timezone scheduling.** Scheduled automations now calculate each user’s next run time using that user’s saved timezone, preventing run drift caused by server-time fallback. [Commit](https://github.com/open-webui/open-webui/commit/a4d62253df55c6307112eb76a6bfa29a7f538e21)
- 🔎 **Notes search matching.** Notes search now handles multi-word and hyphenated queries more reliably, so relevant notes and snippets are easier to find from partial phrase searches. [Commit](https://github.com/open-webui/open-webui/commit/a35926261646f8897ba71da1572ed5dff802e3be)
- 📐 **Display math rendering.** Chat markdown now correctly recognizes and renders "$$...$$" expressions as display math, improving reliability for multiline and escaped KaTeX content while keeping malformed delimiters from disrupting message rendering. [#23526](https://github.com/open-webui/open-webui/issues/23526), [Commit](https://github.com/open-webui/open-webui/commit/15b89b9218b7d2c7239c579aa3d23c2892227ac6)
- 🚫 **LDAP empty-password rejection.** LDAP login now rejects empty or whitespace-only passwords before bind attempts, preventing unauthenticated simple-bind behavior from granting access on permissive LDAP server configurations. [#23633](https://github.com/open-webui/open-webui/pull/23633)
- 🌐 **IPv6 SSRF address blocking.** URL validation now uses standard IP address checks for both IPv4 and IPv6, preventing private, loopback, link-local, reserved, and mapped-address SSRF bypasses through IPv6 hostname resolution. [#23453](https://github.com/open-webui/open-webui/pull/23453)
- 🔒 **API key endpoint restriction bypass.** API key endpoint restrictions are now enforced regardless of whether the key is sent through Authorization headers, cookies, or "x-api-key", preventing bypass through alternate key transport paths. [#23637](https://github.com/open-webui/open-webui/pull/23637)
- 🔐 **Channel sharing permission enforcement.** Channel creation and updates now enforce allowed access grant rules for public sharing, preventing unauthorized wildcard sharing on group channels. [#23638](https://github.com/open-webui/open-webui/pull/23638)
- 🛑 **Socket role invalidation.** Socket sessions now disconnect automatically when a user is demoted or deleted, preventing stale admin privileges from persisting until reconnect. [#23642](https://github.com/open-webui/open-webui/pull/23642)
- 🛂 **Tool server access checks.** Tool listing now correctly awaits server access checks, preventing users from seeing server-backed tools they do not have permission to use. [Commit](https://github.com/open-webui/open-webui/commit/d40f31982be3eed37e55e3f67b1eea9a5dc8c525)
- 🛑 **Task endpoint access control.** Global task listing and direct task stop endpoints are now restricted to administrators, while regular users can stop only their own chat tasks through a scoped chat endpoint. [#23454](https://github.com/open-webui/open-webui/pull/23454)
- 🧱 **Redis cache key isolation.** Tool server and terminal server cache entries now include the Redis key prefix, preventing multiple Open WebUI instances that share one Redis database from overwriting each other’s cached connection data. [#23649](https://github.com/open-webui/open-webui/pull/23649)
- 🧠 **Client session leak prevention.** Outbound provider requests now use a shared session pool with safer response cleanup and shutdown handling, preventing aiohttp session buildup and reducing memory growth during heavy concurrent API traffic. [#23540](https://github.com/open-webui/open-webui/issues/23540), [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135)
- 🧩 **Tool enum value handling.** Tool schema generation now safely handles enum values as strings, preventing failures when OpenAPI parameters include non-string enum entries. [#23597](https://github.com/open-webui/open-webui/issues/23597), [Commit](https://github.com/open-webui/open-webui/commit/4498e6faf2b1bdd1caa0e2c1c15d90a2790cd721)
- 🧷 **Responses model access control.** The OpenAI-compatible Responses endpoint now enforces per-model permissions, preventing non-admin users from accessing models they are not allowed to use. [#23481](https://github.com/open-webui/open-webui/pull/23481)
- 🛡️ **Collection process endpoint permissions.** Collection processing endpoints now enforce collection ownership checks for web and text processing requests. [Commit](https://github.com/open-webui/open-webui/commit/ba83613ff297bc82db660b5273f04672d744902f), [#23634](https://github.com/open-webui/open-webui/pull/23634)
- 📚 **Knowledge query access enforcement.** Knowledge-base collection queries now block unauthorized enumeration and require read access before returning results. [Commit](https://github.com/open-webui/open-webui/commit/860b90fd17d14ba00674621edd294dee150491d2), [#23635](https://github.com/open-webui/open-webui/pull/23635), [#23452](https://github.com/open-webui/open-webui/pull/23452)
- 🔍 **RAG collection query permissions.** Vector search collection queries now enforce access checks before retrieval results are returned. [Commit](https://github.com/open-webui/open-webui/commit/f44b7a01f5b854f47c1594a1ab5f72096f736262), [#23627](https://github.com/open-webui/open-webui/pull/23627)
- 🔗 **Chained base model access checks.** Chained base model execution now enforces per-model access rules to prevent unauthorized model usage. [Commit](https://github.com/open-webui/open-webui/commit/8acce144f99992b75c25f0e5038b16881ce9f066), [Commit](https://github.com/open-webui/open-webui/commit/50363ba66b19613a2fc0cab6a3f7f724a825135e), [#23647](https://github.com/open-webui/open-webui/pull/23647)
- ✍️ **Collaborative document write checks.** Collaborative document updates now require proper write permission before changes are accepted. [Commit](https://github.com/open-webui/open-webui/commit/638c7ab80216452910bdc59a19eb90e6b7244c6c), [Commit](https://github.com/open-webui/open-webui/commit/3271b013a8b30a882364679dcb40ffc9a89f037e), [#23624](https://github.com/open-webui/open-webui/pull/23624)
- 📥 **Model import ownership validation.** Model import now enforces ownership and access grant checks to prevent unauthorized imports. [Commit](https://github.com/open-webui/open-webui/commit/499129625bf96b2c03a6d057a2f91fdf07fd1c49), [#23628](https://github.com/open-webui/open-webui/pull/23628)
- 🚫 **Inactive member channel access.** Deactivated group members can no longer read or write channel content through direct API calls, so channel permissions now match active membership status. [#23623](https://github.com/open-webui/open-webui/pull/23623)
- 🎛️ **Ollama endpoint model permissions.** Restricted models are now protected on Ollama show, generate, embed, and embeddings endpoints, preventing authenticated users from using private models without read access. [#23631](https://github.com/open-webui/open-webui/pull/23631)
- 🧭 **Azure deployment path validation.** Azure model names are now validated and safely encoded before request URL construction, preventing path traversal attempts from reaching unintended Azure endpoints. [#23629](https://github.com/open-webui/open-webui/pull/23629)
- 👥 **Private channel member list access.** Standard channel member lists now require proper read permission, preventing unauthorized users from enumerating members of private channels by direct API calls. [#23625](https://github.com/open-webui/open-webui/pull/23625)
- 🌀 **Tool server schema recursion safety.** Tool server OpenAPI conversion now handles circular request schema references safely, preventing conversion crashes and ensuring one bad tool server spec does not break the full tool server list. [#23588](https://github.com/open-webui/open-webui/pull/23588), [Commit](https://github.com/open-webui/open-webui/commit/d3df8f1f372411314be9121fbf61d107939fa258)
- 🧱 **Safer file path handling.** File upload, transcription cache, and model download paths now use safer path construction helpers to reduce path parsing risks and improve cross-platform path safety. [Commit](https://github.com/open-webui/open-webui/commit/15f9a8f3f13f112c96cb1b16f88859f65de58346)
- 🧾 **Prompt save error feedback.** Saving prompt edits now shows a clear error toast if the save fails, so failed updates are visible instead of silently failing in the editor flow. [Commit](https://github.com/open-webui/open-webui/commit/36a81ad43b7c0d450079f818a7546eaa517e3d95)
- 🧾 **Tool call JSON rendering.** Tool call arguments and structured results now render as plain formatted JSON blocks instead of markdown code fences, preventing formatting quirks and making tool output easier to read consistently. [Commit](https://github.com/open-webui/open-webui/commit/a7d4c53f3adb80768b67e4a410b486b04a581521)
- 👥 **First-user admin race protection.** Concurrent first-time LDAP or OAuth registrations can no longer create multiple admin accounts, so only the true first account is promoted during initial setup. [#23626](https://github.com/open-webui/open-webui/pull/23626)
- 🔒 **SCIM token checks.** SCIM authentication now compares tokens in a safer way, helping prevent timing-based token guessing attacks. [#23577](https://github.com/open-webui/open-webui/pull/23577)
- 🔒 **Safer file access checks.** HTML file previews now treat missing or non-admin owners as inaccessible, preventing accidental access to files that should not be shown. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9)
- 🖼️ **ComfyUI request hangs.** Concurrent image generation and editing requests to ComfyUI now complete reliably instead of getting stuck when the same user starts multiple requests at once. [#23592](https://github.com/open-webui/open-webui/pull/23592), [#23591](https://github.com/open-webui/open-webui/issues/23591)
- 🧭 **Permission-aware built-in tools.** Built-in tools now consistently respect user feature permissions for memories, web search, image generation, code interpreter, notes, channels, and automations, preventing tools from being exposed to users without access. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
- 🛑 **Interrupted MCP cleanup stability.** Interrupted MCP tool calls no longer leave runaway cleanup behavior that can drive container CPU usage to 100%, keeping instances stable after cancellations or dropped connections. [#23143](https://github.com/open-webui/open-webui/issues/23143)
- 🚪 **OAuth redirect URI reliability.** OAuth login redirects now use provider client metadata more consistently, preventing incorrect HTTP callback URLs behind reverse proxies and improving sign-in reliability for providers such as Feishu. [#23203](https://github.com/open-webui/open-webui/pull/23203), [#23128](https://github.com/open-webui/open-webui/issues/23128)
- 🌐 **OAuth redirect handling.** OAuth provider token exchange now follows redirects automatically, improving sign-in reliability with identity providers that redirect token endpoint requests. [#23409](https://github.com/open-webui/open-webui/issues/23409), [Commit](https://github.com/open-webui/open-webui/commit/498ff8cdc3dd47000cdc60e5adcf36f4adfbe07d)
- ☁️ **OneDrive picker redirect handling.** OneDrive file picker authentication now uses the current app origin as the redirect URI, improving sign-in reliability when launching the picker from deployed environments. [#23450](https://github.com/open-webui/open-webui/issues/23450), [Commit](https://github.com/open-webui/open-webui/commit/21cc8281323d505d7d084cc496bd433063315c86)
- 🍪 **OAuth session cookie persistence.** OIDC sign-in now correctly sets the "oauth_session_id" cookie, so "system_oauth" connections can forward user OAuth tokens to upstream providers as expected. [#23251](https://github.com/open-webui/open-webui/pull/23251), [#23250](https://github.com/open-webui/open-webui/issues/23250)
- 🔑 **OAuth session cookie handling.** OAuth callback processing no longer fails on undefined cookie expiry data, so OAuth session cookies are stored correctly after sign-in. [#23207](https://github.com/open-webui/open-webui/pull/23207), [#23197](https://github.com/open-webui/open-webui/issues/23197)
- 🔏 **Ollama SSL handling.** Ollama model management and file uploads now respect the configured SSL verification setting, so self-signed certificates work when SSL verification is disabled. [#23503](https://github.com/open-webui/open-webui/issues/23503), [Commit](https://github.com/open-webui/open-webui/commit/e51b661af0e71a24f041428f328fcc6e97a15262)
- 🛡️ **OAuth avatar URL validation.** OAuth sign-in now validates profile picture URLs before fetching them, preventing invalid image links from causing login-time errors. [#23356](https://github.com/open-webui/open-webui/pull/23356)
- 🔑 **User invite token expiry.** New user invite logins now respect the configured "JWT_EXPIRES_IN" setting, so signup tokens expire as expected instead of using the default lifetime. [#23576](https://github.com/open-webui/open-webui/pull/23576)
- 🚪 **Channel access checks.** Channel actions now verify the current user when checking access, improving permission enforcement across channel views and message actions. [Commit](https://github.com/open-webui/open-webui/commit/4632f200a9ac98c915aee412b34e86c3d3c58bb1)
- 📣 **Channel message lookups.** Channel message details and pinning now work more reliably when the sender account is missing, avoiding failures in those views. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9)
- 📌 **Pinned webhook message handling.** Viewing pinned webhook messages now works reliably even when webhook profile data is missing, preventing server errors and frontend crashes in channel pinned message dialogs. [#23414](https://github.com/open-webui/open-webui/pull/23414)
- 🛡️ **Note edit permission enforcement.** Note saving now requires write access instead of read access, preventing unauthorized users from modifying notes while preserving expected collaboration permissions. [Commit](https://github.com/open-webui/open-webui/commit/584a9a0920d8c8c72fc89ccbac83c970b5a4bd4a)
- 🗂️ **Archived chats menu visibility.** The 'Archived Chats' option in the user menu is now shown reliably for all users, so non-admin accounts can consistently access archived conversations. [Commit](https://github.com/open-webui/open-webui/commit/07262fa62c2323fc7948389e5b5b8a5d1b72fade)
- 💾 **Error message persistence.** LLM errors that occur during streaming are now saved to the database even if the connection drops, so users can see what went wrong when they reconnect. [#23231](https://github.com/open-webui/open-webui/pull/23231)
- 🚫 **Missing message completion guard.** Chat completion finalization now skips invalid requests without a message identifier, preventing unnecessary error toasts caused by rare frontend concurrency timing. [#23184](https://github.com/open-webui/open-webui/pull/23184)
- 🧠 **Active message completion accuracy.** Switching chats or refreshing during generation no longer marks the currently streaming assistant message as finished too early, so thinking blocks and action buttons appear at the correct time. [#23171](https://github.com/open-webui/open-webui/issues/23171)
- 📞 **Call overlay visibility.** Incoming call events now open the call overlay and controls reliably, preventing cases where the call interface briefly appeared and then disappeared. [Commit](https://github.com/open-webui/open-webui/commit/ee9db91df02120e1e3651e8881734966b710ad52)
- 💬 **Prompt submission handling.** Chat messages now preserve attached files more reliably when prompts are sent, including queued messages and shared prompt actions. [Commit](https://github.com/open-webui/open-webui/commit/6d6dfbf02c893d72d85d4490cb41f1665b1f9f95)
- 🧾 **Prompt variable form saving.** Prompt variable forms now save reliably without runtime errors or an unresponsive save action, so input values and placeholders work correctly when applying prompt templates with variables. [#23225](https://github.com/open-webui/open-webui/issues/23225), [#23480](https://github.com/open-webui/open-webui/issues/23480)
- 🛟 **Task model fallback safety.** Task routing now handles missing default model entries safely, preventing task execution failures when the previously selected model is no longer available. [#23169](https://github.com/open-webui/open-webui/pull/23169)
- 📊 **Usage statistic preservation.** Follow-up generation no longer overwrites existing token usage fields, so stored usage statistics remain accurate for the main response. [#23152](https://github.com/open-webui/open-webui/issues/23152)
- 📝 **Writing block parsing reliability.** ":::writing" blocks now parse more reliably when headers or extra inline text are present, preventing malformed rendering and duplicate output artifacts. [#23174](https://github.com/open-webui/open-webui/issues/23174)
- 🧾 **Code block line break reliability.** Blank lines in submitted code blocks are now preserved more reliably instead of being collapsed. [Commit](https://github.com/open-webui/open-webui/commit/1be9627dd27ffe75957729a4a0d1682a98684f01), [#20302](https://github.com/open-webui/open-webui/issues/20302), [#23451](https://github.com/open-webui/open-webui/pull/23451)
- ✂️ **Citation spacing cleanup.** When citations are disabled for a model, citation markers and their leftover spacing are now removed together so punctuation and copied text remain cleanly formatted. [#23141](https://github.com/open-webui/open-webui/issues/23141)
- 🧰 **Pipe tool access.** Pipe functions now receive built-in and MCP tools in **tools**, so tools like Web Search and code execution are available when enabled. [#23365](https://github.com/open-webui/open-webui/issues/23365)
- 📚 **Batch file processing database handling.** Batch knowledge file processing now consistently uses the active database session, preventing failures caused by missing database context during file ownership checks and update writes. [#23137](https://github.com/open-webui/open-webui/issues/23137)
- ⚙️ **Default model parameter loading.** The "DEFAULT_MODEL_PARAMS" environment variable is now parsed and applied correctly, so default generation settings are honored reliably without being ignored at startup. [#23223](https://github.com/open-webui/open-webui/pull/23223)
- 🔧 **Web search settings save reliability.** Saving web search configuration now works without server errors, so administrators can update "WEB_FETCH_MAX_CONTENT_LENGTH" and related retrieval settings successfully from the admin interface. [Commit](https://github.com/open-webui/open-webui/commit/36d02aa1477aa1b4e7fb59d022f99693ebfa8667), [#23127](https://github.com/open-webui/open-webui/issues/23127)
- 🔍 **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. [#23488](https://github.com/open-webui/open-webui/pull/23488), [#23485](https://github.com/open-webui/open-webui/issues/23485)
- 🖼️ **Open Terminal file response handling.** Open Terminal tool responses now preserve binary content types in user-side connections, so image and non-text file reads work consistently instead of being forced into plain text. [#23125](https://github.com/open-webui/open-webui/issues/23125)
- 🖥️ **Terminal label casing.** Terminal names in the chat input now display exactly as stored instead of being automatically capitalized, so domain-style server names appear correctly. [#23518](https://github.com/open-webui/open-webui/pull/23518)
- 🖼️ **Gravatar profile photo saving.** Gravatar profile images can now be saved successfully from account settings, with clearer validation and error handling instead of failing with generic object errors. [#23156](https://github.com/open-webui/open-webui/issues/23156)
- 🪟 **Details expansion preference.** Tool call detail groups now honor the 'Always Expand Details' chat setting, so they open expanded by default when that preference is enabled. [#23262](https://github.com/open-webui/open-webui/pull/23262), [#23255](https://github.com/open-webui/open-webui/issues/23255)
- 🖱️ **Rapid sidebar action protection.** Archive and delete actions in the chat sidebar now ignore repeated clicks while a request is in progress, preventing duplicate requests and stacked error toasts. [#23172](https://github.com/open-webui/open-webui/issues/23172)
- 📲 **Mobile model selector positioning.** The mobile model selector dropdown now applies a constrained viewport width and left offset, preventing overflow and making model selection easier on small screens. [#23310](https://github.com/open-webui/open-webui/pull/23310)
- 🔽 **Task list toggle icons.** The task list collapse button now shows the correct arrow direction, making task sections easier to expand and collapse at a glance. [Commit](https://github.com/open-webui/open-webui/commit/f66b67c8b86b6f9d896a23c7bb53907c2e6b15d3), [#23354](https://github.com/open-webui/open-webui/issues/23354)
- ➕ **Attachment menu auto-close.** The chat attachment menu now closes immediately after selecting upload actions like file upload, camera capture, web attach, Google Drive, or OneDrive, preventing the menu from lingering on screen. [Commit](https://github.com/open-webui/open-webui/commit/4764dd5d3765c22384ed38cbc97a8170daa7a75f), [#23320](https://github.com/open-webui/open-webui/issues/23320)
- 🧹 **Per-chat draft clearing.** Sent message drafts are now cleared using the active chat key, so sent text no longer reappears in the input after a refresh. [Commit](https://github.com/open-webui/open-webui/commit/124b7e9154d7f3ca8a16f2b90621209ac8d6b8c1), [#23296](https://github.com/open-webui/open-webui/issues/23296)
- ✉️ **Context-aware input action button.** The input now shows the send action when text or files are present during generation, while keeping stop controls for truly empty input states to avoid action confusion. [Commit](https://github.com/open-webui/open-webui/commit/86472bb4453af7ea4e5ddc8d127b14d8e67733bc), [#23306](https://github.com/open-webui/open-webui/issues/23306)
- 📉 **Pyodide prompt cache stability.** Pyodide code interpreter context is now appended to the system prompt instead of user messages, preserving stable prefix caching across turns and reducing repeated token costs in long native tool-calling chats. [#23269](https://github.com/open-webui/open-webui/issues/23269)
- 🧪 **Temp chat outlet filtering.** Outlet filters now process temporary chats more reliably, preserving assistant output and usage data so local chat responses stay consistent when filter pipelines are enabled. [Commit](https://github.com/open-webui/open-webui/commit/70a6a24f143b221c787bc50b72582ee1e0c2dac0)
### 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.
- 🧨 **Plugin async migration required.** Custom plugins for Tools, Functions, and Pipelines may require migration to the new async backend signatures after upgrading, so plugin maintainers should update handlers and database call patterns for compatibility and follow the 0.9.0 plugin migration guide. [Migration Guide](https://docs.openwebui.com/features/extensibility/plugin/migration/to-0.9.0)
- 🔄 **Automation terminal source.** Automations now use the terminal configured on the selected model instead of a separate per-automation terminal picker, keeping terminal behavior consistent between chat and scheduled runs. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d)
- 🚧 **OpenAI passthrough now opt-in.** Direct OpenAI catch-all proxy requests are now disabled by default and require enabling "ENABLE_OPENAI_API_PASSTHROUGH", so deployments relying on passthrough must explicitly turn it on after upgrading. [#23640](https://github.com/open-webui/open-webui/pull/23640)
- 🗄️ **SQLite WAL default enabled.** SQLite deployments now default to enabling write-ahead logging, improving concurrent read and write behavior without requiring manual configuration. [Commit](https://github.com/open-webui/open-webui/commit/2f9e326dba3b1087932cb6b8075ed1881bd1c6d6)
## [0.8.12] - 2026-03-26
### Added
- 🌐 **Translation updates.** Translations for Simplified Chinese, Catalan, Portuguese (Brazil), Finnish, and Lithuanian were enhanced and expanded.
### Fixed
- 🔒 **Terminal server connection security.** Terminal server verification and policy saving now proxy through the backend, preventing API key exposure and CORS errors when connecting to in-cluster services. [Commit](https://github.com/open-webui/open-webui/commit/a6413257079a52fa4487eda36543f3955d0fbd53), [Commit](https://github.com/open-webui/open-webui/commit/4567cdc0d9cb7b42b6eba7b676c0ced3f4850d31)
- 🛠️ **Terminal tools exception handling.** Exceptions in middleware.py due to invalid return values from get_terminal_tools() have been resolved. [Commit](https://github.com/open-webui/open-webui/commit/52a06bd48aff34fb2211aac2879f0cd028129267)
- 📦 **Missing beautifulsoup4 dependency.** Users can now start Open WebUI using uvx without encountering the "bs4 module missing" error. [Commit](https://github.com/open-webui/open-webui/commit/1994d65306bbcc7406584e1bfef82f5d353fc91c)
- 🔌 **API files list error.** The /api/v1/files/ endpoint no longer returns a 500 error, fixing a regression that prevented file listing via the API. [Commit](https://github.com/open-webui/open-webui/commit/11f52921dc21c2dc61c03f12bcdf6f19140a350c)
- 📜 **License data loading.** License data now loads correctly, displaying the expected color and logo in the interface. [Commit](https://github.com/open-webui/open-webui/commit/16335f866ea4cedf00c4971963622fcc1fe02d82)
- 👑 **Admin model visibility.** Administrators can now see models even when no access control is configured yet, allowing them to manage all available models. [Commit](https://github.com/open-webui/open-webui/commit/f3f8f9874f55282603c2650b91801640cb3f69cb)
- 📊 **Tool call embed visibility.** Rich UI embeds from tool calls (like visualizations) are now rendered outside collapsed groups and remain visible without requiring manual expansion. [Commit](https://github.com/open-webui/open-webui/commit/4c872a8d128757d4a6f311fb86bc382af2ba5d0d), [Commit](https://github.com/open-webui/open-webui/commit/308fa924a5b2b7e08cd1e8f15b9c8c96e1de8f02)
## [0.8.11] - 2026-03-25
### Added
- 🔀 **Responses API streaming improvements.** The OpenAI proxy now properly handles tool call streaming and re-invocations in the Responses API, preventing duplicate tool calls and preserving output during model re-invocations. [Commit](https://github.com/open-webui/open-webui/commit/93415a48e8893139db13d02d0a6d24e8604a2ac5), [Commit](https://github.com/open-webui/open-webui/commit/f8b3a32caf00dad76687fd8fe698b86f304f3997), [Commit](https://github.com/open-webui/open-webui/commit/2ae47cf20057e92a83fd618b938f3ee9bb124e5b), [Commit](https://github.com/open-webui/open-webui/commit/adcbba34f8bbfbab3e4041269a084f2b71c076d9)
- 🔀 **Responses API stateful sessions.** Administrators can now enable experimental stateful session support via the ENABLE_RESPONSES_API_STATEFUL environment variable, allowing compatible backends to store responses server-side with previous_response_id anchoring for improved multi-turn conversations. [Commit](https://github.com/open-webui/open-webui/commit/dfc2dc2c0bd298cb4bfcf212ef11223586aa54f1)
- 📄 **File viewing pagination.** The view_file and view_knowledge_file tools now support pagination with offset and max_chars parameters, allowing models to read large files in chunks. [Commit](https://github.com/open-webui/open-webui/commit/5d7766e1b6f7ca7749c5a5a780d7b1bb2da28a2f)
- 🗺️ **Knowledge search scoping.** The search_knowledge_files tool now respects model-attached knowledge, searching only within attached knowledge bases and files when available. [Commit](https://github.com/open-webui/open-webui/commit/0f0ba7dadd043460d205477fd3b57556aa970847)
- 🛠️ **Tool HTML embed context.** Tools can now return custom context alongside HTML embeds by using a tuple format, providing the LLM with actionable information instead of a generic message. [#22691](https://github.com/open-webui/open-webui/pull/22691)
- 🔒 **Trusted role header configuration.** Administrators can now configure the WEBUI_AUTH_TRUSTED_ROLE_HEADER environment variable to set user roles (admin, user, or pending) via a trusted header from their identity provider or reverse proxy. [#22523](https://github.com/open-webui/open-webui/pull/22523)
- 🔑 **OIDC authorization parameter injection.** Administrators can now inject extra parameters into the OIDC authorization redirect URL via the OAUTH_AUTHORIZE_PARAMS environment variable, enabling IdP pre-selection for brokers like CILogon and Keycloak. [#22863](https://github.com/open-webui/open-webui/issues/22863), [Commit](https://github.com/open-webui/open-webui/commit/69171a4c8bb7f995461b4a2feef194f112b32004)
- 🔑 **Google OAuth session persistence.** Administrators can now configure Google OAuth to issue refresh tokens via the GOOGLE_OAUTH_AUTHORIZE_PARAMS environment variable, preventing OAuth sessions from expiring after one hour and ensuring tools and integrations that rely on OAuth tokens remain functional. [#22652](https://github.com/open-webui/open-webui/pull/22652)
- 🔌 **Embed prompt confirmation.** Interactive tool embeds can now submit prompts to the chat without requiring same-origin access, showing a confirmation dialog for cross-origin requests to prevent abuse. [#22908](https://github.com/open-webui/open-webui/pull/22908)
- 🏮 **Tool binary response handling.** Tool servers can now return binary data such as images, which are properly processed and displayed in chat for both multimodal and non-multimodal models. [Commit](https://github.com/open-webui/open-webui/commit/1c25b06dca83ad491b4dc3d373b1c215a7a8fd3e), [Commit](https://github.com/open-webui/open-webui/commit/108a019cb8e63a533250abe84f2b6f2b7c2131c4)
- ⚡ **Svelte upgrade performance.** Page and markdown rendering are now approximately 25% faster across the board, with significantly less memory usage for smoother UI interactions. [#22611](https://github.com/open-webui/open-webui/issues/22611)
- 🧩 **Model and filter lookup optimization.** Model and filter membership lookups are now faster thanks to optimized data structure operations during model list loading. [Commit](https://github.com/open-webui/open-webui/commit/7eae377c01f8d2de94a694b72279f769c82658cd)
- 💨 **Chat render throttling.** Chat message rendering now uses requestAnimationFrame batching to stay smooth during rapid model responses, preventing dropped frames when fast models send many events per second. [#22947](https://github.com/open-webui/open-webui/pull/22947)
- 🚀 **Function list API optimization.** The functions list API now returns only essential metadata without function source code, reducing payload sizes by over 99% and making the Functions admin page load significantly faster. [#22788](https://github.com/open-webui/open-webui/pull/22788)
- ✨ **Smoother loading animation.** The loading shimmer animation now looks smoother and more natural, with softer highlight colors. [#22516](https://github.com/open-webui/open-webui/pull/22516)
- 🧪 **Terminal connection verification.** Users can now verify their terminal server connection is working before saving the configuration, making setup more reliable. [#22567](https://github.com/open-webui/open-webui/pull/22567)
- 📁 **Chat folder emoji reset.** Users can now reset chat folder emojis back to the default icon using a "Reset to Default" button in the emoji picker, making it easier to revert custom icons. [#22554](https://github.com/open-webui/open-webui/pull/22554)
- 📊 **Metrics export interval configuration.** Administrators can now control OpenTelemetry metrics export frequency via the OTEL_METRICS_EXPORT_INTERVAL_MILLIS environment variable, enabling cost optimization for metrics services like Grafana Cloud. [#22529](https://github.com/open-webui/open-webui/pull/22529)
- 🏥 **Readiness probe endpoint.** A new /ready endpoint is now available for Kubernetes deployments, returning 200 only after startup completes and database/Redis are reachable, enabling more reliable container orchestration. [#22507](https://github.com/open-webui/open-webui/pull/22507)
- 🔩 **Tool server timeout configuration.** Administrators can now configure a separate HTTP timeout for tool server requests via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable, enabling fine-tuned control over how long tool calls are allowed to take. [Commit](https://github.com/open-webui/open-webui/commit/a3238aa79f344765f5b62cb64eba71ffd001abaf)
- 📌 **Knowledge file previews.** Knowledge base files can now be opened in a new tab directly from the file list, making it easier to view content without downloading. [#22629](https://github.com/open-webui/open-webui/pull/22629)
- 🎯 **Knowledge tool hybrid search support.** The built-in query_knowledge_files tool now respects hybrid search and reranking settings, matching the behavior of the middleware RAG pipeline. [Commit](https://github.com/open-webui/open-webui/commit/9a2c60d5954ecbc172d09e9955d52a07d135dcbc)
- 🗣️ **Temporary chat folder support.** Temporary chats can now use folder-level system prompts and knowledge files, making them more powerful for quick explorations. [Commit](https://github.com/open-webui/open-webui/commit/adcc50d3370301afd5561e0f58ff6f3ab3750818)
- 📡 **Terminal port previews.** Detected ports in the File Navigator can now be previewed inline with a browser-style view, navigation controls, and an address bar, instead of only opening in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/689061822173e561a153290b2bb816f4cb6f4959), [Commit](https://github.com/open-webui/open-webui/commit/1dc647f43b1929f5c4d1af393a90a47f56cb745e)
- ✏️ **File renaming.** Files and folders in the File Navigator can now be renamed by double-clicking or using the context menu, with Enter to confirm and Escape to cancel. [Commit](https://github.com/open-webui/open-webui/commit/637cd136c2271baf4787815bc8bc25241626a943)
- 🧭 **File Navigator navigation history.** The File Navigator toolbar now includes Back and Forward buttons for navigating through folder and file history, similar to a web browser. [Commit](https://github.com/open-webui/open-webui/commit/3a4b862e818c69fff6f3a3c67b50c51aa00c03e9)
- 🗑️ **Delete connection confirmations.** Users are now prompted with a confirmation dialog before deleting connections, preventing accidental deletions. [Commit](https://github.com/open-webui/open-webui/commit/157ff57c40bc40c53bc608828dac3779e95c2ffa)
- 📦 **Document loader fallbacks.** Excel and PowerPoint files can now be processed even when the unstructured package is not installed, using pandas and python-pptx as fallback loaders. [Commit](https://github.com/open-webui/open-webui/commit/6862d618ee17f95d3cae78819ed993e7fbc7e632)
- 🧠 **Memory management search and sort.** Users can now search and sort their personal memories in the Memory management modal, making it easier to find specific memories. [Commit](https://github.com/open-webui/open-webui/commit/47ab4c71d50fd631b04c95f2febb085dd0a13083)
- 📦 **SBOM generation script.** A new script for generating CycloneDX Software Bill of Materials is now available in the scripts directory. [Commit](https://github.com/open-webui/open-webui/commit/39100eca4915e4fe86a6912aa97dde86ed72e015)
- ⚙️ **Ruff linter and formatter.** Added Ruff as the Python linter and formatter, replacing the black-based workflow for better code quality with near-instant execution. [#22576](https://github.com/open-webui/open-webui/pull/22576), [#22462](https://github.com/open-webui/open-webui/discussions/22462)
- 🖥️ **Offline code formatting support.** The black formatter for Python code editing is now bundled locally in the Docker image, enabling code formatting to work in air-gapped deployments where client browsers cannot reach PyPI. Formatting failures no longer block saves, allowing code to be preserved even when offline. [#22509](https://github.com/open-webui/open-webui/issues/22509), [Commit](https://github.com/open-webui/open-webui/commit/8507e5eb0d18896f1bbf990a00a4361aec171a30)
- ✏️ **Markdown file editing.** Users can now edit and save Markdown files directly in the file navigator, with empty files automatically switching to editor mode for immediate editing. [Commit](https://github.com/open-webui/open-webui/commit/47e47e42af682e7f75c8359999f7cdf969bf903e)
- 🍔 **Model bulk actions menu.** Users can now quickly enable, disable, show, or hide multiple models at once using a new hamburger menu on the workspace Models page filter bar, with actions respecting the current search and filter settings. [#22484](https://github.com/open-webui/open-webui/pull/22484)
- 📂 **Files list pagination.** The files list API now supports pagination, returning paginated results with a total count for easier navigation through large file collections. [Commit](https://github.com/open-webui/open-webui/commit/f9756de693a93e918c037d757afddb7defc847e4)
- 🖇 **Web fetch content length config.** Administrators can now configure the maximum characters to return from fetched URLs via WEB_FETCH_MAX_CONTENT_LENGTH environment variable or the admin settings page, instead of the previous hardcoded 50K limit. [Commit](https://github.com/open-webui/open-webui/commit/b171b0216b916745420c7caf513093a315ed9560), [#22774](https://github.com/open-webui/open-webui/issues/22774)
- 🤖 **Ollama Anthropic endpoint support.** The Ollama proxy now supports the Anthropic-compatible /v1/messages endpoint, allowing clients using the Anthropic API format to work through Open WebUI with proper authentication and model access controls. [Commit](https://github.com/open-webui/open-webui/commit/f23296b22d3304e5bfcd19151e5802eec55bd98f), [#22861](https://github.com/open-webui/open-webui/issues/22861)
- 📝 **Writing block rendering.** Responses from OpenAI models that include :::writing blocks are now rendered as formatted content in a styled container with a copy button, instead of displaying raw marker text. [#22672](https://github.com/open-webui/open-webui/issues/22672), [Commit](https://github.com/open-webui/open-webui/commit/53b8a1f71bd0cb0a0122175ad5210da492018728)
- 💡 **Memory deletion confirmation.** Users are now asked to confirm before deleting individual memory entries, with the memory content displayed for review. [#22888](https://github.com/open-webui/open-webui/pull/22888)
- 📓 **Multi-artifact HTML rendering.** Code blocks with multiple HTML sections now render as separate artifacts instead of merging into one, allowing models to display distinct interactive components. [Commit](https://github.com/open-webui/open-webui/commit/9a6bf78e14a13864e72db87426da4f5996abe716)
- 🚩 **Drag chats as references.** Users can now drag chats from the sidebar and drop them into the message input to add them as Reference Chats. [Commit](https://github.com/open-webui/open-webui/commit/ebb7ce2092efc8d78da4974623647dbd18b6e372)
- ⌨️ **Terminal system prompts.** Terminal servers can now provide custom system prompts that are automatically included when their tools are used. [Commit](https://github.com/open-webui/open-webui/commit/6a9d67b5bb4c93fd343b334bee3e37703dff59f6)
- 💾 **Terminal state persistence.** The selected terminal server and its enabled state now persist across page loads, making terminal usage more seamless. [Commit](https://github.com/open-webui/open-webui/commit/d577ff1e4af750dda09e558dac7edb8dd2470850)
- 💾 **Terminal folder downloads.** Users can now download folders as ZIP archives and bulk-download multiple selected files as a single ZIP directly from the File Navigator toolbar, making file exports faster and more convenient. [Commit](https://github.com/open-webui/open-webui/commit/3841e85abb3ea3e8d8b364dff0102f0124844d22), [Commit](https://github.com/open-webui/open-webui/commit/cf60b1882f1929200649b59f867289dea54e4210)
- 🔐 **MCP OAuth 2.1 static credentials.** MCP servers that require static client_id and client_secret can now be connected using a new OAuth 2.1 Static auth type, enabling integration with MCP servers that don't support dynamic client registration. [#22266](https://github.com/open-webui/open-webui/pull/22266), [Commit](https://github.com/open-webui/open-webui/commit/601bb783587a3e965cf88c148e4856b988655b13)
- 🎪 **Collapsible tool and thinking groups.** Consecutive tool calls and reasoning blocks are now grouped into a single collapsible summary (e.g., "Explored tool1, tool2"), keeping chat responses clean and readable while preserving full detail on expand. [#21604](https://github.com/open-webui/open-webui/issues/21604), [Commit](https://github.com/open-webui/open-webui/commit/261aec8c864646eb7215be0d5c14a79cad3cb93f)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Finnish, Portuguese (Portugal), Catalan, Turkish, Japanese, Simplified Chinese, Traditional Chinese, Estonian, Spanish, Azerbaijani, and German were enhanced and expanded.
### Fixed
- 🔒 **Model access control bypass.** Fixed a security vulnerability where external clients could bypass model access controls by setting a URL parameter, preventing unauthorized access to restricted models. [Commit](https://github.com/open-webui/open-webui/commit/c0385f60ba049da48d2d5452068586d375303c37)
- 🛡️ **Terminal proxy path sanitization.** The terminal server proxy now properly sanitizes paths to prevent directory traversal and SSRF attacks, protecting against security vulnerabilities. [Commit](https://github.com/open-webui/open-webui/commit/f9d38a073fae32032ed44073cf2817cba20210bb)
- 🛡️ **Tool configuration access control.** Tool configuration endpoints now properly verify user permissions, preventing unauthorized access to tool settings. [Commit](https://github.com/open-webui/open-webui/commit/bc5b3ec6b8ec0fef894eb8046c636ee33688b8c4)
- 🗝️ **Tool valves access control.** The tool user valves endpoints now properly verify ownership and access grants before returning or updating configuration, with appropriate 404 responses for missing tools and 401 for unauthorized access. [Commit](https://github.com/open-webui/open-webui/commit/f949d17db1e62e0b79aecbbcbcabe3d57d8d4af6)
- 🔐 **Collaborative document authorization.** Fixed a security vulnerability in collaborative documents where authorization could be bypassed using alternative document ID formats, preventing unauthorized access to notes. [Commit](https://github.com/open-webui/open-webui/commit/3107a5363d13c899a995c930cbb1121a80f754f9)
- 🔏 **OAuth session persistence.** Users logging in via OAuth or OIDC providers now stay logged in for the configured JWT expiry duration instead of being logged out when closing the browser. [#22809](https://github.com/open-webui/open-webui/pull/22809)
- 🚪 **OAuth sub claim configuration crash.** Using the OAUTH_SUB_CLAIM environment variable no longer causes crashes during token exchange requests, fixing a missing configuration registration. [#22865](https://github.com/open-webui/open-webui/pull/22865)
- 🔍 **OAuth discovery header parsing.** The OAuth protected resource discovery now correctly handles both quoted and unquoted values in the WWW-Authenticate header, fixing compatibility with MCP servers that return unquoted metadata. [#22646](https://github.com/open-webui/open-webui/discussions/22646), [Commit](https://github.com/open-webui/open-webui/commit/fe7e002fea7283abcf901e22de5c8a7d86e336ea)
- 👤 **Admin OAuth group sync.** Admin user group memberships from OAuth and LDAP providers are now properly synced to Open WebUI, fixing a limitation where admin role excluded users from group updates. [#22537](https://github.com/open-webui/open-webui/pull/22537), [Commit](https://github.com/open-webui/open-webui/commit/a1aceb5f879abd130ef83085d98a0d51316a8fc3)
- 🎫 **Password change complexity validation.** Password complexity rules are now properly enforced when users change their password, closing a security gap where new passwords could bypass configured complexity requirements. [Commit](https://github.com/open-webui/open-webui/commit/bd8aa3b6a0b6a2320f41b20a51b9842f39aadb7f)
- 🔏 **OAuth role enforcement.** OAuth role management now properly denies access when a user's roles don't match any configured OAUTH_ALLOWED_ROLES or OAUTH_ADMIN_ROLES, instead of silently bypassing the restriction. [#13676](https://github.com/open-webui/open-webui/issues/13676), [#15551](https://github.com/open-webui/open-webui/issues/15551), [Commit](https://github.com/open-webui/open-webui/commit/6d7744c21903ec5a9ad951770dea76e9ba19cbcc)
- 🔑 **Microsoft Entra ID role claim preservation.** Role claims from Microsoft Entra ID tokens are now preserved during OAuth login, fixing ENABLE_OAUTH_ROLE_MANAGEMENT for Microsoft OAuth which was previously ignored because the userinfo endpoint stripped the roles claim. [#20518](https://github.com/open-webui/open-webui/issues/20518), [Commit](https://github.com/open-webui/open-webui/commit/aa2f7fbe5229c3985ce427602069cdeababda481)
- 🔍 **SCIM group filtering.** The SCIM endpoint now properly handles displayName and externalId filters when provisioning groups from identity providers like Microsoft Entra ID, preventing all groups from being returned instead of the filtered subset. [#21543](https://github.com/open-webui/open-webui/pull/21543)
- 🔐 **Forwarded allow IPs configuration.** The FORWARDED_ALLOW_IPS environment variable is now properly respected by the startup scripts instead of being hardcoded to '\*', allowing administrators to restrict which proxies are trusted for request forwarding. [#22539](https://github.com/open-webui/open-webui/issues/22539), [Commit](https://github.com/open-webui/open-webui/commit/0aebdd5f83cd1d811009edcbb2bec432a34e7c81)
- 🍪 **Model list auth cookie forwarding.** Model list requests to backends that require cookie-based authentication now properly forward auth headers and cookies, preventing "Unauthorized" errors when loading models. [Commit](https://github.com/open-webui/open-webui/commit/76ece4049e96bd6890593f17a946a9af6b082fab)
- 🔱 **Model lookup race condition.** Fixed a race condition in Redis model storage that caused intermittent "model not found" errors in multi-replica deployments under heavy load, by eliminating the window between hash deletion and updates. [Commit](https://github.com/open-webui/open-webui/commit/ee901fcd2ca82d7a7dad48170c64df782d3e040a)
- 🎚️ **Bulk model action reliability.** Bulk enable, disable, show, and hide operations in the admin Models settings now properly refresh the model list after completion, ensuring changes are reflected immediately and correct toast notifications are shown. [#22962](https://github.com/open-webui/open-webui/pull/22962), [Commit](https://github.com/open-webui/open-webui/commit/75932be880f3b86f78f00b4352b9f1350b8f53fa), [Commit](https://github.com/open-webui/open-webui/commit/15ae3f588b1aa4ddb686ae68afebd6064036a201)
- 🔄 **Paginated list duplicates.** Fixed duplicate items appearing in paginated lists when loading more items in chats, knowledge, notes, and search across the UI. [Commit](https://github.com/open-webui/open-webui/commit/58e78e8946fb3644107489fe8e01b17709302b2f)
- 🧽 **Duplicate chat list refresh.** Sending messages no longer triggers duplicate sidebar chat list refreshes, eliminating an unnecessary database query that was already handled by the save and completion handlers. [#22982](https://github.com/open-webui/open-webui/pull/22982)
- 🧹 **Chat history save optimization.** The chat list is no longer refreshed on every chat history save, branch navigation, or edit — only on meaningful state changes like new chat creation, title generation, and response completion. [#22983](https://github.com/open-webui/open-webui/pull/22983)
- 💬 **Message queue responsiveness.** The message queue no longer waits for background tasks like title generation and follow-up suggestions to complete, allowing users to send new messages immediately after a response finishes without unnecessary delays. [Commit](https://github.com/open-webui/open-webui/commit/486c004cbb43f15d5c3e31561f51f22effff1f6c), [#22565](https://github.com/open-webui/open-webui/issues/22565)
- 🗄️ **Migration reliability.** Database migrations no longer fail when chat data has unexpected format, making upgrades more reliable. [#22588](https://github.com/open-webui/open-webui/pull/22588), [#22568](https://github.com/open-webui/open-webui/issues/22568)
- 🫧 **Memory modal event bubbling.** Fixed an issue where clicking the Delete button in the Memory management modal would also open the Edit Memory modal due to event bubbling. [#22783](https://github.com/open-webui/open-webui/issues/22783)
- 🧩 **Memory tool registration.** Models with capabilities.memory: true now correctly have memory tools available for execution, fixing a retry loop where add_memory appeared in the tool schema but was not registered for backend execution. [#22666](https://github.com/open-webui/open-webui/issues/22666), [#22675](https://github.com/open-webui/open-webui/pull/22675), [Commit](https://github.com/open-webui/open-webui/commit/d9339919046c3e977f313f603782d220aab4257f)
- 📝 **Input variables modal crash.** Fixed a crash that occurred when selecting custom prompts with prompt variables, causing the Input Variables modal to display an infinite loading spinner instead of the variable input fields. [#22748](https://github.com/open-webui/open-webui/issues/22748), [Commit](https://github.com/open-webui/open-webui/commit/0dcd6ac983bede06b8477179192154467f5b24a2)
- 🪛 **Function list API crash fix.** Fixed a 500 error on the functions list API endpoint that was introduced by the recent optimization, by adding proper model configuration for SQLAlchemy ORM objects. [#22924](https://github.com/open-webui/open-webui/pull/22924)
- 🗂️ **Sidebar chat menu closure.** Sidebar chat dropdown menus now close properly after clicking "Clone", "Share", "Download", "Rename", "Pin", "Move", "Archive", or "Delete", instead of remaining visible. [#22884](https://github.com/open-webui/open-webui/pull/22884), [#22784](https://github.com/open-webui/open-webui/issues/22784)
- 🧭 **Chat deletion and archive redirection.** Users are now redirected to the chat list when deleting or archiving the currently active chat, instead of being left on a stale chat page. [#22755](https://github.com/open-webui/open-webui/pull/22755)
- 🚩 **User menu navigation fix.** Clicking Playground or Admin Panel from the user menu now uses client-side routing instead of causing full page reloads, restoring smooth SPA navigation. [Commit](https://github.com/open-webui/open-webui/commit/7ffcd3908ee90f88a4c4684d6cd6e75efd117461)
- 🔧 **Tool server connection persistence.** Fixed a bug where tool server connection updates were not being saved to persistent storage, ensuring OAuth client information is now properly preserved. [Commit](https://github.com/open-webui/open-webui/commit/b8ea267f8ec3931de55db7801156b9c07d3ad5f6)
- 🔩 **Tool server index bounds checking.** Tool servers with invalid indices no longer crash the application with IndexError after upgrades, preventing tool server configuration loss. [#22490](https://github.com/open-webui/open-webui/issues/22490), [Commit](https://github.com/open-webui/open-webui/commit/8da29566a1f81c38e80009bdea3ce4d9be860605)
- 🔌 **Tool server frontend timeout.** Fetch requests to external tool servers now time out after 10 seconds, preventing the UI from hanging indefinitely when a configured tool server is unreachable. [#22543](https://github.com/open-webui/open-webui/issues/22543), [Commit](https://github.com/open-webui/open-webui/commit/adf7af34ff934319a35470c572237d2d08f1de0b)
- 🔌 **MCP OAuth tool auto-selection.** MCP tools requiring OAuth authentication are now automatically re-selected after completing the auth flow, instead of leaving users to manually re-enable the tool on return to the chat. [#22994](https://github.com/open-webui/open-webui/issues/22994), [#22995](https://github.com/open-webui/open-webui/pull/22995), [Commit](https://github.com/open-webui/open-webui/commit/4d50001c4192c609b1010626ebb6496692823873)
- 🏷️ **Channel @mentions.** Direct connection models no longer appear in channel @mention suggestions, preventing confusion since they don't work in channels. [#22553](https://github.com/open-webui/open-webui/issues/22553), [Commit](https://github.com/open-webui/open-webui/commit/0a87c1ecd078320a08c4cc62d41fe8727fb3b5f7)
- 📎 **Channel message attachments.** Users can now press Enter to send messages with only file or image attachments in channels, direct messages, and threads, aligning with the behavior of the Send button. [#22752](https://github.com/open-webui/open-webui/pull/22752)
- 🗣️ **Image-only message handling.** Models like Gemini and Claude no longer fail when receiving messages with only file or image attachments and no text, by stripping empty text content blocks before sending to the API. [Commit](https://github.com/open-webui/open-webui/commit/ea515fa26e11faac146c48a5e3a2a284e1792bb3), [#22880](https://github.com/open-webui/open-webui/issues/22880)
- 🧹 **Channel thread sidebar cleanup.** The thread sidebar in channels and direct messages now automatically closes when the parent message is deleted, preventing orphaned threads. [#22890](https://github.com/open-webui/open-webui/pull/22890)
- 💡 **Chat input suggestion modal.** The suggestion modal for tags, mentions, and commands now correctly reappears when backspacing into a trigger character after it was dismissed. [#22899](https://github.com/open-webui/open-webui/pull/22899)
- ⏱️ **Chat action button timing.** Action buttons under assistant messages no longer appear prematurely when switching chats while a response is still streaming. [Commit](https://github.com/open-webui/open-webui/commit/ecba37070d6eb3cb033195a070b6c4ab5f396415), [#22891](https://github.com/open-webui/open-webui/issues/22891)
- 💬 **Skill and model mention persistence.** Skills selected via $ and models selected via @ in the chat input are now properly restored after a page refresh, instead of reverting to plain text while losing their interactive state. [#22913](https://github.com/open-webui/open-webui/issues/22913), [Commit](https://github.com/open-webui/open-webui/commit/be21db706993c0db95ac09509dfdb023de64daff)
- 🧹 **Webhook profile image errors.** Fixed 404 errors appearing in the browser console when scrolling through channel messages sent by webhooks, by skipping the user profile preview for webhook senders. [#22893](https://github.com/open-webui/open-webui/pull/22893)
- 🧮 **Logit bias parameter handling.** Using logit_bias parameters no longer causes errors when the input is already in dictionary format. [#22597](https://github.com/open-webui/open-webui/issues/22597), [Commit](https://github.com/open-webui/open-webui/commit/e34ed72e1e958505e940b74bf1c6a4808640bd17)
- 🪛 **Temp chat tool calling.** Temporary chats now properly preserve tool call information, fixing native tool calling with JSON schema that was previously broken. [#22475](https://github.com/open-webui/open-webui/pull/22475), [Commit](https://github.com/open-webui/open-webui/commit/bcd313c363ca50d71aa80bcb2f29c81fad3dff37)
- 🔗 **Multi-system message merging.** Models with strict chat templates like Qwen no longer fail when multiple pipeline stages inject separate system messages, as all system messages are now merged into one at the start. [#22505](https://github.com/open-webui/open-webui/issues/22505), [Commit](https://github.com/open-webui/open-webui/commit/631bd20c3537ce85bbaec02f9e0049c88fa8fdd4)
- 📜 **Public note access.** Opening public notes via direct share link no longer returns a 500 error caused by a missing function import. [#22680](https://github.com/open-webui/open-webui/issues/22680), [Commit](https://github.com/open-webui/open-webui/commit/566e25569e5e7d9c1e42db840ba4ba578887d208)
- 👤 **Terminal access user visibility.** The terminal connection access dialog now shows the currently logged-in user when searching for users to grant access, fixing an issue where users with identical display names were filtered incorrectly. [#22491](https://github.com/open-webui/open-webui/issues/22491), [Commit](https://github.com/open-webui/open-webui/commit/4a8f995c3fd4602ec2aaccc07efc4e8504dda84d)
- 👥 **User groups display.** User groups in the admin panel profile preview now wrap properly instead of overflowing horizontally, with a scrollbar when the list is long. [#22547](https://github.com/open-webui/open-webui/pull/22547)
- 🔧 **Model list drag-and-drop.** Fixed drag-and-drop reordering of models in admin settings, preventing UI glitches and state synchronization issues. [Commit](https://github.com/open-webui/open-webui/commit/753589e51ccbbe5c4f78a7d13e19c67e6c0000d7)
- 🖼️ **Model profile image fallbacks.** Model profile images now display a fallback icon when they fail to load, and model icons no longer disappear on paginated Models pages in admin and workspace settings. [#22485](https://github.com/open-webui/open-webui/pull/22485)
- 🖼️ **Profile image fallbacks.** Added fallback handlers for model and user profile images throughout the chat interface, preventing broken image icons when avatars fail to load. [#22486](https://github.com/open-webui/open-webui/pull/22486)
- 🧲 **RAG thinking model support.** Knowledge base queries now correctly parse JSON responses from thinking models like GLM-5 and DeepSeek-R1 by stripping their reasoning blocks before JSON extraction. [#22400](https://github.com/open-webui/open-webui/pull/22400)
- 🔍 **RAG query generation robustness.** The RAG query generation, web search, and image generation handlers now correctly extract JSON from model responses containing thinking tags by finding the last JSON block instead of the first, preventing "No sources found" errors with thinking models. [#21888](https://github.com/open-webui/open-webui/issues/21888), [Commit](https://github.com/open-webui/open-webui/commit/c0fcbc5b4cb29012e2913983c632edc5d24b9aea)
- 🔍 **Ollama embedding robustness.** Ollama embedding requests now include the truncate parameter to handle inputs exceeding the context window, preventing 500 errors when processing long documents. Error messages from failed embedding requests are also now properly surfaced instead of being silently swallowed. [#22671](https://github.com/open-webui/open-webui/issues/22671), [Commit](https://github.com/open-webui/open-webui/commit/d738044f47c70c755bec9bf244aa11878fe98d9c)
- 🔄 **Ollama embedding retry logic.** Embedding requests to Ollama now retry with exponential backoff when encountering 503 errors (such as when the model reloads mid-processing), preventing files from being silently dropped from knowledge bases. [#22571](https://github.com/open-webui/open-webui/issues/22571), [Commit](https://github.com/open-webui/open-webui/commit/8b6fa1f4ab6099a305de08706621075c205f65c4)
- 🗄️ **Oracle 23AI hybrid search.** Fixed an UnboundLocalError that occurred when using hybrid search with Oracle 23AI as the vector store, preventing knowledge base queries from failing. [Commit](https://github.com/open-webui/open-webui/commit/fcf720835285a4cea10fc1ebed0b454971463b20), [#22616](https://github.com/open-webui/open-webui/issues/22616)
- 🌐 **Dynamic HTML language attribute.** The HTML lang attribute now dynamically updates when users change their interface language, preventing browsers from triggering unwanted translation popups. [Commit](https://github.com/open-webui/open-webui/commit/de5e0fbc00e7abcd84e1272c301b0707f8ea5ac6)
- 📐 **File upload deduplication.** Attaching files that are already in the chat no longer triggers duplicate uploads. [Commit](https://github.com/open-webui/open-webui/commit/10f06a64fed474e9958b96295a953e0eebf9e4be)
- 🕵️ **Serper.dev search results.** Fixed web search results not displaying properly when using the Serper.dev provider by using the correct API response field. [#22869](https://github.com/open-webui/open-webui/pull/22869)
- 🔲 **Markdown task list checkbox styling.** Fixed task list checkboxes in markdown rendering to display consistently without shrinking in narrow layouts. [#22886](https://github.com/open-webui/open-webui/pull/22886)
- 🎨 **Artifacts sidebar tab background fix.** The Artifacts sidebar now correctly updates and displays when switching back to a browser tab that was in the background, ensuring artifacts are visible without requiring a manual refresh. [#22889](https://github.com/open-webui/open-webui/issues/22889)
- 🔃 **Chat input URL indexing fix.** Fixed an issue where URLs could be indexed twice when using multiple triggers followed by backspace and re-entering a URL. [#22749](https://github.com/open-webui/open-webui/issues/22749)
- 🔎 **Search modal chat preview avatars.** Fixed assistant profile images not displaying in the chat preview pane of the Search Modal. [#22782](https://github.com/open-webui/open-webui/pull/22782)
- 📋 **Prompts search pagination fix.** Fixed a bug where searching prompts from a paginated page would incorrectly use the current page number, resulting in "No prompts found" even when matching results existed. [#22912](https://github.com/open-webui/open-webui/pull/22912)
- 🗂️ **Reasoning block copy cleanup.** Copied chat responses no longer include reasoning block content or excess whitespace, ensuring only the intended message text is captured. [#22786](https://github.com/open-webui/open-webui/issues/22786), [Commit](https://github.com/open-webui/open-webui/commit/4f0e57420154800946394bc986b2c691462b2782)
- 🔤 **Emoji removal for text normalization.** Fixed the emoji removal function used in search and title generation to correctly handle all emoji types, including those with variation selectors (❤️, ☀️, ✅), keycap sequences (1️⃣), and ZWJ family sequences (👨‍👩‍👧‍👦). [#22915](https://github.com/open-webui/open-webui/pull/22915)
- ⏹️ **Task cancellation status tracking.** Cancelled tasks now correctly mark only the affected messages as done instead of clearing all task statuses for the chat, ensuring proper status tracking when multiple messages have pending tasks. [#22743](https://github.com/open-webui/open-webui/pull/22743)
- 🎨 **Filter icon display fix.** Fixed filter icons showing the wrong icon after removing one of multiple active filters below the chat input. [#22862](https://github.com/open-webui/open-webui/pull/22862)
- 📊 **Channel message data loading.** Fixed redundant 404 API calls that occurred when rendering channel messages, preventing unnecessary requests and console errors. [#22894](https://github.com/open-webui/open-webui/pull/22894)
- 👻 **Response message skeleton display.** Fixed an issue where the skeleton loader would incorrectly show or hide based on complex status history conditions, by extracting the visibility logic into a cleaner reactive variable. [Commit](https://github.com/open-webui/open-webui/commit/5df4277216fbb9de603fdf4289f8366292568234)
- 🐛 **Shared chat viewing crash.** Shared chats can now be viewed by unauthenticated users without crashing, with proper fallback handling for missing user profile information. [#22751](https://github.com/open-webui/open-webui/pull/22751), [#22742](https://github.com/open-webui/open-webui/issues/22742)
- 🛠️ **Plugin ID sanitization.** Creating Functions or Tools with emojis or special characters in their names now generates valid IDs that pass backend validation, instead of failing with an error. [#22695](https://github.com/open-webui/open-webui/pull/22695)
- 📋 **Chat title preservation.** Regenerating responses or using branches no longer overwrites user-specified chat titles when auto-naming is disabled, by checking the full chat message count instead of just the current branch. [#22754](https://github.com/open-webui/open-webui/pull/22754)
- 🎧 **Read Aloud in chat preview.** The Read Aloud button in the Search Chats modal preview no longer causes crashes, and TTS functionality is now properly hidden in read-only chat contexts. [Commit](https://github.com/open-webui/open-webui/commit/d8fa0f426a88f5c27b3216b7db35e1db47bbba28)
- 📡 **Heartbeat event loop blocking.** The WebSocket heartbeat handler no longer blocks the event loop when updating user activity, improving responsiveness under heavy load with many concurrent connections. [#22980](https://github.com/open-webui/open-webui/pull/22980)
- 🗝️ **Message upsert API reliability.** The message upsert API endpoint no longer crashes when called, fixing an error where a database session was incorrectly passed to a function that doesn't accept it. [#22959](https://github.com/open-webui/open-webui/issues/22959), [Commit](https://github.com/open-webui/open-webui/commit/70285fb6cad26b50d783583b68be5227ace16055)
- 🔓 **Forward auth proxy compatibility.** Fixed error pages that could appear when using authenticating reverse-proxies by properly handling 401 responses from background API requests, allowing the browser to re-authenticate with the identity provider. [#22942](https://github.com/open-webui/open-webui/pull/22942)
- 🔃 **Tool call streaming display.** Sequential tool calls are now properly accumulated during streaming, fixing an issue where completed tool calls could disappear from the display before the next tool call finished streaming. [Commit](https://github.com/open-webui/open-webui/commit/a9c5c787b9f6b10491924d38645042064b3c941e)
- 🧠 **Reasoning spinner content preservation.** Prior assistant content and tool call blocks no longer disappear during the reasoning spinner when responding after tool execution. [#23001](https://github.com/open-webui/open-webui/pull/23001)
- 🖥️ **Pyodide file list refresh.** Files created or modified during manual code execution now appear immediately in the pyodide files list without requiring a browser tab refresh. [Commit](https://github.com/open-webui/open-webui/commit/5c4062c64841974bf193ff321d92d10f28a09746)
- 🖱️ **Dropdown submenu hover stability.** Secondary hover menus like Download and Move now remain open while navigating into them, fixing an issue where an 8px gap between the trigger and submenu would cause the menu to disappear before a selection could be made. [#22744](https://github.com/open-webui/open-webui/issues/22744), [Commit](https://github.com/open-webui/open-webui/commit/cffbc3558e911abd6c4780cd028794b2f7282cd7)
- 📊 **Model tag normalization.** Model tags from backends that return them as string arrays are now properly normalized to object format, preventing crashes when filtering models by tag in the admin and workspace models pages. [#20819](https://github.com/open-webui/open-webui/issues/20819), [Commit](https://github.com/open-webui/open-webui/commit/90ca2e9b0f15cc9be7cf298fbefacaa45074cae9)
- 🎯 **Arena model sub-model settings.** Arena models now properly use the selected sub-model's settings — including RAG knowledge bases, web access, code interpreter, and tool capabilities — instead of the arena wrapper's empty defaults. [#16950](https://github.com/open-webui/open-webui/issues/16950), [Commit](https://github.com/open-webui/open-webui/commit/857d7e6f373d26a7a8989417c3a7fe99cdc03f20)
- 🧩 **Model editor default metadata.** The Model Editor now loads admin-configured default model metadata instead of hardcoded values, preventing admin defaults from being silently overwritten when users save models without realizing they were overriding system-wide settings. [#22996](https://github.com/open-webui/open-webui/issues/22996), [Commit](https://github.com/open-webui/open-webui/commit/cdc2b3bf850044051aafcd46f22fb25a1899788c)
- ✏️ **Rich text paste sanitization.** Copying and pasting text with HTML characters (like `<` or `>`) no longer corrupts the editor content, as the paste handler now properly escapes HTML entities before processing mentions and special syntax. [Commit](https://github.com/open-webui/open-webui/commit/94f877ff328d410339308ad2c566c9afcdf43014)
### Changed
- 🪝 **User webhooks disabled by default.** User webhook notifications are now disabled by default and properly gated by the ENABLE_USER_WEBHOOKS configuration, ensuring webhooks only fire when explicitly enabled. [Commit](https://github.com/open-webui/open-webui/commit/c24a4da17dbaddf47e2e0f865c1d602d0ff36ee6)
- 🧩 **MCP integration visibility.** MCP (Streamable HTTP) integrations are now hidden from user-level settings, matching the intended behavior where only administrators can configure MCP connections through the admin panel. User-level connections now show the connection type as read-only. [#22615](https://github.com/open-webui/open-webui/issues/22615), [Commit](https://github.com/open-webui/open-webui/commit/1eef5b4f6a718c0fcf3605f1ed62669aca07b454)
- 🧲 **Web search result limit.** The configured web search result count now acts as a maximum limit, preventing models from requesting more results than administrators allow. [#22577](https://github.com/open-webui/open-webui/pull/22577)
## [0.8.10] - 2026-03-08
### Added
@@ -3972,7 +4509,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
- Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
- When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
+14 -10
View File
@@ -135,29 +135,33 @@ RUN apt-get update && \
# install python dependencies
COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt
RUN pip3 install --no-cache-dir uv && \
# Set UV_LINK_MODE to copy to prevent 0-byte file corruption in QEMU arm64 cross-builds
ENV UV_LINK_MODE=copy
RUN set -e; \
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
# 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')" && \
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<=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 && \
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 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/ && \
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
rm -rf /var/lib/apt/lists/*;
# Install Ollama if requested
+21 -3
View File
@@ -1,3 +1,5 @@
Open WebUI License
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
All rights reserved.
@@ -15,11 +17,27 @@ modification, are permitted provided that the following conditions are met:
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
4. Notwithstanding any other provision of this License, and as a material
condition of the rights granted herein, licensees are strictly prohibited
from altering, removing, obscuring, or replacing any "Open WebUI"
branding, including but not limited to the name, logo, or any visual,
textual, or symbolic identifiers that distinguish the software and its
interfaces, in any deployment or distribution, except in the following
circumstances: (i) deployments or distributions where the total number
of end users (defined as individual natural persons with direct access
to the application) does not exceed fifty (50) within any rolling
thirty (30) day period; (ii) the licensee has obtained specific prior
written permission from the copyright holder; or (iii) where the
licensee has obtained a duly executed enterprise license expressly
permitting such modification. For all other cases, any removal or
alteration of the "Open WebUI" branding shall constitute a material
breach of license.
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
Materials governed by prior licenses retain those original license
terms, as specified in LICENSE_HISTORY.
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
By contributing to this project, you agree to the project's Contributor
License Agreement (CONTRIBUTOR_LICENSE_AGREEMENT).
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+1 -3
View File
@@ -47,7 +47,7 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
- 💾 **Persistent Artifact Storage**: Built-in key-value storage API for artifacts, enabling features like journals, trackers, leaderboards, and collaborative tools with both personal and shared data scopes across sessions.
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support using your choice of 9 vector databases and multiple content extraction engines (Tika, Docling, Document Intelligence, Mistral OCR, PaddleOCR-vl, External loaders). Load documents directly into chat or add files to your document library, effortlessly accessing them using the `#` command before a query.
- 🔍 **Web Search for RAG**: Perform web searches using 15+ providers including `SearXNG`, `Google PSE`, `Brave Search`, `Kagi`, `Mojeek`, `Tavily`, `Perplexity`, `serpstack`, `serper`, `Serply`, `DuckDuckGo`, `SearchApi`, `SerpApi`, `Bing`, `Jina`, `Exa`, `Sougou`, `Azure AI Search`, and `Ollama Cloud`, injecting results directly into your chat experience.
@@ -172,8 +172,6 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
### Troubleshooting
Encountering connection issues? Our [Open WebUI Documentation](https://docs.openwebui.com/troubleshooting/) has got you covered. For further assistance and to join our vibrant community, visit the [Open WebUI Discord](https://discord.gg/5rJgQTnV4s).
+1 -1
View File
@@ -1,3 +1,3 @@
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
PORT="${PORT:-8080}"
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload
+39 -39
View File
@@ -1,103 +1,103 @@
import base64
import os
import random
import sys
from pathlib import Path
from typing import Annotated
import typer
import uvicorn
from typing import Optional
from typing_extensions import Annotated
app = typer.Typer()
KEY_FILE = Path.cwd() / ".webui_secret_key"
KEY_FILE = Path.cwd() / '.webui_secret_key'
def version_callback(value: bool):
def version_callback(value: bool) -> None:
if value:
from open_webui.env import VERSION
typer.echo(f"Open WebUI version: {VERSION}")
typer.echo(f'Open WebUI version: {VERSION}')
raise typer.Exit()
@app.command()
def main(
version: Annotated[
Optional[bool], typer.Option("--version", callback=version_callback)
] = None,
version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None,
):
pass
@app.command()
def serve(
host: str = "0.0.0.0",
host: str = '0.0.0.0',
port: int = 8080,
):
os.environ["FROM_INIT_PY"] = "true"
if os.getenv("WEBUI_SECRET_KEY") is None:
typer.echo(
"Loading WEBUI_SECRET_KEY from file, not provided as an environment variable."
)
os.environ['FROM_INIT_PY'] = 'true'
if os.getenv('WEBUI_SECRET_KEY') is None:
typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.')
if not KEY_FILE.exists():
typer.echo(f"Generating a new secret key and saving it to {KEY_FILE}")
typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}')
KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12)))
typer.echo(f"Loading WEBUI_SECRET_KEY from {KEY_FILE}")
os.environ["WEBUI_SECRET_KEY"] = KEY_FILE.read_text()
typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}')
os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text()
if os.getenv("USE_CUDA_DOCKER", "false") == "true":
typer.echo(
"CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries."
)
LD_LIBRARY_PATH = os.getenv("LD_LIBRARY_PATH", "").split(":")
os.environ["LD_LIBRARY_PATH"] = ":".join(
if os.getenv('USE_CUDA_DOCKER', 'false') == 'true':
typer.echo('CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries.')
LD_LIBRARY_PATH = os.getenv('LD_LIBRARY_PATH', '').split(':')
os.environ['LD_LIBRARY_PATH'] = ':'.join(
LD_LIBRARY_PATH
+ [
"/usr/local/lib/python3.11/site-packages/torch/lib",
"/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib",
'/usr/local/lib/python3.11/site-packages/torch/lib',
'/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib',
]
)
try:
import torch
assert torch.cuda.is_available(), "CUDA not available"
typer.echo("CUDA seems to be working")
assert torch.cuda.is_available(), 'CUDA not available'
typer.echo('CUDA seems to be working')
except Exception as e:
typer.echo(
"Error when testing CUDA but USE_CUDA_DOCKER is true. "
"Resetting USE_CUDA_DOCKER to false and removing "
f"LD_LIBRARY_PATH modifications: {e}"
'Error when testing CUDA but USE_CUDA_DOCKER is true. '
'Resetting USE_CUDA_DOCKER to false and removing '
f'LD_LIBRARY_PATH modifications: {e}'
)
os.environ["USE_CUDA_DOCKER"] = "false"
os.environ["LD_LIBRARY_PATH"] = ":".join(LD_LIBRARY_PATH)
os.environ['USE_CUDA_DOCKER'] = 'false'
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
import open_webui.main # we need set environment variables before importing main
import open_webui.main # noqa: F401
from open_webui.env import UVICORN_WORKERS # Import the workers setting
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
# which is incompatible with psycopg v3 async. Setting loop='none' lets
# asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py.
loop = 'none' if sys.platform == 'win32' else 'auto'
uvicorn.run(
"open_webui.main:app",
'open_webui.main:app',
host=host,
port=port,
forwarded_allow_ips="*",
forwarded_allow_ips='*',
workers=UVICORN_WORKERS,
loop=loop,
)
@app.command()
def dev(
host: str = "0.0.0.0",
host: str = '0.0.0.0',
port: int = 8080,
reload: bool = True,
):
uvicorn.run(
"open_webui.main:app",
'open_webui.main:app',
host=host,
port=port,
reload=reload,
forwarded_allow_ips="*",
forwarded_allow_ips='*',
)
if __name__ == "__main__":
if __name__ == '__main__':
app()
+1717 -1798
View File
File diff suppressed because it is too large Load Diff
+73 -80
View File
@@ -2,125 +2,118 @@ from enum import Enum
class MESSAGES(str, Enum):
DEFAULT = lambda msg="": f"{msg if msg else ''}"
MODEL_ADDED = lambda model="": f"The model '{model}' has been added successfully."
MODEL_DELETED = (
lambda model="": f"The model '{model}' has been deleted successfully."
)
DEFAULT = lambda msg='': f'{msg if msg else ""}'
MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully."
MODEL_DELETED = lambda model='': f"The model '{model}' has been deleted successfully."
class WEBHOOK_MESSAGES(str, Enum):
DEFAULT = lambda msg="": f"{msg if msg else ''}"
USER_SIGNUP = lambda username="": (
f"New user signed up: {username}" if username else "New user signed up"
)
DEFAULT = lambda msg='': f'{msg if msg else ""}'
USER_SIGNUP = lambda username='': f'New user signed up: {username}' if username else 'New user signed up'
class ERROR_MESSAGES(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = (
lambda err="": f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}'
ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.'
CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.'
DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.'
EMAIL_MISMATCH = 'Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again.'
EMAIL_TAKEN = 'Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew.'
USERNAME_TAKEN = 'Uh-oh! This username is already registered. Please choose another username.'
PASSWORD_TOO_LONG = (
'Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long.'
)
ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now."
CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance."
DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot."
EMAIL_MISMATCH = "Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again."
EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew."
USERNAME_TAKEN = (
"Uh-oh! This username is already registered. Please choose another username."
)
PASSWORD_TOO_LONG = "Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long."
COMMAND_TAKEN = "Uh-oh! This command is already registered. Please choose another command string."
FILE_EXISTS = "Uh-oh! This file is already registered. Please choose another file."
COMMAND_TAKEN = 'Uh-oh! This command is already registered. Please choose another command string.'
FILE_EXISTS = 'Uh-oh! This file is already registered. Please choose another file.'
ID_TAKEN = "Uh-oh! This id is already registered. Please choose another id string."
MODEL_ID_TAKEN = "Uh-oh! This model id is already registered. Please choose another model id string."
NAME_TAG_TAKEN = "Uh-oh! This name tag is already registered. Please choose another name tag string."
MODEL_ID_TOO_LONG = "The model id is too long. Please make sure your model id is less than 256 characters long."
ID_TAKEN = 'Uh-oh! This id is already registered. Please choose another id string.'
MODEL_ID_TAKEN = 'Uh-oh! This model id is already registered. Please choose another model id string.'
NAME_TAG_TAKEN = 'Uh-oh! This name tag is already registered. Please choose another name tag string.'
MODEL_ID_TOO_LONG = 'The model id is too long. Please make sure your model id is less than 256 characters long.'
INVALID_TOKEN = (
"Your session has expired or the token is invalid. Please sign in again."
)
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
INVALID_TOKEN = 'Your session has expired or the token is invalid. Please sign in again.'
INVALID_CRED = 'The email or password provided is incorrect. Please check for typos and try logging in again.'
INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)."
INCORRECT_PASSWORD = (
"The password provided is incorrect. Please check for typos and try again."
INCORRECT_PASSWORD = 'The password provided is incorrect. Please check for typos and try again.'
INVALID_TRUSTED_HEADER = (
'Your provider has not provided a trusted header. Please contact your administrator for assistance.'
)
INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance."
EXISTING_USERS = "You can't turn off authentication because there are existing users. If you want to disable WEBUI_AUTH, make sure your web interface doesn't have any existing users and is a fresh installation."
UNAUTHORIZED = "401 Unauthorized"
ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance."
ACTION_PROHIBITED = (
"The requested action has been restricted as a security measure."
UNAUTHORIZED = '401 Unauthorized'
ACCESS_PROHIBITED = (
'You do not have permission to access this resource. Please contact your administrator for assistance.'
)
ACTION_PROHIBITED = 'The requested action has been restricted as a security measure.'
FILE_NOT_SENT = "FILE_NOT_SENT"
FILE_NOT_SENT = 'FILE_NOT_SENT'
FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format and try again."
NOT_FOUND = "We could not find what you're looking for :/"
USER_NOT_FOUND = "We could not find what you're looking for :/"
API_KEY_NOT_FOUND = "Oops! It looks like there's a hiccup. The API key is missing. Please make sure to provide a valid API key to access this feature."
API_KEY_NOT_ALLOWED = "Use of API key is not enabled in the environment."
API_KEY_NOT_ALLOWED = 'Use of API key is not enabled in the environment.'
MALICIOUS = "Unusual activities detected, please try again in a few minutes."
MALICIOUS = 'Unusual activities detected, please try again in a few minutes.'
PANDOC_NOT_INSTALLED = "Pandoc is not installed on the server. Please contact your administrator for assistance."
INCORRECT_FORMAT = (
lambda err="": f"Invalid format. Please use the correct format{err}"
)
RATE_LIMIT_EXCEEDED = "API rate limit exceeded"
PANDOC_NOT_INSTALLED = 'Pandoc is not installed on the server. Please contact your administrator for assistance.'
INCORRECT_FORMAT = lambda err='': f'Invalid format. Please use the correct format{err}'
RATE_LIMIT_EXCEEDED = 'API rate limit exceeded'
MODEL_NOT_FOUND = lambda name="": f"Model '{name}' was not found"
OPENAI_NOT_FOUND = lambda name="": "OpenAI API was not found"
OLLAMA_NOT_FOUND = "WebUI could not connect to Ollama"
CREATE_API_KEY_ERROR = "Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance."
API_KEY_CREATION_NOT_ALLOWED = "API key creation is not allowed in the environment."
MODEL_NOT_FOUND = lambda name='': f"Model '{name}' was not found"
OPENAI_NOT_FOUND = lambda name='': 'OpenAI API was not found'
OLLAMA_NOT_FOUND = 'WebUI could not connect to Ollama'
CREATE_API_KEY_ERROR = 'Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance.'
API_KEY_CREATION_NOT_ALLOWED = 'API key creation is not allowed in the environment.'
EMPTY_CONTENT = "The content provided is empty. Please ensure that there is text or data present before proceeding."
EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.'
DB_NOT_SQLITE = "This feature is only available when running with SQLite databases."
DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.'
INVALID_URL = (
"Oops! The URL you provided is invalid. Please double-check and try again."
INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.'
WEB_SEARCH_ERROR = lambda err='': f'{err if err else "Oops! Something went wrong while searching the web."}'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
FILE_TOO_LARGE = lambda size='': (
f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
)
WEB_SEARCH_ERROR = (
lambda err="": f"{err if err else 'Oops! Something went wrong while searching the web.'}"
DUPLICATE_CONTENT = 'Duplicate content detected. Please provide unique content to proceed.'
FILE_NOT_PROCESSED = (
'Extracted content is not available for this file. Please ensure that the file is processed before proceeding.'
)
OLLAMA_API_DISABLED = (
"The Ollama API is disabled. Please enable it to use this feature."
)
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
FILE_TOO_LARGE = (
lambda size="": f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
)
AUTOMATION_LIMIT_EXCEEDED = lambda size='': f'Automation limit reached ({size})'
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
DUPLICATE_CONTENT = (
"Duplicate content detected. Please provide unique content to proceed."
)
FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding."
INVALID_PASSWORD = lambda err="": (
err if err else "The password does not meet the required validation criteria."
)
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'
SERVER_CONNECTION_ERROR = 'Open WebUI: Server Connection Error'
REQUIRED_FIELD_EMPTY = lambda name='': f'Required field {name} is empty'
OAUTH_NOT_CONFIGURED = lambda name='': f"Provider '{name}' is not configured"
class TASKS(str, Enum):
def __str__(self) -> str:
return super().__str__()
DEFAULT = lambda task="": f"{task if task else 'generation'}"
TITLE_GENERATION = "title_generation"
FOLLOW_UP_GENERATION = "follow_up_generation"
TAGS_GENERATION = "tags_generation"
EMOJI_GENERATION = "emoji_generation"
QUERY_GENERATION = "query_generation"
IMAGE_PROMPT_GENERATION = "image_prompt_generation"
AUTOCOMPLETE_GENERATION = "autocomplete_generation"
FUNCTION_CALLING = "function_calling"
MOA_RESPONSE_GENERATION = "moa_response_generation"
DEFAULT = lambda task='': f'{task if task else "generation"}'
TITLE_GENERATION = 'title_generation'
FOLLOW_UP_GENERATION = 'follow_up_generation'
TAGS_GENERATION = 'tags_generation'
EMOJI_GENERATION = 'emoji_generation'
QUERY_GENERATION = 'query_generation'
IMAGE_PROMPT_GENERATION = 'image_prompt_generation'
AUTOCOMPLETE_GENERATION = 'autocomplete_generation'
FUNCTION_CALLING = 'function_calling'
MOA_RESPONSE_GENERATION = 'moa_response_generation'
+439 -435
View File
File diff suppressed because it is too large Load Diff
+114 -116
View File
@@ -34,9 +34,10 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.tools import get_tools
from open_webui.utils.access_control import check_model_access
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.misc import (
add_or_update_system_message,
@@ -54,20 +55,18 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
def get_function_module_by_id(request: Request, pipe_id: str):
function_module, _, _ = get_function_module_from_cache(request, pipe_id)
async def get_function_module_by_id(request: Request, pipe_id: str):
function_module, _, _ = await get_function_module_from_cache(request, pipe_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
Valves = function_module.Valves
valves = Functions.get_function_valves_by_id(pipe_id)
valves = await Functions.get_function_valves_by_id(pipe_id)
if valves:
try:
function_module.valves = Valves(
**{k: v for k, v in valves.items() if v is not None}
)
function_module.valves = Valves(**{k: v for k, v in valves.items() if v is not None})
except Exception as e:
log.exception(f"Error loading valves for function {pipe_id}: {e}")
log.exception(f'Error loading valves for function {pipe_id}: {e}')
raise e
else:
function_module.valves = Valves()
@@ -76,19 +75,19 @@ def get_function_module_by_id(request: Request, pipe_id: str):
async def get_function_models(request):
pipes = Functions.get_functions_by_type("pipe", active_only=True)
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
pipe_models = []
for pipe in pipes:
try:
function_module = get_function_module_by_id(request, pipe.id)
function_module = await get_function_module_by_id(request, pipe.id)
has_user_valves = False
if hasattr(function_module, "UserValves"):
if hasattr(function_module, 'UserValves'):
has_user_valves = True
# Check if function is a manifold
if hasattr(function_module, "pipes"):
if hasattr(function_module, 'pipes'):
sub_pipes = []
# Handle pipes being a list, sync function, or async function
@@ -104,32 +103,30 @@ async def get_function_models(request):
log.exception(e)
sub_pipes = []
log.debug(
f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
)
log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}")
for p in sub_pipes:
sub_pipe_id = f'{pipe.id}.{p["id"]}'
sub_pipe_name = p["name"]
sub_pipe_name = p['name']
if hasattr(function_module, "name"):
sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
if hasattr(function_module, 'name'):
sub_pipe_name = f'{function_module.name}{sub_pipe_name}'
pipe_flag = {"type": pipe.type}
pipe_flag = {'type': pipe.type}
pipe_models.append(
{
"id": sub_pipe_id,
"name": sub_pipe_name,
"object": "model",
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
"has_user_valves": has_user_valves,
'id': sub_pipe_id,
'name': sub_pipe_name,
'object': 'model',
'created': pipe.created_at,
'owned_by': 'openai',
'pipe': pipe_flag,
'has_user_valves': has_user_valves,
}
)
else:
pipe_flag = {"type": "pipe"}
pipe_flag = {'type': 'pipe'}
log.debug(
f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
@@ -137,13 +134,13 @@ async def get_function_models(request):
pipe_models.append(
{
"id": pipe.id,
"name": pipe.name,
"object": "model",
"created": pipe.created_at,
"owned_by": "openai",
"pipe": pipe_flag,
"has_user_valves": has_user_valves,
'id': pipe.id,
'name': pipe.name,
'object': 'model',
'created': pipe.created_at,
'owned_by': 'openai',
'pipe': pipe_flag,
'has_user_valves': has_user_valves,
}
)
except Exception as e:
@@ -153,9 +150,7 @@ async def get_function_models(request):
return pipe_models
async def generate_function_chat_completion(
request, form_data, user, models: dict = {}
):
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
async def execute_pipe(pipe, params):
if inspect.iscoroutinefunction(pipe):
return await pipe(**params)
@@ -166,35 +161,35 @@ async def generate_function_chat_completion(
if isinstance(res, str):
return res
if isinstance(res, Generator):
return "".join(map(str, res))
return ''.join(map(str, res))
if isinstance(res, AsyncGenerator):
return "".join([str(stream) async for stream in res])
return ''.join([str(stream) async for stream in res])
def process_line(form_data: dict, line):
if isinstance(line, BaseModel):
line = line.model_dump_json()
line = f"data: {line}"
line = f'data: {line}'
if isinstance(line, dict):
line = f"data: {json.dumps(line)}"
line = f'data: {json.dumps(line)}'
try:
line = line.decode("utf-8")
line = line.decode('utf-8')
except Exception:
pass
if line.startswith("data:"):
return f"{line}\n\n"
if line.startswith('data:'):
return f'{line}\n\n'
else:
line = openai_chat_chunk_message_template(form_data["model"], line)
return f"data: {json.dumps(line)}\n\n"
line = openai_chat_chunk_message_template(form_data['model'], line)
return f'data: {json.dumps(line)}\n\n'
def get_pipe_id(form_data: dict) -> str:
pipe_id = form_data["model"]
if "." in pipe_id:
pipe_id, _ = pipe_id.split(".", 1)
pipe_id = form_data['model']
if '.' in pipe_id:
pipe_id, _ = pipe_id.split('.', 1)
return pipe_id
def get_function_params(function_module, form_data, user, extra_params=None):
async def get_function_params(function_module, form_data, user, extra_params=None):
if extra_params is None:
extra_params = {}
@@ -202,27 +197,25 @@ async def generate_function_chat_completion(
# Get the signature of the function
sig = inspect.signature(function_module.pipe)
params = {"body": form_data} | {
k: v for k, v in extra_params.items() if k in sig.parameters
}
params = {'body': form_data} | {k: v for k, v in extra_params.items() if k in sig.parameters}
if "__user__" in params and hasattr(function_module, "UserValves"):
user_valves = Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id)
if '__user__' in params and hasattr(function_module, 'UserValves'):
user_valves = await Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id)
try:
params["__user__"]["valves"] = function_module.UserValves(**user_valves)
params['__user__']['valves'] = function_module.UserValves(**user_valves)
except Exception as e:
log.exception(e)
params["__user__"]["valves"] = function_module.UserValves()
params['__user__']['valves'] = function_module.UserValves()
return params
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id)
model_id = form_data.get('model')
model_info = await Models.get_model_by_id(model_id)
metadata = form_data.pop("metadata", {})
metadata = form_data.pop('metadata', {})
files = metadata.get("files", [])
tool_ids = metadata.get("tool_ids", [])
files = metadata.get('files', [])
tool_ids = metadata.get('tool_ids', [])
# Check if tool_ids is None
if tool_ids is None:
tool_ids = []
@@ -233,66 +226,73 @@ async def generate_function_chat_completion(
__task_body__ = None
if metadata:
if all(k in metadata for k in ("session_id", "chat_id", "message_id")):
__event_emitter__ = get_event_emitter(metadata)
__event_call__ = get_event_call(metadata)
__task__ = metadata.get("task", None)
__task_body__ = metadata.get("task_body", None)
if all(k in metadata for k in ('session_id', 'chat_id', 'message_id')):
__event_emitter__ = await get_event_emitter(metadata)
__event_call__ = await get_event_call(metadata)
__task__ = metadata.get('task', None)
__task_body__ = metadata.get('task_body', None)
oauth_token = None
try:
if request.cookies.get("oauth_session_id", None):
oauth_session_id = request.cookies.get('oauth_session_id', None)
if oauth_session_id:
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
oauth_session_id,
)
# Fallback: no cookie (automation, API key, etc.) — use most recent session
if oauth_token is None:
from open_webui.models.oauth_sessions import OAuthSessions
sessions = await OAuthSessions.get_sessions_by_user_id(user.id)
if sessions:
best = max(sessions, key=lambda s: s.updated_at)
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
best.id,
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
log.error(f'Error getting OAuth token: {e}')
extra_params = {
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
"__chat_id__": metadata.get("chat_id", None),
"__session_id__": metadata.get("session_id", None),
"__message_id__": metadata.get("message_id", None),
"__task__": __task__,
"__task_body__": __task_body__,
"__files__": files,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request,
'__event_emitter__': __event_emitter__,
'__event_call__': __event_call__,
'__chat_id__': metadata.get('chat_id', None),
'__session_id__': metadata.get('session_id', None),
'__message_id__': metadata.get('message_id', None),
'__task__': __task__,
'__task_body__': __task_body__,
'__files__': files,
'__user__': user.model_dump() if isinstance(user, UserModel) else {},
'__metadata__': metadata,
'__oauth_token__': oauth_token,
'__request__': request,
}
extra_params["__tools__"] = await get_tools(
request,
tool_ids,
user,
{
**extra_params,
"__model__": models.get(form_data["model"], None),
"__messages__": form_data["messages"],
"__files__": files,
},
)
extra_params['__tools__'] = metadata.get('tools', {})
if model_info:
if model_info.base_model_id:
form_data["model"] = model_info.base_model_id
form_data['model'] = model_info.base_model_id
if not BYPASS_MODEL_ACCESS_CONTROL:
bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass)
params = model_info.params.model_dump()
if params:
system = params.pop("system", None)
system = params.pop('system', None)
form_data = apply_model_params_to_body_openai(params, form_data)
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = get_function_module_by_id(request, pipe_id)
function_module = await get_function_module_by_id(request, pipe_id)
pipe = function_module.pipe
params = get_function_params(function_module, form_data, user, extra_params)
params = await get_function_params(function_module, form_data, user, extra_params)
if form_data.get("stream", False):
if form_data.get('stream', False):
async def stream_content():
try:
@@ -304,17 +304,17 @@ async def generate_function_chat_completion(
yield data
return
if isinstance(res, dict):
yield f"data: {json.dumps(res)}\n\n"
yield f'data: {json.dumps(res)}\n\n'
return
except Exception as e:
log.error(f"Error: {e}")
yield f"data: {json.dumps({'error': {'detail':str(e)}})}\n\n"
log.error(f'Error: {e}')
yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n'
return
if isinstance(res, str):
message = openai_chat_chunk_message_template(form_data["model"], res)
yield f"data: {json.dumps(message)}\n\n"
message = openai_chat_chunk_message_template(form_data['model'], res)
yield f'data: {json.dumps(message)}\n\n'
if isinstance(res, Iterator):
for line in res:
@@ -325,21 +325,19 @@ async def generate_function_chat_completion(
yield process_line(form_data, line)
if isinstance(res, str) or isinstance(res, Generator):
finish_message = openai_chat_chunk_message_template(
form_data["model"], ""
)
finish_message["choices"][0]["finish_reason"] = "stop"
yield f"data: {json.dumps(finish_message)}\n\n"
yield "data: [DONE]"
finish_message = openai_chat_chunk_message_template(form_data['model'], '')
finish_message['choices'][0]['finish_reason'] = 'stop'
yield f'data: {json.dumps(finish_message)}\n\n'
yield 'data: [DONE]'
return StreamingResponse(stream_content(), media_type="text/event-stream")
return StreamingResponse(stream_content(), media_type='text/event-stream')
else:
try:
res = await execute_pipe(pipe, params)
except Exception as e:
log.error(f"Error: {e}")
return {"error": {"detail": str(e)}}
log.error(f'Error: {e}')
return {'error': {'detail': str(e)}}
if isinstance(res, StreamingResponse) or isinstance(res, dict):
return res
@@ -347,4 +345,4 @@ async def generate_function_chat_completion(
return res.model_dump()
message = await get_message_content(res)
return openai_chat_completion_message_template(form_data["model"], message)
return openai_chat_completion_message_template(form_data['model'], message)
+266 -38
View File
@@ -1,8 +1,10 @@
import os
import sys
import json
import logging
from contextlib import contextmanager
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from open_webui.internal.wrappers import register_connection
from open_webui.env import (
@@ -15,10 +17,17 @@ from open_webui.env import (
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
ENABLE_DB_MIGRATIONS,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool
@@ -28,6 +37,86 @@ from typing_extensions import Self
log = logging.getLogger(__name__)
# ── SSL URL normalization (used by sync engine & Alembic migrations) ─
#
# psycopg2 (sync) needs ``sslmode=`` in the connection string (it does
# not recognise the bare ``ssl=`` key that some ORMs emit). The helpers
# below strip all SSL-related query params, normalise them, and
# reattach them in the canonical libpq form.
#
# The **async** engine now uses psycopg (v3), which speaks libpq
# natively, so it needs no translation at all — the DATABASE_URL is
# passed through as-is.
# ─────────────────────────────────────────────────────────────────────
def _pop_first(params: dict[str, list[str]], key: str) -> str | None:
"""Pop a single-valued query param, returning ``None`` if absent."""
values = params.pop(key, None)
return values[0] if values else None
def _is_postgres_url(url: str) -> bool:
"""Return True if *url* looks like a PostgreSQL connection string."""
return bool(url) and any(url.startswith(p) for p in ('postgresql://', 'postgresql+', 'postgres://'))
def extract_ssl_params_from_url(url: str) -> tuple[str, dict[str, str]]:
"""Strip SSL query-string parameters from a PostgreSQL URL.
Returns ``(url_without_ssl, ssl_dict)`` where *ssl_dict* maps
canonical libpq key names (``sslmode``, ``sslrootcert``, …) to
their values. Non-PostgreSQL URLs are returned unchanged with an
empty dict.
"""
if not _is_postgres_url(url):
return url, {}
parsed = urlparse(url)
qp = parse_qs(parsed.query, keep_blank_values=True)
# Prefer sslmode (libpq canonical) over the bare ``ssl`` key.
sslmode_val = _pop_first(qp, 'sslmode')
ssl_val = _pop_first(qp, 'ssl')
ssl_mode = sslmode_val or ssl_val
ssl_dict: dict[str, str] = {}
if ssl_mode:
ssl_dict['sslmode'] = ssl_mode
for key in ('sslrootcert', 'sslcert', 'sslkey', 'sslcrl'):
val = _pop_first(qp, key)
if val:
ssl_dict[key] = val
if not ssl_dict:
return url, ssl_dict
cleaned_query = urlencode(qp, doseq=True)
return urlunparse(parsed._replace(query=cleaned_query)), ssl_dict
def reattach_ssl_params_to_url(url_without_ssl: str, ssl_dict: dict[str, str]) -> str:
"""Re-append SSL query-string parameters to a cleaned PostgreSQL URL.
Used for psycopg2/libpq consumers that expect ``sslmode`` and the
certificate-file keys in the connection string.
"""
if not ssl_dict:
return url_without_ssl
parts = [f'{k}={v}' for k, v in ssl_dict.items() if v]
if not parts:
return url_without_ssl
sep = '&' if '?' in url_without_ssl else '?'
return f'{url_without_ssl}{sep}{"&".join(parts)}'
# Backwards-compatible aliases for external callers.
extract_ssl_mode_from_url = extract_ssl_params_from_url
reattach_ssl_mode_to_url = reattach_ssl_params_to_url
class JSONField(types.TypeDecorator):
impl = types.Text
cache_ok = True
@@ -53,20 +142,23 @@ class JSONField(types.TypeDecorator):
# Workaround to handle the peewee migration
# This is required to ensure the peewee migration is handled before the alembic migration
def handle_peewee_migration(DATABASE_URL):
# db = None
db = None
try:
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`)
# and cert-file params are preserved in the connection string.
url_without_ssl, ssl_params = extract_ssl_params_from_url(DATABASE_URL)
normalized_url = reattach_ssl_params_to_url(url_without_ssl, ssl_params)
# Replace the postgresql:// with postgres:// to handle the peewee migration
db = register_connection(DATABASE_URL.replace("postgresql://", "postgres://"))
migrate_dir = OPEN_WEBUI_DIR / "internal" / "migrations"
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
router = Router(db, logger=log, migrate_dir=migrate_dir)
router.run()
db.close()
except Exception as e:
log.error(f"Failed to initialize the database connection: {e}")
log.warning(
"Hint: If your database password contains special characters, you may need to URL-encode it."
)
log.error(f'Failed to initialize the database connection: {e}')
log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.')
raise
finally:
# Properly closing the database connection
@@ -74,25 +166,61 @@ def handle_peewee_migration(DATABASE_URL):
db.close()
# Assert if db connection has been closed
assert db.is_closed(), "Database connection is still open."
if db is not None:
assert db.is_closed(), 'Database connection is still open.'
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
# Normalize SSL params from the URL once; the sync engine needs them
# reattached in canonical libpq form for psycopg2.
_url_without_ssl, _ssl_dict = extract_ssl_params_from_url(DATABASE_URL)
# For psycopg2 (sync engine), re-append sslmode + cert-file params.
SQLALCHEMY_DATABASE_URL = reattach_ssl_params_to_url(_url_without_ssl, _ssl_dict) if _ssl_dict else DATABASE_URL
def _make_async_url(url: str) -> str:
"""Convert a sync database URL to its async driver equivalent.
The async engine uses psycopg (v3) which speaks libpq natively,
so all standard connection-string parameters (``sslmode``,
``options``, ``target_session_attrs``, etc.) are passed through
without any translation.
"""
if url.startswith('sqlite+sqlcipher://'):
raise ValueError(
'sqlite+sqlcipher:// URLs are not supported with async engine. '
'Use standard sqlite:// or postgresql:// instead.'
)
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
# psycopg v3 — auto-selects async mode with create_async_engine
if url.startswith('postgresql+psycopg2://'):
return url.replace('postgresql+psycopg2://', 'postgresql+psycopg://', 1)
if url.startswith('postgresql://'):
return url.replace('postgresql://', 'postgresql+psycopg://', 1)
if url.startswith('postgres://'):
return url.replace('postgres://', 'postgresql+psycopg://', 1)
# For other dialects, return as-is and let SQLAlchemy handle it
return url
# ============================================================
# SYNC ENGINE (used only for: startup migrations, config loading,
# Alembic, peewee migration, health checks)
# ============================================================
# Handle SQLCipher URLs
if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
database_password = os.environ.get("DATABASE_PASSWORD")
if not database_password or database_password.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
database_password = os.environ.get('DATABASE_PASSWORD')
if not database_password or database_password.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
# Extract database path from SQLCipher URL
db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "")
db_path = SQLALCHEMY_DATABASE_URL.replace('sqlite+sqlcipher://', '')
# Create a custom creator function that uses sqlcipher3
def create_sqlcipher_connection():
@@ -109,7 +237,7 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
engine = create_engine(
"sqlite://",
'sqlite://',
creator=create_sqlcipher_connection,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
@@ -121,28 +249,44 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
)
else:
engine = create_engine(
"sqlite://",
'sqlite://',
creator=create_sqlcipher_connection,
poolclass=NullPool,
echo=False,
)
log.info("Connected to encrypted SQLite database using SQLCipher")
log.info('Connected to encrypted SQLite database using SQLCipher')
elif "sqlite" in SQLALCHEMY_DATABASE_URL:
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
def on_connect(dbapi_connection, connection_record):
def _apply_sqlite_pragmas(dbapi_connection):
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
cursor = dbapi_connection.cursor()
if DATABASE_ENABLE_SQLITE_WAL:
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute('PRAGMA journal_mode=WAL')
else:
cursor.execute("PRAGMA journal_mode=DELETE")
cursor.execute('PRAGMA journal_mode=DELETE')
# Each PRAGMA is skipped when its env var is empty, allowing opt-out.
if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS:
cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}')
if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT:
cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}')
if DATABASE_SQLITE_PRAGMA_CACHE_SIZE:
cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}')
if DATABASE_SQLITE_PRAGMA_TEMP_STORE:
cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}')
if DATABASE_SQLITE_PRAGMA_MMAP_SIZE:
cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}')
if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT:
cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}')
cursor.close()
event.listen(engine, "connect", on_connect)
def on_connect(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
event.listen(engine, 'connect', on_connect)
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
@@ -156,22 +300,20 @@ else:
poolclass=QueuePool,
)
else:
engine = create_engine(
SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool
)
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
else:
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
# Sync session — used ONLY for startup config loading (config.py runs at import time)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj)
ScopedSession = scoped_session(SessionLocal)
def get_session():
"""Sync session generator — used ONLY for startup/config operations."""
db = SessionLocal()
try:
yield db
@@ -182,10 +324,96 @@ 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:
# ============================================================
# ASYNC ENGINE (used for ALL runtime database operations)
# ============================================================
# psycopg (v3) speaks libpq natively — the full DATABASE_URL is passed
# through as-is. SSL params, ``options``, ``target_session_attrs``, etc.
# all work without any stripping or translation.
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
# psycopg v3 cannot run in async mode under Windows' default
# ProactorEventLoop — switch to SelectorEventLoop before creating
# the async engine. This runs at import time, which is early enough
# to cover every entry point (workers, reload, direct invocations).
if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
connect_args={'check_same_thread': False},
pool_size=_sqlite_pool_size,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
@event.listens_for(async_engine.sync_engine, 'connect')
def _set_sqlite_pragmas(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
else:
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_size=DATABASE_POOL_SIZE,
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
poolclass=NullPool,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
autocommit=False,
autoflush=False,
expire_on_commit=False,
)
async def get_async_session():
"""Async session generator for FastAPI Depends()."""
async with AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
@asynccontextmanager
async def get_async_db():
"""Async context manager for use outside of FastAPI dependency injection."""
async with AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
@asynccontextmanager
async def get_async_db_context(db: Optional[AsyncSession] = None):
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
yield db
else:
with get_db() as session:
async with get_async_db() as session:
yield session
@@ -56,7 +56,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
active = pw.BooleanField()
class Meta:
table_name = "auth"
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
@@ -67,7 +67,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "chat"
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
@@ -78,7 +78,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "chatidtag"
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
@@ -92,7 +92,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "document"
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
@@ -103,7 +103,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "modelfile"
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
@@ -115,7 +115,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "prompt"
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
@@ -125,7 +125,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
data = pw.TextField(null=True)
class Meta:
table_name = "tag"
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
@@ -137,7 +137,7 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "user"
table_name = 'user'
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
@@ -149,7 +149,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
active = pw.BooleanField()
class Meta:
table_name = "auth"
table_name = 'auth'
@migrator.create_model
class Chat(pw.Model):
@@ -160,7 +160,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "chat"
table_name = 'chat'
@migrator.create_model
class ChatIdTag(pw.Model):
@@ -171,7 +171,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "chatidtag"
table_name = 'chatidtag'
@migrator.create_model
class Document(pw.Model):
@@ -185,7 +185,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "document"
table_name = 'document'
@migrator.create_model
class Modelfile(pw.Model):
@@ -196,7 +196,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "modelfile"
table_name = 'modelfile'
@migrator.create_model
class Prompt(pw.Model):
@@ -208,7 +208,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "prompt"
table_name = 'prompt'
@migrator.create_model
class Tag(pw.Model):
@@ -218,7 +218,7 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
data = pw.TextField(null=True)
class Meta:
table_name = "tag"
table_name = 'tag'
@migrator.create_model
class User(pw.Model):
@@ -230,24 +230,24 @@ def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
timestamp = pw.BigIntegerField()
class Meta:
table_name = "user"
table_name = 'user'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model("user")
migrator.remove_model('user')
migrator.remove_model("tag")
migrator.remove_model('tag')
migrator.remove_model("prompt")
migrator.remove_model('prompt')
migrator.remove_model("modelfile")
migrator.remove_model('modelfile')
migrator.remove_model("document")
migrator.remove_model('document')
migrator.remove_model("chatidtag")
migrator.remove_model('chatidtag')
migrator.remove_model("chat")
migrator.remove_model('chat')
migrator.remove_model("auth")
migrator.remove_model('auth')
@@ -36,12 +36,10 @@ with suppress(ImportError):
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
"chat", share_id=pw.CharField(max_length=255, null=True, unique=True)
)
migrator.add_fields('chat', share_id=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("chat", "share_id")
migrator.remove_fields('chat', 'share_id')
@@ -36,12 +36,10 @@ with suppress(ImportError):
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
"user", api_key=pw.CharField(max_length=255, null=True, unique=True)
)
migrator.add_fields('user', api_key=pw.CharField(max_length=255, null=True, unique=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("user", "api_key")
migrator.remove_fields('user', 'api_key')
@@ -36,10 +36,10 @@ with suppress(ImportError):
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields("chat", archived=pw.BooleanField(default=False))
migrator.add_fields('chat', archived=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("chat", "archived")
migrator.remove_fields('chat', 'archived')
@@ -45,22 +45,20 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
"chat",
'chat',
created_at=pw.DateTimeField(null=True), # Allow null for transition
updated_at=pw.DateTimeField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql(
"UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
)
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields("chat", "timestamp")
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
"chat",
'chat',
created_at=pw.DateTimeField(null=False),
updated_at=pw.DateTimeField(null=False),
)
@@ -69,22 +67,20 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'chat' table
migrator.add_fields(
"chat",
'chat',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
)
# Populate the new fields from an existing 'timestamp' field
migrator.sql(
"UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL"
)
migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL')
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields("chat", "timestamp")
migrator.remove_fields('chat', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
"chat",
'chat',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
)
@@ -101,29 +97,29 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True))
migrator.add_fields('chat', timestamp=pw.DateTimeField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql("UPDATE chat SET timestamp = created_at")
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields("chat", "created_at", "updated_at")
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False))
migrator.change_fields('chat', timestamp=pw.DateTimeField(null=False))
def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False):
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields("chat", timestamp=pw.BigIntegerField(null=True))
migrator.add_fields('chat', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql("UPDATE chat SET timestamp = created_at")
migrator.sql('UPDATE chat SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields("chat", "created_at", "updated_at")
migrator.remove_fields('chat', 'created_at', 'updated_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields("chat", timestamp=pw.BigIntegerField(null=False))
migrator.change_fields('chat', timestamp=pw.BigIntegerField(null=False))
@@ -38,45 +38,45 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
# Alter the tables with timestamps
migrator.change_fields(
"chatidtag",
'chatidtag',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
"document",
'document',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
"modelfile",
'modelfile',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
"prompt",
'prompt',
timestamp=pw.BigIntegerField(),
)
migrator.change_fields(
"user",
'user',
timestamp=pw.BigIntegerField(),
)
# Alter the tables with varchar to text where necessary
migrator.change_fields(
"auth",
'auth',
password=pw.TextField(),
)
migrator.change_fields(
"chat",
'chat',
title=pw.TextField(),
)
migrator.change_fields(
"document",
'document',
title=pw.TextField(),
filename=pw.TextField(),
)
migrator.change_fields(
"prompt",
'prompt',
title=pw.TextField(),
)
migrator.change_fields(
"user",
'user',
profile_image_url=pw.TextField(),
)
@@ -87,43 +87,43 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
if isinstance(database, pw.SqliteDatabase):
# Alter the tables with timestamps
migrator.change_fields(
"chatidtag",
'chatidtag',
timestamp=pw.DateField(),
)
migrator.change_fields(
"document",
'document',
timestamp=pw.DateField(),
)
migrator.change_fields(
"modelfile",
'modelfile',
timestamp=pw.DateField(),
)
migrator.change_fields(
"prompt",
'prompt',
timestamp=pw.DateField(),
)
migrator.change_fields(
"user",
'user',
timestamp=pw.DateField(),
)
migrator.change_fields(
"auth",
'auth',
password=pw.CharField(max_length=255),
)
migrator.change_fields(
"chat",
'chat',
title=pw.CharField(),
)
migrator.change_fields(
"document",
'document',
title=pw.CharField(),
filename=pw.CharField(),
)
migrator.change_fields(
"prompt",
'prompt',
title=pw.CharField(),
)
migrator.change_fields(
"user",
'user',
profile_image_url=pw.CharField(),
)
@@ -38,7 +38,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
# Adding fields created_at and updated_at to the 'user' table
migrator.add_fields(
"user",
'user',
created_at=pw.BigIntegerField(null=True), # Allow null for transition
updated_at=pw.BigIntegerField(null=True), # Allow null for transition
last_active_at=pw.BigIntegerField(null=True), # Allow null for transition
@@ -50,11 +50,11 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
)
# Now that the data has been copied, remove the original 'timestamp' field
migrator.remove_fields("user", "timestamp")
migrator.remove_fields('user', 'timestamp')
# Update the fields to be not null now that they are populated
migrator.change_fields(
"user",
'user',
created_at=pw.BigIntegerField(null=False),
updated_at=pw.BigIntegerField(null=False),
last_active_at=pw.BigIntegerField(null=False),
@@ -65,14 +65,14 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Recreate the timestamp field initially allowing null values for safe transition
migrator.add_fields("user", timestamp=pw.BigIntegerField(null=True))
migrator.add_fields('user', timestamp=pw.BigIntegerField(null=True))
# Copy the earliest created_at date back into the new timestamp field
# This assumes created_at was originally a copy of timestamp
migrator.sql('UPDATE "user" SET timestamp = created_at')
# Remove the created_at and updated_at fields
migrator.remove_fields("user", "created_at", "updated_at", "last_active_at")
migrator.remove_fields('user', 'created_at', 'updated_at', 'last_active_at')
# Finally, alter the timestamp field to not allow nulls if that was the original setting
migrator.change_fields("user", timestamp=pw.BigIntegerField(null=False))
migrator.change_fields('user', timestamp=pw.BigIntegerField(null=False))
@@ -43,10 +43,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = "memory"
table_name = 'memory'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model("memory")
migrator.remove_model('memory')
@@ -51,10 +51,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = "model"
table_name = 'model'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model("model")
migrator.remove_model('model')
@@ -42,12 +42,12 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
# Fetch data from 'modelfile' table and insert into 'model' table
migrate_modelfile_to_model(migrator, database)
# Drop the 'modelfile' table
migrator.remove_model("modelfile")
migrator.remove_model('modelfile')
def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
ModelFile = migrator.orm["modelfile"]
Model = migrator.orm["model"]
ModelFile = migrator.orm['modelfile']
Model = migrator.orm['model']
modelfiles = ModelFile.select()
@@ -57,25 +57,25 @@ def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database):
modelfile.modelfile = json.loads(modelfile.modelfile)
meta = json.dumps(
{
"description": modelfile.modelfile.get("desc"),
"profile_image_url": modelfile.modelfile.get("imageUrl"),
"ollama": {"modelfile": modelfile.modelfile.get("content")},
"suggestion_prompts": modelfile.modelfile.get("suggestionPrompts"),
"categories": modelfile.modelfile.get("categories"),
"user": {**modelfile.modelfile.get("user", {}), "community": True},
'description': modelfile.modelfile.get('desc'),
'profile_image_url': modelfile.modelfile.get('imageUrl'),
'ollama': {'modelfile': modelfile.modelfile.get('content')},
'suggestion_prompts': modelfile.modelfile.get('suggestionPrompts'),
'categories': modelfile.modelfile.get('categories'),
'user': {**modelfile.modelfile.get('user', {}), 'community': True},
}
)
info = parse_ollama_modelfile(modelfile.modelfile.get("content"))
info = parse_ollama_modelfile(modelfile.modelfile.get('content'))
# Insert the processed data into the 'model' table
Model.create(
id=f"ollama-{modelfile.tag_name}",
id=f'ollama-{modelfile.tag_name}',
user_id=modelfile.user_id,
base_model_id=info.get("base_model_id"),
name=modelfile.modelfile.get("title"),
base_model_id=info.get('base_model_id'),
name=modelfile.modelfile.get('title'),
meta=meta,
params=json.dumps(info.get("params", {})),
params=json.dumps(info.get('params', {})),
created_at=modelfile.timestamp,
updated_at=modelfile.timestamp,
)
@@ -86,7 +86,7 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
recreate_modelfile_table(migrator, database)
move_data_back_to_modelfile(migrator, database)
migrator.remove_model("model")
migrator.remove_model('model')
def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
@@ -102,8 +102,8 @@ def recreate_modelfile_table(migrator: Migrator, database: pw.Database):
def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
Model = migrator.orm["model"]
Modelfile = migrator.orm["modelfile"]
Model = migrator.orm['model']
Modelfile = migrator.orm['modelfile']
models = Model.select()
@@ -112,13 +112,13 @@ def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database):
meta = json.loads(model.meta)
modelfile_data = {
"title": model.name,
"desc": meta.get("description"),
"imageUrl": meta.get("profile_image_url"),
"content": meta.get("ollama", {}).get("modelfile"),
"suggestionPrompts": meta.get("suggestion_prompts"),
"categories": meta.get("categories"),
"user": {k: v for k, v in meta.get("user", {}).items() if k != "community"},
'title': model.name,
'desc': meta.get('description'),
'imageUrl': meta.get('profile_image_url'),
'content': meta.get('ollama', {}).get('modelfile'),
'suggestionPrompts': meta.get('suggestion_prompts'),
'categories': meta.get('categories'),
'user': {k: v for k, v in meta.get('user', {}).items() if k != 'community'},
}
# Insert the processed data back into the 'modelfile' table
@@ -37,11 +37,11 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields settings to the 'user' table
migrator.add_fields("user", settings=pw.TextField(null=True))
migrator.add_fields('user', settings=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields("user", "settings")
migrator.remove_fields('user', 'settings')
@@ -51,10 +51,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = "tool"
table_name = 'tool'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model("tool")
migrator.remove_model('tool')
@@ -37,11 +37,11 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
# Adding fields info to the 'user' table
migrator.add_fields("user", info=pw.TextField(null=True))
migrator.add_fields('user', info=pw.TextField(null=True))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
# Remove the settings field
migrator.remove_fields("user", "info")
migrator.remove_fields('user', 'info')
@@ -45,10 +45,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
created_at = pw.BigIntegerField(null=False)
class Meta:
table_name = "file"
table_name = 'file'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model("file")
migrator.remove_model('file')
@@ -51,10 +51,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
updated_at = pw.BigIntegerField(null=False)
class Meta:
table_name = "function"
table_name = 'function'
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_model("function")
migrator.remove_model('function')
@@ -36,14 +36,14 @@ with suppress(ImportError):
def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields("tool", valves=pw.TextField(null=True))
migrator.add_fields("function", valves=pw.TextField(null=True))
migrator.add_fields("function", is_active=pw.BooleanField(default=False))
migrator.add_fields('tool', valves=pw.TextField(null=True))
migrator.add_fields('function', valves=pw.TextField(null=True))
migrator.add_fields('function', is_active=pw.BooleanField(default=False))
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("tool", "valves")
migrator.remove_fields("function", "valves")
migrator.remove_fields("function", "is_active")
migrator.remove_fields('tool', 'valves')
migrator.remove_fields('function', 'valves')
migrator.remove_fields('function', 'is_active')
@@ -33,7 +33,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
"user",
'user',
oauth_sub=pw.TextField(null=True, unique=True),
)
@@ -41,4 +41,4 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("user", "oauth_sub")
migrator.remove_fields('user', 'oauth_sub')
@@ -37,7 +37,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your migrations here."""
migrator.add_fields(
"function",
'function',
is_global=pw.BooleanField(default=False),
)
@@ -45,4 +45,4 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False):
def rollback(migrator: Migrator, database: pw.Database, *, fake=False):
"""Write your rollback migrations here."""
migrator.remove_fields("function", "is_global")
migrator.remove_fields('function', 'is_global')
+15 -17
View File
@@ -10,13 +10,13 @@ from playhouse.shortcuts import ReconnectMixin
log = logging.getLogger(__name__)
db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None}
db_state = ContextVar("db_state", default=db_state_default.copy())
db_state_default = {'closed': None, 'conn': None, 'ctx': None, 'transactions': None}
db_state = ContextVar('db_state', default=db_state_default.copy())
class PeeweeConnectionState(object):
def __init__(self, **kwargs):
super().__setattr__("_state", db_state)
super().__setattr__('_state', db_state)
super().__init__(**kwargs)
def __setattr__(self, name, value):
@@ -30,10 +30,10 @@ class PeeweeConnectionState(object):
class CustomReconnectMixin(ReconnectMixin):
reconnect_errors = (
# psycopg2
(OperationalError, "termin"),
(InterfaceError, "closed"),
(OperationalError, 'termin'),
(InterfaceError, 'closed'),
# peewee
(PeeWeeInterfaceError, "closed"),
(PeeWeeInterfaceError, 'closed'),
)
@@ -43,23 +43,21 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase):
def register_connection(db_url):
# Check if using SQLCipher protocol
if db_url.startswith("sqlite+sqlcipher://"):
database_password = os.environ.get("DATABASE_PASSWORD")
if not database_password or database_password.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
if db_url.startswith('sqlite+sqlcipher://'):
database_password = os.environ.get('DATABASE_PASSWORD')
if not database_password or database_password.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
from playhouse.sqlcipher_ext import SqlCipherDatabase
# Parse the database path from SQLCipher URL
# Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite
db_path = db_url.replace("sqlite+sqlcipher://", "")
db_path = db_url.replace('sqlite+sqlcipher://', '')
# Use Peewee's native SqlCipherDatabase with encryption
db = SqlCipherDatabase(db_path, passphrase=database_password)
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to encrypted SQLite database using SQLCipher")
log.info('Connected to encrypted SQLite database using SQLCipher')
else:
# Standard database connection (existing logic)
@@ -68,7 +66,7 @@ def register_connection(db_url):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to PostgreSQL database")
log.info('Connected to PostgreSQL database')
# Get the connection details
connection = parse(db_url, unquote_user=True, unquote_password=True)
@@ -80,7 +78,7 @@ def register_connection(db_url):
# Enable autoconnect for SQLite databases, managed by Peewee
db.autoconnect = True
db.reuse_if_open = True
log.info("Connected to SQLite database")
log.info('Connected to SQLite database')
else:
raise ValueError("Unsupported database connection")
raise ValueError('Unsupported database connection')
return db
+1069 -774
View File
File diff suppressed because it is too large Load Diff
+17 -13
View File
@@ -3,7 +3,9 @@ from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from open_webui.internal.db import extract_ssl_params_from_url, reattach_ssl_params_to_url
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
@@ -16,7 +18,7 @@ if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
if LOG_FORMAT == "json":
if LOG_FORMAT == 'json':
from open_webui.env import JSONFormatter
for handler in logging.root.handlers:
@@ -35,8 +37,12 @@ target_metadata = Auth.metadata
DB_URL = DATABASE_URL
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2 for sync migrations).
url_without_ssl, ssl_params = extract_ssl_params_from_url(DB_URL)
DB_URL = reattach_ssl_params_to_url(url_without_ssl, ssl_params) if ssl_params else DB_URL
if DB_URL:
config.set_main_option("sqlalchemy.url", DB_URL.replace("%", "%%"))
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))
def run_migrations_offline() -> None:
@@ -51,12 +57,12 @@ def run_migrations_offline() -> None:
script output.
"""
url = config.get_main_option("sqlalchemy.url")
url = config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
dialect_opts={'paramstyle': 'named'},
)
with context.begin_transaction():
@@ -71,15 +77,13 @@ def run_migrations_online() -> None:
"""
# Handle SQLCipher URLs
if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "":
raise ValueError(
"DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs"
)
if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'):
if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '':
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
# Extract database path from SQLCipher URL
db_path = DB_URL.replace("sqlite+sqlcipher://", "")
if db_path.startswith("/"):
db_path = DB_URL.replace('sqlite+sqlcipher://', '')
if db_path.startswith('/'):
db_path = db_path[1:] # Remove leading slash for relative paths
# Create a custom creator function that uses sqlcipher3
@@ -91,7 +95,7 @@ def run_migrations_online() -> None:
return conn
connectable = create_engine(
"sqlite://", # Dummy URL since we're using creator
'sqlite://', # Dummy URL since we're using creator
creator=create_sqlcipher_connection,
echo=False,
)
@@ -99,7 +103,7 @@ def run_migrations_online() -> None:
# Standard database connection (existing logic)
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
+1 -1
View File
@@ -12,4 +12,4 @@ def get_existing_tables():
def get_revision_id():
import uuid
return str(uuid.uuid4()).replace("-", "")[:12]
return str(uuid.uuid4()).replace('-', '')[:12]
@@ -9,38 +9,38 @@ Create Date: 2025-08-13 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "018012973d35"
down_revision = "d31026856c01"
revision = '018012973d35'
down_revision = 'd31026856c01'
branch_labels = None
depends_on = None
def upgrade():
# Chat table indexes
op.create_index("folder_id_idx", "chat", ["folder_id"])
op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"])
op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"])
op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"])
op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"])
op.create_index('folder_id_idx', 'chat', ['folder_id'])
op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned'])
op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived'])
op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id'])
op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id'])
# Tag table index
op.create_index("user_id_idx", "tag", ["user_id"])
op.create_index('user_id_idx', 'tag', ['user_id'])
# Function table index
op.create_index("is_global_idx", "function", ["is_global"])
op.create_index('is_global_idx', 'function', ['is_global'])
def downgrade():
# Chat table indexes
op.drop_index("folder_id_idx", table_name="chat")
op.drop_index("user_id_pinned_idx", table_name="chat")
op.drop_index("user_id_archived_idx", table_name="chat")
op.drop_index("updated_at_user_id_idx", table_name="chat")
op.drop_index("folder_id_user_id_idx", table_name="chat")
op.drop_index('folder_id_idx', table_name='chat')
op.drop_index('user_id_pinned_idx', table_name='chat')
op.drop_index('user_id_archived_idx', table_name='chat')
op.drop_index('updated_at_user_id_idx', table_name='chat')
op.drop_index('folder_id_user_id_idx', table_name='chat')
# Tag table index
op.drop_index("user_id_idx", table_name="tag")
op.drop_index('user_id_idx', table_name='tag')
# Function table index
op.drop_index("is_global_idx", table_name="function")
op.drop_index('is_global_idx', table_name='function')
@@ -13,8 +13,8 @@ from sqlalchemy.engine.reflection import Inspector
import json
revision = "1af9b942657b"
down_revision = "242a2047eae0"
revision = '1af9b942657b'
down_revision = '242a2047eae0'
branch_labels = None
depends_on = None
@@ -25,43 +25,40 @@ def upgrade():
inspector = Inspector.from_engine(conn)
# Clean up potential leftover temp table from previous failures
conn.execute(sa.text("DROP TABLE IF EXISTS _alembic_tmp_tag"))
conn.execute(sa.text('DROP TABLE IF EXISTS _alembic_tmp_tag'))
# Check if the 'tag' table exists
tables = inspector.get_table_names()
# Step 1: Modify Tag table using batch mode for SQLite support
if "tag" in tables:
if 'tag' in tables:
# Get the current columns in the 'tag' table
columns = [col["name"] for col in inspector.get_columns("tag")]
columns = [col['name'] for col in inspector.get_columns('tag')]
# Get any existing unique constraints on the 'tag' table
current_constraints = inspector.get_unique_constraints("tag")
current_constraints = inspector.get_unique_constraints('tag')
with op.batch_alter_table("tag", schema=None) as batch_op:
with op.batch_alter_table('tag', schema=None) as batch_op:
# Check if the unique constraint already exists
if not any(
constraint["name"] == "uq_id_user_id"
for constraint in current_constraints
):
if not any(constraint['name'] == 'uq_id_user_id' for constraint in current_constraints):
# Create unique constraint if it doesn't exist
batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"])
batch_op.create_unique_constraint('uq_id_user_id', ['id', 'user_id'])
# Check if the 'data' column exists before trying to drop it
if "data" in columns:
batch_op.drop_column("data")
if 'data' in columns:
batch_op.drop_column('data')
# Check if the 'meta' column needs to be created
if "meta" not in columns:
if 'meta' not in columns:
# Add the 'meta' column if it doesn't already exist
batch_op.add_column(sa.Column("meta", sa.JSON(), nullable=True))
batch_op.add_column(sa.Column('meta', sa.JSON(), nullable=True))
tag = table(
"tag",
column("id", sa.String()),
column("name", sa.String()),
column("user_id", sa.String()),
column("meta", sa.JSON()),
'tag',
column('id', sa.String()),
column('name', sa.String()),
column('user_id', sa.String()),
column('meta', sa.JSON()),
)
# Step 2: Migrate tags
@@ -70,12 +67,12 @@ def upgrade():
tag_updates = {}
for row in result:
new_id = row.name.replace(" ", "_").lower()
new_id = row.name.replace(' ', '_').lower()
tag_updates[row.id] = new_id
for tag_id, new_tag_id in tag_updates.items():
print(f"Updating tag {tag_id} to {new_tag_id}")
if new_tag_id == "pinned":
print(f'Updating tag {tag_id} to {new_tag_id}')
if new_tag_id == 'pinned':
# delete tag
delete_stmt = sa.delete(tag).where(tag.c.id == tag_id)
conn.execute(delete_stmt)
@@ -86,9 +83,7 @@ def upgrade():
if existing_tag_result:
# Handle duplicate case: the new_tag_id already exists
print(
f"Tag {new_tag_id} already exists. Removing current tag with ID {tag_id} to avoid duplicates."
)
print(f'Tag {new_tag_id} already exists. Removing current tag with ID {tag_id} to avoid duplicates.')
# Option 1: Delete the current tag if an update to new_tag_id would cause duplication
delete_stmt = sa.delete(tag).where(tag.c.id == tag_id)
conn.execute(delete_stmt)
@@ -98,19 +93,15 @@ def upgrade():
conn.execute(update_stmt)
# Add columns `pinned` and `meta` to 'chat'
op.add_column("chat", sa.Column("pinned", sa.Boolean(), nullable=True))
op.add_column(
"chat", sa.Column("meta", sa.JSON(), nullable=False, server_default="{}")
)
op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True))
op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}'))
chatidtag = table(
"chatidtag", column("chat_id", sa.String()), column("tag_name", sa.String())
)
chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String()))
chat = table(
"chat",
column("id", sa.String()),
column("pinned", sa.Boolean()),
column("meta", sa.JSON()),
'chat',
column('id', sa.String()),
column('pinned', sa.Boolean()),
column('meta', sa.JSON()),
)
# Fetch existing tags
@@ -120,29 +111,27 @@ def upgrade():
chat_updates = {}
for row in result:
chat_id = row.chat_id
tag_name = row.tag_name.replace(" ", "_").lower()
tag_name = row.tag_name.replace(' ', '_').lower()
if tag_name == "pinned":
if tag_name == 'pinned':
# Specifically handle 'pinned' tag
if chat_id not in chat_updates:
chat_updates[chat_id] = {"pinned": True, "meta": {}}
chat_updates[chat_id] = {'pinned': True, 'meta': {}}
else:
chat_updates[chat_id]["pinned"] = True
chat_updates[chat_id]['pinned'] = True
else:
if chat_id not in chat_updates:
chat_updates[chat_id] = {"pinned": False, "meta": {"tags": [tag_name]}}
chat_updates[chat_id] = {'pinned': False, 'meta': {'tags': [tag_name]}}
else:
tags = chat_updates[chat_id]["meta"].get("tags", [])
tags = chat_updates[chat_id]['meta'].get('tags', [])
tags.append(tag_name)
chat_updates[chat_id]["meta"]["tags"] = list(set(tags))
chat_updates[chat_id]['meta']['tags'] = list(set(tags))
# Update chats based on accumulated changes
for chat_id, updates in chat_updates.items():
update_stmt = sa.update(chat).where(chat.c.id == chat_id)
update_stmt = update_stmt.values(
meta=updates.get("meta", {}), pinned=updates.get("pinned", False)
)
update_stmt = update_stmt.values(meta=updates.get('meta', {}), pinned=updates.get('pinned', False))
conn.execute(update_stmt)
pass
@@ -12,8 +12,8 @@ from sqlalchemy.sql import table, select, update
import json
revision = "242a2047eae0"
down_revision = "6a39f3d8e55c"
revision = '242a2047eae0'
down_revision = '6a39f3d8e55c'
branch_labels = None
depends_on = None
@@ -22,39 +22,37 @@ def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = inspector.get_columns("chat")
column_dict = {col["name"]: col for col in columns}
columns = inspector.get_columns('chat')
column_dict = {col['name']: col for col in columns}
chat_column = column_dict.get("chat")
old_chat_exists = "old_chat" in column_dict
chat_column = column_dict.get('chat')
old_chat_exists = 'old_chat' in column_dict
if chat_column:
if isinstance(chat_column["type"], sa.Text):
if isinstance(chat_column['type'], sa.Text):
print("Converting 'chat' column to JSON")
if old_chat_exists:
print("Dropping old 'old_chat' column")
op.drop_column("chat", "old_chat")
op.drop_column('chat', 'old_chat')
# Step 1: Rename current 'chat' column to 'old_chat'
print("Renaming 'chat' column to 'old_chat'")
op.alter_column(
"chat", "chat", new_column_name="old_chat", existing_type=sa.Text()
)
op.alter_column('chat', 'chat', new_column_name='old_chat', existing_type=sa.Text())
# Step 2: Add new 'chat' column of type JSON
print("Adding new 'chat' column of type JSON")
op.add_column("chat", sa.Column("chat", sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('chat', sa.JSON(), nullable=True))
else:
# If the column is already JSON, no need to do anything
pass
# Step 3: Migrate data from 'old_chat' to 'chat'
chat_table = table(
"chat",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("old_chat", sa.Text()),
sa.Column("chat", sa.JSON()),
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('old_chat', sa.Text()),
sa.Column('chat', sa.JSON()),
)
# - Selecting all data from the table
@@ -67,41 +65,33 @@ def upgrade():
except json.JSONDecodeError:
json_data = None # Handle cases where the text cannot be converted to JSON
connection.execute(
sa.update(chat_table)
.where(chat_table.c.id == row.id)
.values(chat=json_data)
)
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data))
# Step 4: Drop 'old_chat' column
print("Dropping 'old_chat' column")
op.drop_column("chat", "old_chat")
op.drop_column('chat', 'old_chat')
def downgrade():
# Step 1: Add 'old_chat' column back as Text
op.add_column("chat", sa.Column("old_chat", sa.Text(), nullable=True))
op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True))
# Step 2: Convert 'chat' JSON data back to text and store in 'old_chat'
chat_table = table(
"chat",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("chat", sa.JSON()),
sa.Column("old_chat", sa.Text()),
'chat',
sa.Column('id', sa.String(), primary_key=True),
sa.Column('chat', sa.JSON()),
sa.Column('old_chat', sa.Text()),
)
connection = op.get_bind()
results = connection.execute(select(chat_table.c.id, chat_table.c.chat))
for row in results:
text_data = json.dumps(row.chat) if row.chat is not None else None
connection.execute(
sa.update(chat_table)
.where(chat_table.c.id == row.id)
.values(old_chat=text_data)
)
connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data))
# Step 3: Remove the new 'chat' JSON column
op.drop_column("chat", "chat")
op.drop_column('chat', 'chat')
# Step 4: Rename 'old_chat' back to 'chat'
op.alter_column("chat", "old_chat", new_column_name="chat", existing_type=sa.Text())
op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text())
@@ -13,19 +13,19 @@ import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "2f1211949ecc"
down_revision: Union[str, None] = "37f288994c47"
revision: str = '2f1211949ecc'
down_revision: Union[str, None] = '37f288994c47'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# New columns to be added to channel_member table
op.add_column("channel_member", sa.Column("status", sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True))
op.add_column(
"channel_member",
'channel_member',
sa.Column(
"is_active",
'is_active',
sa.Boolean(),
nullable=False,
default=True,
@@ -34,9 +34,9 @@ def upgrade() -> None:
)
op.add_column(
"channel_member",
'channel_member',
sa.Column(
"is_channel_muted",
'is_channel_muted',
sa.Boolean(),
nullable=False,
default=False,
@@ -44,9 +44,9 @@ def upgrade() -> None:
),
)
op.add_column(
"channel_member",
'channel_member',
sa.Column(
"is_channel_pinned",
'is_channel_pinned',
sa.Boolean(),
nullable=False,
default=False,
@@ -54,49 +54,41 @@ def upgrade() -> None:
),
)
op.add_column("channel_member", sa.Column("data", sa.JSON(), nullable=True))
op.add_column("channel_member", sa.Column("meta", sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True))
op.add_column(
"channel_member", sa.Column("joined_at", sa.BigInteger(), nullable=False)
)
op.add_column(
"channel_member", sa.Column("left_at", sa.BigInteger(), nullable=True)
)
op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False))
op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True))
op.add_column(
"channel_member", sa.Column("last_read_at", sa.BigInteger(), nullable=True)
)
op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
op.add_column(
"channel_member", sa.Column("updated_at", sa.BigInteger(), nullable=True)
)
op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True))
# New columns to be added to message table
op.add_column(
"message",
'message',
sa.Column(
"is_pinned",
'is_pinned',
sa.Boolean(),
nullable=False,
default=False,
server_default=sa.sql.expression.false(),
),
)
op.add_column("message", sa.Column("pinned_at", sa.BigInteger(), nullable=True))
op.add_column("message", sa.Column("pinned_by", sa.Text(), nullable=True))
op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True))
op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("channel_member", "updated_at")
op.drop_column("channel_member", "last_read_at")
op.drop_column('channel_member', 'updated_at')
op.drop_column('channel_member', 'last_read_at')
op.drop_column("channel_member", "meta")
op.drop_column("channel_member", "data")
op.drop_column('channel_member', 'meta')
op.drop_column('channel_member', 'data')
op.drop_column("channel_member", "is_channel_pinned")
op.drop_column("channel_member", "is_channel_muted")
op.drop_column('channel_member', 'is_channel_pinned')
op.drop_column('channel_member', 'is_channel_muted')
op.drop_column("message", "pinned_by")
op.drop_column("message", "pinned_at")
op.drop_column("message", "is_pinned")
op.drop_column('message', 'pinned_by')
op.drop_column('message', 'pinned_at')
op.drop_column('message', 'is_pinned')
@@ -12,8 +12,8 @@ import uuid
from alembic import op
import sqlalchemy as sa
revision: str = "374d2f66af06"
down_revision: Union[str, None] = "c440947495f3"
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
@@ -26,13 +26,13 @@ def upgrade() -> None:
# 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()),
'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
@@ -53,61 +53,61 @@ def upgrade() -> None:
# 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),
'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),
'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_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()),
'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:
@@ -120,7 +120,7 @@ def upgrade() -> None:
new_uuid = str(uuid.uuid4())
history_uuid = str(uuid.uuid4())
clean_command = command[1:] if command and command.startswith("/") else command
clean_command = command[1:] if command and command.startswith('/') else command
# Insert into prompt_new
conn.execute(
@@ -148,12 +148,12 @@ def upgrade() -> None:
prompt_id=new_uuid,
parent_id=None,
snapshot={
"name": title,
"content": content,
"command": clean_command,
"data": {},
"meta": {},
"access_control": access_control,
'name': title,
'content': content,
'command': clean_command,
'data': {},
'meta': {},
'access_control': access_control,
},
user_id=user_id,
commit_message=None,
@@ -162,8 +162,8 @@ def upgrade() -> None:
)
# Step 5: Replace old table with new one
op.drop_table("prompt")
op.rename_table("prompt_new", "prompt")
op.drop_table('prompt')
op.rename_table('prompt_new', 'prompt')
def downgrade() -> None:
@@ -171,13 +171,13 @@ def downgrade() -> None:
# 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()),
'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:
@@ -195,31 +195,31 @@ def downgrade() -> None:
current_data = []
# Step 2: Drop history and table
op.drop_table("prompt_history")
op.drop_table("prompt")
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),
'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()),
'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:
@@ -231,9 +231,7 @@ def downgrade() -> None:
access_control = row[5]
# Restore leading /
old_command = (
"/" + command if command and not command.startswith("/") else command
)
old_command = '/' + command if command and not command.startswith('/') else command
conn.execute(
sa.insert(old_prompt_table).values(
@@ -9,8 +9,8 @@ Create Date: 2024-12-30 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "3781e22d8b01"
down_revision = "7826ab40b532"
revision = '3781e22d8b01'
down_revision = '7826ab40b532'
branch_labels = None
depends_on = None
@@ -18,9 +18,9 @@ depends_on = None
def upgrade():
# Add 'type' column to the 'channel' table
op.add_column(
"channel",
'channel',
sa.Column(
"type",
'type',
sa.Text(),
nullable=True,
),
@@ -28,43 +28,31 @@ def upgrade():
# Add 'parent_id' column to the 'message' table for threads
op.add_column(
"message",
sa.Column("parent_id", sa.Text(), nullable=True),
'message',
sa.Column('parent_id', sa.Text(), nullable=True),
)
op.create_table(
"message_reaction",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Unique reaction ID
sa.Column("user_id", sa.Text(), nullable=False), # User who reacted
sa.Column(
"message_id", sa.Text(), nullable=False
), # Message that was reacted to
sa.Column(
"name", sa.Text(), nullable=False
), # Reaction name (e.g. "thumbs_up")
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the reaction was added
'message_reaction',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID
sa.Column('user_id', sa.Text(), nullable=False), # User who reacted
sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to
sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up")
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added
)
op.create_table(
"channel_member",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column("channel_id", sa.Text(), nullable=False), # Associated channel
sa.Column("user_id", sa.Text(), nullable=False), # Associated user
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the user joined the channel
'channel_member',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row
sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel
sa.Column('user_id', sa.Text(), nullable=False), # Associated user
sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel
)
def downgrade():
# Revert 'type' column addition to the 'channel' table
op.drop_column("channel", "type")
op.drop_column("message", "parent_id")
op.drop_table("message_reaction")
op.drop_table("channel_member")
op.drop_column('channel', 'type')
op.drop_column('message', 'parent_id')
op.drop_table('message_reaction')
op.drop_table('channel_member')
@@ -15,8 +15,8 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "37f288994c47"
down_revision: Union[str, None] = "a5c220713937"
revision: str = '37f288994c47'
down_revision: Union[str, None] = 'a5c220713937'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -24,50 +24,48 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create new table
op.create_table(
"group_member",
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
'group_member',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column(
"group_id",
'group_id',
sa.Text(),
sa.ForeignKey("group.id", ondelete="CASCADE"),
sa.ForeignKey('group.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
"user_id",
'user_id',
sa.Text(),
sa.ForeignKey("user.id", ondelete="CASCADE"),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.UniqueConstraint('group_id', 'user_id', name='uq_group_member_group_user'),
)
connection = op.get_bind()
# 2. Read existing group with user_ids JSON column
group_table = sa.Table(
"group",
'group',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG
sa.Column('id', sa.Text()),
sa.Column('user_ids', sa.JSON()), # JSON stored as text in SQLite + PG
)
results = connection.execute(
sa.select(group_table.c.id, group_table.c.user_ids)
).fetchall()
results = connection.execute(sa.select(group_table.c.id, group_table.c.user_ids)).fetchall()
print(results)
# 3. Insert members into group_member table
gm_table = sa.Table(
"group_member",
'group_member',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("group_id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("created_at", sa.BigInteger()),
sa.Column("updated_at", sa.BigInteger()),
sa.Column('id', sa.Text()),
sa.Column('group_id', sa.Text()),
sa.Column('user_id', sa.Text()),
sa.Column('created_at', sa.BigInteger()),
sa.Column('updated_at', sa.BigInteger()),
)
now = int(time.time())
@@ -86,11 +84,11 @@ def upgrade() -> None:
rows = [
{
"id": str(uuid.uuid4()),
"group_id": group_id,
"user_id": uid,
"created_at": now,
"updated_at": now,
'id': str(uuid.uuid4()),
'group_id': group_id,
'user_id': uid,
'created_at': now,
'updated_at': now,
}
for uid in user_ids
]
@@ -99,47 +97,41 @@ def upgrade() -> None:
connection.execute(gm_table.insert(), rows)
# 4. Optionally drop the old column
with op.batch_alter_table("group") as batch:
batch.drop_column("user_ids")
with op.batch_alter_table('group') as batch:
batch.drop_column('user_ids')
def downgrade():
# Reverse: restore user_ids column
with op.batch_alter_table("group") as batch:
batch.add_column(sa.Column("user_ids", sa.JSON()))
with op.batch_alter_table('group') as batch:
batch.add_column(sa.Column('user_ids', sa.JSON()))
connection = op.get_bind()
gm_table = sa.Table(
"group_member",
'group_member',
sa.MetaData(),
sa.Column("group_id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("created_at", sa.BigInteger()),
sa.Column("updated_at", sa.BigInteger()),
sa.Column('group_id', sa.Text()),
sa.Column('user_id', sa.Text()),
sa.Column('created_at', sa.BigInteger()),
sa.Column('updated_at', sa.BigInteger()),
)
group_table = sa.Table(
"group",
'group',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_ids", sa.JSON()),
sa.Column('id', sa.Text()),
sa.Column('user_ids', sa.JSON()),
)
# Build JSON arrays again
results = connection.execute(sa.select(group_table.c.id)).fetchall()
for (group_id,) in results:
members = connection.execute(
sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)
).fetchall()
members = connection.execute(sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)).fetchall()
member_ids = [m[0] for m in members]
connection.execute(
group_table.update()
.where(group_table.c.id == group_id)
.values(user_ids=member_ids)
)
connection.execute(group_table.update().where(group_table.c.id == group_id).values(user_ids=member_ids))
# Drop the new table
op.drop_table("group_member")
op.drop_table('group_member')
@@ -12,8 +12,8 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "38d63c18f30f"
down_revision: Union[str, None] = "3af16a1c9fb6"
revision: str = '38d63c18f30f'
down_revision: Union[str, None] = '3af16a1c9fb6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -21,59 +21,55 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint)
inspector = sa.inspect(op.get_bind())
columns = inspector.get_columns("user")
columns = inspector.get_columns('user')
pk_columns = inspector.get_pk_constraint("user")["constrained_columns"]
id_column = next((col for col in columns if col["name"] == "id"), None)
pk_columns = inspector.get_pk_constraint('user')['constrained_columns']
id_column = next((col for col in columns if col['name'] == 'id'), None)
if id_column and not id_column.get("unique", False):
unique_constraints = inspector.get_unique_constraints("user")
unique_columns = {tuple(u["column_names"]) for u in unique_constraints}
if id_column and not id_column.get('unique', False):
unique_constraints = inspector.get_unique_constraints('user')
unique_columns = {tuple(u['column_names']) for u in unique_constraints}
with op.batch_alter_table("user") as batch_op:
with op.batch_alter_table('user') as batch_op:
# If primary key is wrong, drop it
if pk_columns and pk_columns != ["id"]:
batch_op.drop_constraint(
inspector.get_pk_constraint("user")["name"], type_="primary"
)
if pk_columns and pk_columns != ['id']:
batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary')
# Add unique constraint if missing
if ("id",) not in unique_columns:
batch_op.create_unique_constraint("uq_user_id", ["id"])
if ('id',) not in unique_columns:
batch_op.create_unique_constraint('uq_user_id', ['id'])
# Re-create correct primary key
batch_op.create_primary_key("pk_user_id", ["id"])
batch_op.create_primary_key('pk_user_id', ['id'])
# Create oauth_session table
op.create_table(
"oauth_session",
sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True),
'oauth_session',
sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True),
sa.Column(
"user_id",
'user_id',
sa.Text(),
sa.ForeignKey("user.id", ondelete="CASCADE"),
sa.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("expires_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column('provider', sa.Text(), nullable=False),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('expires_at', sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
# Create indexes for better performance
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
op.create_index(
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
)
op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id'])
op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at'])
op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider'])
def downgrade() -> None:
# Drop indexes first
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
op.drop_index('idx_oauth_session_user_provider', table_name='oauth_session')
op.drop_index('idx_oauth_session_expires_at', table_name='oauth_session')
op.drop_index('idx_oauth_session_user_id', table_name='oauth_session')
# Drop the table
op.drop_table("oauth_session")
op.drop_table('oauth_session')
@@ -13,8 +13,8 @@ from sqlalchemy.engine.reflection import Inspector
import json
revision = "3ab32c4b8f59"
down_revision = "1af9b942657b"
revision = '3ab32c4b8f59'
down_revision = '1af9b942657b'
branch_labels = None
depends_on = None
@@ -24,58 +24,55 @@ def upgrade():
inspector = Inspector.from_engine(conn)
# Inspecting the 'tag' table constraints and structure
existing_pk = inspector.get_pk_constraint("tag")
unique_constraints = inspector.get_unique_constraints("tag")
existing_indexes = inspector.get_indexes("tag")
existing_pk = inspector.get_pk_constraint('tag')
unique_constraints = inspector.get_unique_constraints('tag')
existing_indexes = inspector.get_indexes('tag')
print(f"Primary Key: {existing_pk}")
print(f"Unique Constraints: {unique_constraints}")
print(f"Indexes: {existing_indexes}")
print(f'Primary Key: {existing_pk}')
print(f'Unique Constraints: {unique_constraints}')
print(f'Indexes: {existing_indexes}')
with op.batch_alter_table("tag", schema=None) as batch_op:
with op.batch_alter_table('tag', schema=None) as batch_op:
# Drop existing primary key constraint if it exists
if existing_pk and existing_pk.get("constrained_columns"):
pk_name = existing_pk.get("name")
if existing_pk and existing_pk.get('constrained_columns'):
pk_name = existing_pk.get('name')
if pk_name:
print(f"Dropping primary key constraint: {pk_name}")
batch_op.drop_constraint(pk_name, type_="primary")
print(f'Dropping primary key constraint: {pk_name}')
batch_op.drop_constraint(pk_name, type_='primary')
# Now create the new primary key with the combination of 'id' and 'user_id'
print("Creating new primary key with 'id' and 'user_id'.")
batch_op.create_primary_key("pk_id_user_id", ["id", "user_id"])
batch_op.create_primary_key('pk_id_user_id', ['id', 'user_id'])
# Drop unique constraints that could conflict with the new primary key
for constraint in unique_constraints:
if (
constraint["name"] == "uq_id_user_id"
constraint['name'] == 'uq_id_user_id'
): # Adjust this name according to what is actually returned by the inspector
print(f"Dropping unique constraint: {constraint['name']}")
batch_op.drop_constraint(constraint["name"], type_="unique")
print(f'Dropping unique constraint: {constraint["name"]}')
batch_op.drop_constraint(constraint['name'], type_='unique')
for index in existing_indexes:
if index["unique"]:
if not any(
constraint["name"] == index["name"]
for constraint in unique_constraints
):
if index['unique']:
if not any(constraint['name'] == index['name'] for constraint in unique_constraints):
# You are attempting to drop unique indexes
print(f"Dropping unique index: {index['name']}")
batch_op.drop_index(index["name"])
print(f'Dropping unique index: {index["name"]}')
batch_op.drop_index(index['name'])
def downgrade():
conn = op.get_bind()
inspector = Inspector.from_engine(conn)
current_pk = inspector.get_pk_constraint("tag")
current_pk = inspector.get_pk_constraint('tag')
with op.batch_alter_table("tag", schema=None) as batch_op:
with op.batch_alter_table('tag', schema=None) as batch_op:
# Drop the current primary key first, if it matches the one we know we added in upgrade
if current_pk and "pk_id_user_id" == current_pk.get("name"):
batch_op.drop_constraint("pk_id_user_id", type_="primary")
if current_pk and 'pk_id_user_id' == current_pk.get('name'):
batch_op.drop_constraint('pk_id_user_id', type_='primary')
# Restore the original primary key
batch_op.create_primary_key("pk_id", ["id"])
batch_op.create_primary_key('pk_id', ['id'])
# Since primary key on just 'id' is restored, we now add back any unique constraints if necessary
batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"])
batch_op.create_unique_constraint('uq_id_user_id', ['id', 'user_id'])
@@ -12,21 +12,21 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3af16a1c9fb6"
down_revision: Union[str, None] = "018012973d35"
revision: str = '3af16a1c9fb6'
down_revision: Union[str, None] = '018012973d35'
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("username", sa.String(length=50), nullable=True))
op.add_column("user", sa.Column("bio", sa.Text(), nullable=True))
op.add_column("user", sa.Column("gender", sa.Text(), nullable=True))
op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True))
op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True))
op.add_column('user', sa.Column('bio', sa.Text(), nullable=True))
op.add_column('user', sa.Column('gender', sa.Text(), nullable=True))
op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("user", "username")
op.drop_column("user", "bio")
op.drop_column("user", "gender")
op.drop_column("user", "date_of_birth")
op.drop_column('user', 'username')
op.drop_column('user', 'bio')
op.drop_column('user', 'gender')
op.drop_column('user', 'date_of_birth')
@@ -18,38 +18,38 @@ import json
import uuid
# revision identifiers, used by Alembic.
revision: str = "3e0e00844bb0"
down_revision: Union[str, None] = "90ef40d4714e"
revision: str = '3e0e00844bb0'
down_revision: Union[str, None] = '90ef40d4714e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"knowledge_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
'knowledge_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
"knowledge_id",
'knowledge_id',
sa.Text(),
sa.ForeignKey("knowledge.id", ondelete="CASCADE"),
sa.ForeignKey('knowledge.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
"file_id",
'file_id',
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_knowledge_file_knowledge_id", "knowledge_id"),
sa.Index("ix_knowledge_file_file_id", "file_id"),
sa.Index("ix_knowledge_file_user_id", "user_id"),
sa.Index('ix_knowledge_file_knowledge_id', 'knowledge_id'),
sa.Index('ix_knowledge_file_file_id', 'file_id'),
sa.Index('ix_knowledge_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint(
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
'knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file'
), # prevent duplicate entries
)
@@ -57,35 +57,33 @@ def upgrade() -> None:
# 2. Read existing group with user_ids JSON column
knowledge_table = sa.Table(
"knowledge",
'knowledge',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("data", sa.JSON()), # JSON stored as text in SQLite + PG
sa.Column('id', sa.Text()),
sa.Column('user_id', sa.Text()),
sa.Column('data', sa.JSON()), # JSON stored as text in SQLite + PG
)
results = connection.execute(
sa.select(
knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data
)
sa.select(knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data)
).fetchall()
# 3. Insert members into group_member table
kf_table = sa.Table(
"knowledge_file",
'knowledge_file',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("user_id", sa.Text()),
sa.Column("knowledge_id", sa.Text()),
sa.Column("file_id", sa.Text()),
sa.Column("created_at", sa.BigInteger()),
sa.Column("updated_at", sa.BigInteger()),
sa.Column('id', sa.Text()),
sa.Column('user_id', sa.Text()),
sa.Column('knowledge_id', sa.Text()),
sa.Column('file_id', sa.Text()),
sa.Column('created_at', sa.BigInteger()),
sa.Column('updated_at', sa.BigInteger()),
)
file_table = sa.Table(
"file",
'file',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column('id', sa.Text()),
)
now = int(time.time())
@@ -102,50 +100,48 @@ def upgrade() -> None:
if not isinstance(data, dict):
continue
file_ids = data.get("file_ids", [])
file_ids = data.get('file_ids', [])
for file_id in file_ids:
file_exists = connection.execute(
sa.select(file_table.c.id).where(file_table.c.id == file_id)
).fetchone()
file_exists = connection.execute(sa.select(file_table.c.id).where(file_table.c.id == file_id)).fetchone()
if not file_exists:
continue # skip non-existing files
row = {
"id": str(uuid.uuid4()),
"user_id": user_id,
"knowledge_id": knowledge_id,
"file_id": file_id,
"created_at": now,
"updated_at": now,
'id': str(uuid.uuid4()),
'user_id': user_id,
'knowledge_id': knowledge_id,
'file_id': file_id,
'created_at': now,
'updated_at': now,
}
connection.execute(kf_table.insert().values(**row))
with op.batch_alter_table("knowledge") as batch:
batch.drop_column("data")
with op.batch_alter_table('knowledge') as batch:
batch.drop_column('data')
def downgrade() -> None:
# 1. Add back the old data column
op.add_column("knowledge", sa.Column("data", sa.JSON(), nullable=True))
op.add_column('knowledge', sa.Column('data', sa.JSON(), nullable=True))
connection = op.get_bind()
# 2. Read knowledge_file entries and reconstruct data JSON
knowledge_table = sa.Table(
"knowledge",
'knowledge',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("data", sa.JSON()),
sa.Column('id', sa.Text()),
sa.Column('data', sa.JSON()),
)
kf_table = sa.Table(
"knowledge_file",
'knowledge_file',
sa.MetaData(),
sa.Column("id", sa.Text()),
sa.Column("knowledge_id", sa.Text()),
sa.Column("file_id", sa.Text()),
sa.Column('id', sa.Text()),
sa.Column('knowledge_id', sa.Text()),
sa.Column('file_id', sa.Text()),
)
results = connection.execute(sa.select(knowledge_table.c.id)).fetchall()
@@ -157,13 +153,9 @@ def downgrade() -> None:
file_ids_list = [fid for (fid,) in file_ids]
data_json = {"file_ids": file_ids_list}
data_json = {'file_ids': file_ids_list}
connection.execute(
knowledge_table.update()
.where(knowledge_table.c.id == knowledge_id)
.values(data=data_json)
)
connection.execute(knowledge_table.update().where(knowledge_table.c.id == knowledge_id).values(data=data_json))
# 3. Drop the knowledge_file table
op.drop_table("knowledge_file")
op.drop_table('knowledge_file')
@@ -9,56 +9,56 @@ Create Date: 2024-10-23 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "4ace53fd72c8"
down_revision = "af906e964978"
revision = '4ace53fd72c8'
down_revision = 'af906e964978'
branch_labels = None
depends_on = None
def upgrade():
# Perform safe alterations using batch operation
with op.batch_alter_table("folder", schema=None) as batch_op:
with op.batch_alter_table('folder', schema=None) as batch_op:
# Step 1: Remove server defaults for created_at and updated_at
batch_op.alter_column(
"created_at",
'created_at',
server_default=None, # Removing server default
)
batch_op.alter_column(
"updated_at",
'updated_at',
server_default=None, # Removing server default
)
# Step 2: Change the column types to BigInteger for created_at
batch_op.alter_column(
"created_at",
'created_at',
type_=sa.BigInteger(),
existing_type=sa.DateTime(),
existing_nullable=False,
postgresql_using="extract(epoch from created_at)::bigint", # Conversion for PostgreSQL
postgresql_using='extract(epoch from created_at)::bigint', # Conversion for PostgreSQL
)
# Change the column types to BigInteger for updated_at
batch_op.alter_column(
"updated_at",
'updated_at',
type_=sa.BigInteger(),
existing_type=sa.DateTime(),
existing_nullable=False,
postgresql_using="extract(epoch from updated_at)::bigint", # Conversion for PostgreSQL
postgresql_using='extract(epoch from updated_at)::bigint', # Conversion for PostgreSQL
)
def downgrade():
# Downgrade: Convert columns back to DateTime and restore defaults
with op.batch_alter_table("folder", schema=None) as batch_op:
with op.batch_alter_table('folder', schema=None) as batch_op:
batch_op.alter_column(
"created_at",
'created_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
server_default=sa.func.now(), # Restoring server default on downgrade
)
batch_op.alter_column(
"updated_at",
'updated_at',
type_=sa.DateTime(),
existing_type=sa.BigInteger(),
existing_nullable=False,
@@ -0,0 +1,80 @@
"""add pinned_note table
Revision ID: 4de81c2a3af1
Revises: 56359461a091
Create Date: 2026-05-09 04:29:27.651341
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = '4de81c2a3af1'
down_revision: Union[str, None] = '56359461a091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
import uuid
import time
from sqlalchemy import select, update, insert
from sqlalchemy.sql import table, column
def upgrade() -> None:
op.create_table(
'pinned_note',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
)
conn = op.get_bind()
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table(
'pinned_note',
column('id', sa.Text),
column('user_id', sa.Text),
column('note_id', sa.Text),
column('created_at', sa.BigInteger),
)
notes = conn.execute(select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)).fetchall()
if notes:
now = int(time.time_ns())
conn.execute(
insert(pinned_note_table),
[{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now} for note in notes],
)
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.drop_column('is_pinned')
def downgrade() -> None:
with op.batch_alter_table('note', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True))
conn = op.get_bind()
note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean))
pinned_note_table = table('pinned_note', column('note_id', sa.Text))
notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall()
for note in notes:
conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True))
op.drop_table('pinned_note')
@@ -0,0 +1,83 @@
"""add calendar tables
Revision ID: 56359461a091
Revises: c1d2e3f4a5b6
Create Date: 2026-04-19 16:20:58.162045
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '56359461a091'
down_revision: Union[str, None] = 'c1d2e3f4a5b6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
def downgrade() -> None:
op.drop_index('ix_calendar_event_attendee_user', table_name='calendar_event_attendee')
op.drop_table('calendar_event_attendee')
op.drop_index('ix_calendar_event_user_date', table_name='calendar_event')
op.drop_index('ix_calendar_event_calendar', table_name='calendar_event')
op.drop_table('calendar_event')
op.drop_index('ix_calendar_user', table_name='calendar')
op.drop_table('calendar')
@@ -9,40 +9,40 @@ Create Date: 2024-12-22 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "57c599a3cb57"
down_revision = "922e7a387820"
revision = '57c599a3cb57'
down_revision = '922e7a387820'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"channel",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text()),
sa.Column("name", sa.Text()),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
'channel',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('name', sa.Text()),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
op.create_table(
"message",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text()),
sa.Column("channel_id", sa.Text(), nullable=True),
sa.Column("content", sa.Text()),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
'message',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text()),
sa.Column('channel_id', sa.Text(), nullable=True),
sa.Column('content', sa.Text()),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_table("channel")
op.drop_table('channel')
op.drop_table("message")
op.drop_table('message')
@@ -13,41 +13,39 @@ import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "6283dc0e4d8d"
down_revision: Union[str, None] = "3e0e00844bb0"
revision: str = '6283dc0e4d8d'
down_revision: Union[str, None] = '3e0e00844bb0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"channel_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
'channel_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
"channel_id",
'channel_id',
sa.Text(),
sa.ForeignKey("channel.id", ondelete="CASCADE"),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
"file_id",
'file_id',
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_channel_file_channel_id", "channel_id"),
sa.Index("ix_channel_file_file_id", "file_id"),
sa.Index("ix_channel_file_user_id", "user_id"),
sa.Index('ix_channel_file_channel_id', 'channel_id'),
sa.Index('ix_channel_file_file_id', 'file_id'),
sa.Index('ix_channel_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint(
"channel_id", "file_id", name="uq_channel_file_channel_file"
), # prevent duplicate entries
sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries
)
def downgrade() -> None:
op.drop_table("channel_file")
op.drop_table('channel_file')
@@ -11,37 +11,37 @@ import sqlalchemy as sa
from sqlalchemy.sql import table, column, select
import json
revision = "6a39f3d8e55c"
down_revision = "c0fbf31ca0db"
revision = '6a39f3d8e55c'
down_revision = 'c0fbf31ca0db'
branch_labels = None
depends_on = None
def upgrade():
# Creating the 'knowledge' table
print("Creating knowledge table")
print('Creating knowledge table')
knowledge_table = op.create_table(
"knowledge",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
'knowledge',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
print("Migrating data from document table to knowledge table")
print('Migrating data from document table to knowledge table')
# Representation of the existing 'document' table
document_table = table(
"document",
column("collection_name", sa.String()),
column("user_id", sa.String()),
column("name", sa.String()),
column("title", sa.Text()),
column("content", sa.Text()),
column("timestamp", sa.BigInteger()),
'document',
column('collection_name', sa.String()),
column('user_id', sa.String()),
column('name', sa.String()),
column('title', sa.Text()),
column('content', sa.Text()),
column('timestamp', sa.BigInteger()),
)
# Select all from existing document table
@@ -64,9 +64,9 @@ def upgrade():
user_id=doc.user_id,
description=doc.name,
meta={
"legacy": True,
"document": True,
"tags": json.loads(doc.content or "{}").get("tags", []),
'legacy': True,
'document': True,
'tags': json.loads(doc.content or '{}').get('tags', []),
},
name=doc.title,
created_at=doc.timestamp,
@@ -76,4 +76,4 @@ def upgrade():
def downgrade():
op.drop_table("knowledge")
op.drop_table('knowledge')
@@ -9,18 +9,18 @@ Create Date: 2024-12-23 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "7826ab40b532"
down_revision = "57c599a3cb57"
revision = '7826ab40b532'
down_revision = '57c599a3cb57'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"file",
sa.Column("access_control", sa.JSON(), nullable=True),
'file',
sa.Column('access_control', sa.JSON(), nullable=True),
)
def downgrade():
op.drop_column("file", "access_control")
op.drop_column('file', 'access_control')
@@ -16,7 +16,7 @@ from open_webui.internal.db import JSONField
from open_webui.migrations.util import get_existing_tables
# revision identifiers, used by Alembic.
revision: str = "7e5b5dc7342b"
revision: str = '7e5b5dc7342b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -26,179 +26,179 @@ def upgrade() -> None:
existing_tables = set(get_existing_tables())
# ### commands auto generated by Alembic - please adjust! ###
if "auth" not in existing_tables:
if 'auth' not in existing_tables:
op.create_table(
"auth",
sa.Column("id", sa.String(), nullable=False),
sa.Column("email", sa.String(), nullable=True),
sa.Column("password", sa.Text(), nullable=True),
sa.Column("active", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'auth',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('password', sa.Text(), nullable=True),
sa.Column('active', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "chat" not in existing_tables:
if 'chat' not in existing_tables:
op.create_table(
"chat",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("chat", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.Column("share_id", sa.Text(), nullable=True),
sa.Column("archived", sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("share_id"),
'chat',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('share_id', sa.Text(), nullable=True),
sa.Column('archived', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('share_id'),
)
if "chatidtag" not in existing_tables:
if 'chatidtag' not in existing_tables:
op.create_table(
"chatidtag",
sa.Column("id", sa.String(), nullable=False),
sa.Column("tag_name", sa.String(), nullable=True),
sa.Column("chat_id", sa.String(), nullable=True),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("timestamp", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'chatidtag',
sa.Column('id', sa.String(), nullable=False),
sa.Column('tag_name', sa.String(), nullable=True),
sa.Column('chat_id', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "document" not in existing_tables:
if 'document' not in existing_tables:
op.create_table(
"document",
sa.Column("collection_name", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("filename", sa.Text(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("timestamp", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("collection_name"),
sa.UniqueConstraint("name"),
'document',
sa.Column('collection_name', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('filename', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('collection_name'),
sa.UniqueConstraint('name'),
)
if "file" not in existing_tables:
if 'file' not in existing_tables:
op.create_table(
"file",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("filename", sa.Text(), nullable=True),
sa.Column("meta", JSONField(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'file',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('filename', sa.Text(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "function" not in existing_tables:
if 'function' not in existing_tables:
op.create_table(
"function",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("type", sa.Text(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("meta", JSONField(), nullable=True),
sa.Column("valves", JSONField(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("is_global", sa.Boolean(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'function',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('type', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('valves', JSONField(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_global', sa.Boolean(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "memory" not in existing_tables:
if 'memory' not in existing_tables:
op.create_table(
"memory",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'memory',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "model" not in existing_tables:
if 'model' not in existing_tables:
op.create_table(
"model",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=True),
sa.Column("base_model_id", sa.Text(), nullable=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("params", JSONField(), nullable=True),
sa.Column("meta", JSONField(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'model',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('base_model_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('params', JSONField(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "prompt" not in existing_tables:
if 'prompt' not in existing_tables:
op.create_table(
"prompt",
sa.Column("command", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("timestamp", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("command"),
'prompt',
sa.Column('command', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('timestamp', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('command'),
)
if "tag" not in existing_tables:
if 'tag' not in existing_tables:
op.create_table(
"tag",
sa.Column("id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("data", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'tag',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('data', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "tool" not in existing_tables:
if 'tool' not in existing_tables:
op.create_table(
"tool",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("content", sa.Text(), nullable=True),
sa.Column("specs", JSONField(), nullable=True),
sa.Column("meta", JSONField(), nullable=True),
sa.Column("valves", JSONField(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint("id"),
'tool',
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('specs', JSONField(), nullable=True),
sa.Column('meta', JSONField(), nullable=True),
sa.Column('valves', JSONField(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
if "user" not in existing_tables:
if 'user' not in existing_tables:
op.create_table(
"user",
sa.Column("id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("email", sa.String(), nullable=True),
sa.Column("role", sa.String(), nullable=True),
sa.Column("profile_image_url", sa.Text(), nullable=True),
sa.Column("last_active_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("api_key", sa.String(), nullable=True),
sa.Column("settings", JSONField(), nullable=True),
sa.Column("info", JSONField(), nullable=True),
sa.Column("oauth_sub", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("api_key"),
sa.UniqueConstraint("oauth_sub"),
'user',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('role', sa.String(), nullable=True),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('last_active_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('api_key', sa.String(), nullable=True),
sa.Column('settings', JSONField(), nullable=True),
sa.Column('info', JSONField(), nullable=True),
sa.Column('oauth_sub', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_key'),
sa.UniqueConstraint('oauth_sub'),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("user")
op.drop_table("tool")
op.drop_table("tag")
op.drop_table("prompt")
op.drop_table("model")
op.drop_table("memory")
op.drop_table("function")
op.drop_table("file")
op.drop_table("document")
op.drop_table("chatidtag")
op.drop_table("chat")
op.drop_table("auth")
op.drop_table('user')
op.drop_table('tool')
op.drop_table('tag')
op.drop_table('prompt')
op.drop_table('model')
op.drop_table('memory')
op.drop_table('function')
op.drop_table('file')
op.drop_table('document')
op.drop_table('chatidtag')
op.drop_table('chat')
op.drop_table('auth')
# ### end Alembic commands ###
@@ -13,36 +13,34 @@ import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "81cc2ce44d79"
down_revision: Union[str, None] = "6283dc0e4d8d"
revision: str = '81cc2ce44d79'
down_revision: Union[str, None] = '6283dc0e4d8d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add message_id column to channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"message_id",
'message_id',
sa.Text(),
sa.ForeignKey(
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
),
sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'),
nullable=True,
)
)
# Add data column to knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove message_id column from channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.drop_column("message_id")
with op.batch_alter_table('channel_file', schema=None) as batch_op:
batch_op.drop_column('message_id')
# Remove data column from knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.drop_column("data")
with op.batch_alter_table('knowledge', schema=None) as batch_op:
batch_op.drop_column('data')
@@ -16,8 +16,8 @@ import sqlalchemy as sa
log = logging.getLogger(__name__)
revision: str = "8452d01d26d7"
down_revision: Union[str, None] = "374d2f66af06"
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
@@ -51,74 +51,68 @@ def _flush_batch(conn, table, batch):
except Exception as e:
sp.rollback()
failed += 1
log.warning(f"Failed to insert message {msg['id']}: {e}")
log.warning(f'Failed to insert message {msg["id"]}: {e}')
return inserted, failed
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"),
'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"]
)
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',
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()),
'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()),
)
# Stream rows instead of loading all into memory:
@@ -126,7 +120,7 @@ def upgrade() -> None:
# - stream_results: enables server-side cursors on PostgreSQL (no-op on SQLite)
result = 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-%"))
.where(~chat_table.c.user_id.like('shared-%'))
.execution_options(yield_per=1000, stream_results=True)
)
@@ -150,18 +144,23 @@ def upgrade() -> None:
except Exception:
continue
history = chat_data.get("history", {})
messages = history.get("messages", {})
history = chat_data.get('history', {})
if not isinstance(history, dict):
continue
messages = history.get('messages', {})
if not isinstance(messages, dict):
continue
for message_id, message in messages.items():
if not isinstance(message, dict):
continue
role = message.get("role")
role = message.get('role')
if not role:
continue
timestamp = message.get("timestamp", now)
timestamp = message.get('timestamp', now)
try:
timestamp = int(float(timestamp))
@@ -177,37 +176,33 @@ def upgrade() -> None:
messages_batch.append(
{
"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"),
"usage": message.get("usage"),
"created_at": timestamp,
"updated_at": timestamp,
'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'),
'usage': message.get('usage'),
'created_at': timestamp,
'updated_at': timestamp,
}
)
# Flush batch when full
if len(messages_batch) >= BATCH_SIZE:
inserted, failed = _flush_batch(
conn, chat_message_table, messages_batch
)
inserted, failed = _flush_batch(conn, chat_message_table, messages_batch)
total_inserted += inserted
total_failed += failed
if total_inserted % 50000 < BATCH_SIZE:
log.info(
f"Migration progress: {total_inserted} messages inserted..."
)
log.info(f'Migration progress: {total_inserted} messages inserted...')
messages_batch.clear()
# Flush remaining messages
@@ -216,13 +211,11 @@ def upgrade() -> None:
total_inserted += inserted
total_failed += failed
log.info(
f"Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)"
)
log.info(f'Backfilled {total_inserted} messages into chat_message table ({total_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")
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')
@@ -13,48 +13,46 @@ import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "90ef40d4714e"
down_revision: Union[str, None] = "b10670c03dd5"
revision: str = '90ef40d4714e'
down_revision: Union[str, None] = 'b10670c03dd5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Update 'channel' table
op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True))
op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True))
op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True))
op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True))
op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True))
op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True))
op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True))
op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True))
op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True))
op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True))
op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True))
# Update 'channel_member' table
op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True))
op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True))
op.add_column(
"channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True)
)
op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True))
op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True))
# Create 'channel_webhook' table
op.create_table(
"channel_webhook",
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
'channel_webhook',
sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
"channel_id",
'channel_id',
sa.Text(),
sa.ForeignKey("channel.id", ondelete="CASCADE"),
sa.ForeignKey('channel.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("profile_image_url", sa.Text(), nullable=True),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('profile_image_url', sa.Text(), nullable=True),
sa.Column('token', sa.Text(), nullable=False),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
pass
@@ -62,19 +60,19 @@ def upgrade() -> None:
def downgrade() -> None:
# Downgrade 'channel' table
op.drop_column("channel", "is_private")
op.drop_column("channel", "archived_at")
op.drop_column("channel", "archived_by")
op.drop_column("channel", "deleted_at")
op.drop_column("channel", "deleted_by")
op.drop_column("channel", "updated_by")
op.drop_column('channel', 'is_private')
op.drop_column('channel', 'archived_at')
op.drop_column('channel', 'archived_by')
op.drop_column('channel', 'deleted_at')
op.drop_column('channel', 'deleted_by')
op.drop_column('channel', 'updated_by')
# Downgrade 'channel_member' table
op.drop_column("channel_member", "role")
op.drop_column("channel_member", "invited_by")
op.drop_column("channel_member", "invited_at")
op.drop_column('channel_member', 'role')
op.drop_column('channel_member', 'invited_by')
op.drop_column('channel_member', 'invited_at')
# Drop 'channel_webhook' table
op.drop_table("channel_webhook")
op.drop_table('channel_webhook')
pass
@@ -9,38 +9,38 @@ Create Date: 2024-11-14 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "922e7a387820"
down_revision = "4ace53fd72c8"
revision = '922e7a387820'
down_revision = '4ace53fd72c8'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"group",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), nullable=True),
sa.Column("name", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("permissions", sa.JSON(), nullable=True),
sa.Column("user_ids", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
'group',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('permissions', sa.JSON(), nullable=True),
sa.Column('user_ids', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# Add 'access_control' column to 'model' table
op.add_column(
"model",
sa.Column("access_control", sa.JSON(), nullable=True),
'model',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'is_active' column to 'model' table
op.add_column(
"model",
'model',
sa.Column(
"is_active",
'is_active',
sa.Boolean(),
nullable=False,
server_default=sa.sql.expression.true(),
@@ -49,37 +49,37 @@ def upgrade():
# Add 'access_control' column to 'knowledge' table
op.add_column(
"knowledge",
sa.Column("access_control", sa.JSON(), nullable=True),
'knowledge',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'access_control' column to 'prompt' table
op.add_column(
"prompt",
sa.Column("access_control", sa.JSON(), nullable=True),
'prompt',
sa.Column('access_control', sa.JSON(), nullable=True),
)
# Add 'access_control' column to 'tools' table
op.add_column(
"tool",
sa.Column("access_control", sa.JSON(), nullable=True),
'tool',
sa.Column('access_control', sa.JSON(), nullable=True),
)
def downgrade():
op.drop_table("group")
op.drop_table('group')
# Drop 'access_control' column from 'model' table
op.drop_column("model", "access_control")
op.drop_column('model', 'access_control')
# Drop 'is_active' column from 'model' table
op.drop_column("model", "is_active")
op.drop_column('model', 'is_active')
# Drop 'access_control' column from 'knowledge' table
op.drop_column("knowledge", "access_control")
op.drop_column('knowledge', 'access_control')
# Drop 'access_control' column from 'prompt' table
op.drop_column("prompt", "access_control")
op.drop_column('prompt', 'access_control')
# Drop 'access_control' column from 'tools' table
op.drop_column("tool", "access_control")
op.drop_column('tool', 'access_control')
@@ -9,25 +9,25 @@ Create Date: 2025-05-03 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "9f0c9cd09105"
down_revision = "3781e22d8b01"
revision = '9f0c9cd09105'
down_revision = '3781e22d8b01'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"note",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
'note',
sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), nullable=True),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('access_control', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_table("note")
op.drop_table('note')
@@ -0,0 +1,22 @@
"""Add memory user_id index
Revision ID: a0b1c2d3e4f5
Revises: 4de81c2a3af1
Create Date: 2025-09-15 03:00:00.000000
"""
from alembic import op
revision = 'a0b1c2d3e4f5'
down_revision = '4de81c2a3af1'
branch_labels = None
depends_on = None
def upgrade():
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
def downgrade():
op.drop_index('ix_memory_user_id', table_name='memory')
@@ -13,8 +13,8 @@ import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "f1e2d3c4b5a6"
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
@@ -22,24 +22,24 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
existing_tables = set(get_existing_tables())
if "skill" not in 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),
'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"])
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")
op.drop_index('idx_skill_updated_at', table_name='skill')
op.drop_index('idx_skill_user_id', table_name='skill')
op.drop_table('skill')
@@ -0,0 +1,28 @@
"""Add tasks and summary columns to chat table
Revision ID: a3dd5bedd151
Revises: b2c3d4e5f6a7
Create Date: 2026-03-29 22:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3dd5bedd151'
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('chat', 'summary')
op.drop_column('chat', 'tasks')
@@ -12,8 +12,8 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a5c220713937"
down_revision: Union[str, None] = "38d63c18f30f"
revision: str = 'a5c220713937'
down_revision: Union[str, None] = '38d63c18f30f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -21,14 +21,14 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add 'reply_to_id' column to the 'message' table for replying to messages
op.add_column(
"message",
sa.Column("reply_to_id", sa.Text(), nullable=True),
'message',
sa.Column('reply_to_id', sa.Text(), nullable=True),
)
pass
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column("message", "reply_to_id")
op.drop_column('message', 'reply_to_id')
pass
@@ -10,8 +10,8 @@ from alembic import op
import sqlalchemy as sa
# Revision identifiers, used by Alembic.
revision = "af906e964978"
down_revision = "c29facfe716b"
revision = 'af906e964978'
down_revision = 'c29facfe716b'
branch_labels = None
depends_on = None
@@ -19,33 +19,23 @@ depends_on = None
def upgrade():
# ### Create feedback table ###
op.create_table(
"feedback",
'feedback',
sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type)
sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type)
sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type)
sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type)
sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type)
sa.Column(
"id", sa.Text(), primary_key=True
), # Unique identifier for each feedback (TEXT type)
sa.Column(
"user_id", sa.Text(), nullable=True
), # ID of the user providing the feedback (TEXT type)
sa.Column(
"version", sa.BigInteger(), default=0
), # Version of feedback (BIGINT type)
sa.Column("type", sa.Text(), nullable=True), # Type of feedback (TEXT type)
sa.Column("data", sa.JSON(), nullable=True), # Feedback data (JSON type)
sa.Column(
"meta", sa.JSON(), nullable=True
), # Metadata for feedback (JSON type)
sa.Column(
"snapshot", sa.JSON(), nullable=True
), # snapshot data for feedback (JSON type)
sa.Column(
"created_at", sa.BigInteger(), nullable=False
'created_at', sa.BigInteger(), nullable=False
), # Feedback creation timestamp (BIGINT representing epoch)
sa.Column(
"updated_at", sa.BigInteger(), nullable=False
'updated_at', sa.BigInteger(), nullable=False
), # Feedback update timestamp (BIGINT representing epoch)
)
def downgrade():
# ### Drop feedback table ###
op.drop_table("feedback")
op.drop_table('feedback')
@@ -17,8 +17,8 @@ import json
import time
# revision identifiers, used by Alembic.
revision: str = "b10670c03dd5"
down_revision: Union[str, None] = "2f1211949ecc"
revision: str = 'b10670c03dd5'
down_revision: Union[str, None] = '2f1211949ecc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -33,13 +33,11 @@ def _drop_sqlite_indexes_for_column(table_name, column_name, conn):
for idx in indexes:
index_name = idx[1] # index name
# Get indexed columns
idx_info = conn.execute(
sa.text(f"PRAGMA index_info('{index_name}')")
).fetchall()
idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall()
indexed_cols = [row[2] for row in idx_info] # col names
if column_name in indexed_cols:
conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}"))
conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}'))
def _convert_column_to_json(table: str, column: str):
@@ -47,9 +45,9 @@ def _convert_column_to_json(table: str, column: str):
dialect = conn.dialect.name
# SQLite cannot ALTER COLUMN → must recreate column
if dialect == "sqlite":
if dialect == 'sqlite':
# 1. Add temporary column
op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True))
op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True))
# 2. Load old data
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
@@ -66,14 +64,14 @@ def _convert_column_to_json(table: str, column: str):
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'),
{"val": json.dumps(parsed) if parsed else None, "id": uid},
{'val': json.dumps(parsed) if parsed else None, 'id': uid},
)
# 3. Drop old TEXT column
op.drop_column(table, column)
# 4. Rename new JSON column → original name
op.alter_column(table, f"{column}_json", new_column_name=column)
op.alter_column(table, f'{column}_json', new_column_name=column)
else:
# PostgreSQL supports direct CAST
@@ -81,7 +79,7 @@ def _convert_column_to_json(table: str, column: str):
table,
column,
type_=sa.JSON(),
postgresql_using=f"{column}::json",
postgresql_using=f'{column}::json',
)
@@ -89,85 +87,77 @@ def _convert_column_to_text(table: str, column: str):
conn = op.get_bind()
dialect = conn.dialect.name
if dialect == "sqlite":
op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True))
if dialect == 'sqlite':
op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True))
rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall()
for uid, raw in rows:
conn.execute(
sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'),
{"val": json.dumps(raw) if raw else None, "id": uid},
{'val': json.dumps(raw) if raw else None, 'id': uid},
)
op.drop_column(table, column)
op.alter_column(table, f"{column}_text", new_column_name=column)
op.alter_column(table, f'{column}_text', new_column_name=column)
else:
op.alter_column(
table,
column,
type_=sa.Text(),
postgresql_using=f"to_json({column})::text",
postgresql_using=f'to_json({column})::text',
)
def upgrade() -> None:
op.add_column(
"user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True)
)
op.add_column("user", sa.Column("timezone", sa.String(), nullable=True))
op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True))
op.add_column('user', sa.Column('timezone', sa.String(), nullable=True))
op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True))
op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True))
op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True))
op.add_column(
"user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True)
)
op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True))
op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True))
op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True))
op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True))
op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True))
# Convert info (TEXT/JSONField) → JSON
_convert_column_to_json("user", "info")
_convert_column_to_json('user', 'info')
# Convert settings (TEXT/JSONField) → JSON
_convert_column_to_json("user", "settings")
_convert_column_to_json('user', 'settings')
op.create_table(
"api_key",
sa.Column("id", sa.Text(), primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")),
sa.Column("key", sa.Text(), unique=True, nullable=False),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("expires_at", sa.BigInteger(), nullable=True),
sa.Column("last_used_at", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
'api_key',
sa.Column('id', sa.Text(), primary_key=True, unique=True),
sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')),
sa.Column('key', sa.Text(), unique=True, nullable=False),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.BigInteger(), nullable=True),
sa.Column('last_used_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
conn = op.get_bind()
users = conn.execute(
sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')
).fetchall()
users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall()
for uid, oauth_sub in users:
if oauth_sub:
# Example formats supported:
# provider@sub
# plain sub (stored as {"oidc": {"sub": sub}})
if "@" in oauth_sub:
provider, sub = oauth_sub.split("@", 1)
if '@' in oauth_sub:
provider, sub = oauth_sub.split('@', 1)
else:
provider, sub = "oidc", oauth_sub
provider, sub = 'oidc', oauth_sub
oauth_json = json.dumps({provider: {"sub": sub}})
oauth_json = json.dumps({provider: {'sub': sub}})
conn.execute(
sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'),
{"oauth": oauth_json, "id": uid},
{'oauth': oauth_json, 'id': uid},
)
users_with_keys = conn.execute(
sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')
).fetchall()
users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall()
now = int(time.time())
for uid, api_key in users_with_keys:
@@ -178,72 +168,70 @@ def upgrade() -> None:
VALUES (:id, :user_id, :key, :created_at, :updated_at)
"""),
{
"id": f"key_{uid}",
"user_id": uid,
"key": api_key,
"created_at": now,
"updated_at": now,
'id': f'key_{uid}',
'user_id': uid,
'key': api_key,
'created_at': now,
'updated_at': now,
},
)
if conn.dialect.name == "sqlite":
_drop_sqlite_indexes_for_column("user", "api_key", conn)
_drop_sqlite_indexes_for_column("user", "oauth_sub", conn)
if conn.dialect.name == 'sqlite':
_drop_sqlite_indexes_for_column('user', 'api_key', conn)
_drop_sqlite_indexes_for_column('user', 'oauth_sub', conn)
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("api_key")
batch_op.drop_column("oauth_sub")
with op.batch_alter_table('user') as batch_op:
batch_op.drop_column('api_key')
batch_op.drop_column('oauth_sub')
def downgrade() -> None:
# --- 1. Restore old oauth_sub column ---
op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True))
op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True))
conn = op.get_bind()
users = conn.execute(
sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')
).fetchall()
users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall()
for uid, oauth in users:
try:
data = json.loads(oauth)
provider = list(data.keys())[0]
sub = data[provider].get("sub")
oauth_sub = f"{provider}@{sub}"
sub = data[provider].get('sub')
oauth_sub = f'{provider}@{sub}'
except Exception:
oauth_sub = None
conn.execute(
sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'),
{"oauth_sub": oauth_sub, "id": uid},
{'oauth_sub': oauth_sub, 'id': uid},
)
op.drop_column("user", "oauth")
op.drop_column('user', 'oauth')
# --- 2. Restore api_key field ---
op.add_column("user", sa.Column("api_key", sa.String(), nullable=True))
op.add_column('user', sa.Column('api_key', sa.String(), nullable=True))
# Restore values from api_key
keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall()
keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall()
for uid, key in keys:
conn.execute(
sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'),
{"key": key, "id": uid},
{'key': key, 'id': uid},
)
# Drop new table
op.drop_table("api_key")
op.drop_table('api_key')
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("profile_banner_image_url")
batch_op.drop_column("timezone")
with op.batch_alter_table('user') as batch_op:
batch_op.drop_column('profile_banner_image_url')
batch_op.drop_column('timezone')
batch_op.drop_column("presence_state")
batch_op.drop_column("status_emoji")
batch_op.drop_column("status_message")
batch_op.drop_column("status_expires_at")
batch_op.drop_column('presence_state')
batch_op.drop_column('status_emoji')
batch_op.drop_column('status_message')
batch_op.drop_column('status_expires_at')
# Convert info (JSON) → TEXT
_convert_column_to_text("user", "info")
_convert_column_to_text('user', 'info')
# Convert settings (JSON) → TEXT
_convert_column_to_text("user", "settings")
_convert_column_to_text('user', 'settings')
@@ -12,15 +12,15 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b2c3d4e5f6a7"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
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))
op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True))
def downgrade() -> None:
op.drop_column("user", "scim")
op.drop_column('user', 'scim')
@@ -0,0 +1,27 @@
"""add last_read_at to chat
Revision ID: b7c8d9e0f1a2
Revises: d4e5f6a7b8c9
Create Date: 2026-04-01 04:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
def downgrade():
op.drop_column('chat', 'last_read_at')
@@ -12,21 +12,21 @@ import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c0fbf31ca0db"
down_revision: Union[str, None] = "ca81bd47c050"
revision: str = 'c0fbf31ca0db'
down_revision: Union[str, None] = 'ca81bd47c050'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("file", sa.Column("hash", sa.Text(), nullable=True))
op.add_column("file", sa.Column("data", sa.JSON(), nullable=True))
op.add_column("file", sa.Column("updated_at", sa.BigInteger(), nullable=True))
op.add_column('file', sa.Column('hash', sa.Text(), nullable=True))
op.add_column('file', sa.Column('data', sa.JSON(), nullable=True))
op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True))
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("file", "updated_at")
op.drop_column("file", "data")
op.drop_column("file", "hash")
op.drop_column('file', 'updated_at')
op.drop_column('file', 'data')
op.drop_column('file', 'hash')
@@ -0,0 +1,164 @@
"""Add shared_chat table and migrate existing shares
Revision ID: c1d2e3f4a5b6
Revises: e1f2a3b4c5d6
Create Date: 2026-04-16 23:00:00.000000
"""
import time
import uuid
from alembic import op
import sqlalchemy as sa
revision = 'c1d2e3f4a5b6'
down_revision = 'e1f2a3b4c5d6'
branch_labels = None
depends_on = None
# Lightweight table references for data migration (no ORM models needed)
chat_t = sa.table(
'chat',
sa.column('id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('title', sa.Text),
sa.column('chat', sa.JSON),
sa.column('share_id', sa.Text),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
sa.column('archived', sa.Boolean),
sa.column('meta', sa.JSON),
)
shared_chat_t = sa.table(
'shared_chat',
sa.column('id', sa.Text),
sa.column('chat_id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('title', sa.Text),
sa.column('chat', sa.JSON),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
)
chat_message_t = sa.table(
'chat_message',
sa.column('chat_id', sa.Text),
)
access_grant_t = sa.table(
'access_grant',
sa.column('id', sa.Text),
sa.column('resource_type', sa.Text),
sa.column('resource_id', sa.Text),
sa.column('principal_type', sa.Text),
sa.column('principal_id', sa.Text),
sa.column('permission', sa.Text),
sa.column('created_at', sa.BigInteger),
)
def upgrade():
conn = op.get_bind()
# 1. Create shared_chat table
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 2. Migrate existing shared-* rows
shared_rows = conn.execute(
sa.select(
chat_t.c.id,
chat_t.c.user_id,
chat_t.c.title,
chat_t.c.chat,
chat_t.c.created_at,
chat_t.c.updated_at,
).where(chat_t.c.user_id.like('shared-%'))
).fetchall()
for row in shared_rows:
share_token = row.id
original_chat_id = row.user_id.replace('shared-', '', 1)
# Verify original chat still exists
original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone()
if not original:
continue
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
)
# 3. Clean up old phantom rows
conn.execute(
chat_message_t.delete().where(
chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')))
)
)
conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%')))
def downgrade():
conn = op.get_bind()
shared_rows = conn.execute(
sa.select(
shared_chat_t.c.id,
shared_chat_t.c.chat_id,
shared_chat_t.c.user_id,
shared_chat_t.c.title,
shared_chat_t.c.chat,
shared_chat_t.c.created_at,
shared_chat_t.c.updated_at,
)
).fetchall()
for row in shared_rows:
conn.execute(
chat_t.insert().values(
id=row.id,
user_id=f'shared-{row.chat_id}',
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
archived=False,
meta={},
)
)
conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat'))
op.drop_table('shared_chat')
@@ -12,35 +12,33 @@ import json
from sqlalchemy.sql import table, column
from sqlalchemy import String, Text, JSON, and_
revision = "c29facfe716b"
down_revision = "c69f45358db4"
revision = 'c29facfe716b'
down_revision = 'c69f45358db4'
branch_labels = None
depends_on = None
def upgrade():
# 1. Add the `path` column to the "file" table.
op.add_column("file", sa.Column("path", sa.Text(), nullable=True))
op.add_column('file', sa.Column('path', sa.Text(), nullable=True))
# 2. Convert the `meta` column from Text/JSONField to `JSON()`
# Use Alembic's default batch_op for dialect compatibility.
with op.batch_alter_table("file", schema=None) as batch_op:
with op.batch_alter_table('file', schema=None) as batch_op:
batch_op.alter_column(
"meta",
'meta',
type_=sa.JSON(),
existing_type=sa.Text(),
existing_nullable=True,
nullable=True,
postgresql_using="meta::json",
postgresql_using='meta::json',
)
# 3. Migrate legacy data from `meta` JSONField
# Fetch and process `meta` data from the table, add values to the new `path` column as necessary.
# We will use SQLAlchemy core bindings to ensure safety across different databases.
file_table = table(
"file", column("id", String), column("meta", JSON), column("path", Text)
)
file_table = table('file', column('id', String), column('meta', JSON), column('path', Text))
# Create connection to the database
connection = op.get_bind()
@@ -55,24 +53,18 @@ def upgrade():
# Iterate over each row to extract and update the `path` from `meta` column
for row in results:
if "path" in row.meta:
if 'path' in row.meta:
# Extract the `path` field from the `meta` JSON
path = row.meta.get("path")
path = row.meta.get('path')
# Update the `file` table with the new `path` value
connection.execute(
file_table.update()
.where(file_table.c.id == row.id)
.values({"path": path})
)
connection.execute(file_table.update().where(file_table.c.id == row.id).values({'path': path}))
def downgrade():
# 1. Remove the `path` column
op.drop_column("file", "path")
op.drop_column('file', 'path')
# 2. Revert the `meta` column back to Text/JSONField
with op.batch_alter_table("file", schema=None) as batch_op:
batch_op.alter_column(
"meta", type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True
)
with op.batch_alter_table('file', schema=None) as batch_op:
batch_op.alter_column('meta', type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True)
@@ -12,45 +12,43 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c440947495f3"
down_revision: Union[str, None] = "81cc2ce44d79"
revision: str = 'c440947495f3'
down_revision: Union[str, None] = '81cc2ce44d79'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"chat_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
'chat_file',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column(
"chat_id",
'chat_id',
sa.Text(),
sa.ForeignKey("chat.id", ondelete="CASCADE"),
sa.ForeignKey('chat.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column(
"file_id",
'file_id',
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
sa.ForeignKey('file.id', ondelete='CASCADE'),
nullable=False,
),
sa.Column("message_id", sa.Text(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.Column('message_id', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_chat_file_chat_id", "chat_id"),
sa.Index("ix_chat_file_file_id", "file_id"),
sa.Index("ix_chat_file_message_id", "message_id"),
sa.Index("ix_chat_file_user_id", "user_id"),
sa.Index('ix_chat_file_chat_id', 'chat_id'),
sa.Index('ix_chat_file_file_id', 'file_id'),
sa.Index('ix_chat_file_message_id', 'message_id'),
sa.Index('ix_chat_file_user_id', 'user_id'),
# unique constraints
sa.UniqueConstraint(
"chat_id", "file_id", name="uq_chat_file_chat_file"
), # prevent duplicate entries
sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries
)
pass
def downgrade() -> None:
op.drop_table("chat_file")
op.drop_table('chat_file')
pass
@@ -9,42 +9,40 @@ Create Date: 2024-10-16 02:02:35.241684
from alembic import op
import sqlalchemy as sa
revision = "c69f45358db4"
down_revision = "3ab32c4b8f59"
revision = 'c69f45358db4'
down_revision = '3ab32c4b8f59'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"folder",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("parent_id", sa.Text(), nullable=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("items", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("is_expanded", sa.Boolean(), default=False, nullable=False),
'folder',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('parent_id', sa.Text(), nullable=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('items', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False
),
sa.Column(
"updated_at",
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
sa.PrimaryKeyConstraint("id", "user_id"),
sa.PrimaryKeyConstraint('id', 'user_id'),
)
op.add_column(
"chat",
sa.Column("folder_id", sa.Text(), nullable=True),
'chat',
sa.Column('folder_id', sa.Text(), nullable=True),
)
def downgrade():
op.drop_column("chat", "folder_id")
op.drop_column('chat', 'folder_id')
op.drop_table("folder")
op.drop_table('folder')
@@ -12,23 +12,21 @@ import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "ca81bd47c050"
down_revision: Union[str, None] = "7e5b5dc7342b"
revision: str = 'ca81bd47c050'
down_revision: Union[str, None] = '7e5b5dc7342b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade():
op.create_table(
"config",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("data", sa.JSON(), nullable=False),
sa.Column("version", sa.Integer, nullable=False),
'config',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('version', sa.Integer, nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column(
"created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at",
'updated_at',
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
@@ -38,4 +36,4 @@ def upgrade():
def downgrade():
op.drop_table("config")
op.drop_table('config')
@@ -9,15 +9,15 @@ Create Date: 2025-07-13 03:00:00.000000
from alembic import op
import sqlalchemy as sa
revision = "d31026856c01"
down_revision = "9f0c9cd09105"
revision = 'd31026856c01'
down_revision = '9f0c9cd09105'
branch_labels = None
depends_on = None
def upgrade():
op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True))
op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True))
def downgrade():
op.drop_column("folder", "data")
op.drop_column('folder', 'data')
@@ -0,0 +1,55 @@
"""add automation tables
Revision ID: d4e5f6a7b8c9
Revises: f1e2d3c4b5a6
Create Date: 2026-03-30
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = 'a3dd5bedd151'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'automation',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
op.create_table(
'automation_run',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('automation_id', sa.Text(), nullable=False),
sa.Column('chat_id', sa.Text(), nullable=True),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
op.create_index(
'ix_automation_run_automation_id',
'automation_run',
['automation_id'],
)
def downgrade():
op.drop_index('ix_automation_run_automation_id')
op.drop_table('automation_run')
op.drop_index('ix_automation_next_run')
op.drop_table('automation')
@@ -0,0 +1,23 @@
"""Add is_pinned to note table
Revision ID: e1f2a3b4c5d6
Revises: b7c8d9e0f1a2
Create Date: 2026-04-14 22:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'e1f2a3b4c5d6'
down_revision = 'b7c8d9e0f1a2'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('note', sa.Column('is_pinned', sa.Boolean(), nullable=True))
def downgrade():
op.drop_column('note', 'is_pinned')
@@ -20,8 +20,8 @@ import sqlalchemy as sa
from open_webui.migrations.util import get_existing_tables
revision: str = "f1e2d3c4b5a6"
down_revision: Union[str, None] = "8452d01d26d7"
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
@@ -30,34 +30,34 @@ def upgrade() -> None:
existing_tables = set(get_existing_tables())
# Create access_grant table
if "access_grant" not in existing_tables:
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),
'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",
'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"],
'idx_access_grant_resource',
'access_grant',
['resource_type', 'resource_id'],
)
op.create_index(
"idx_access_grant_principal",
"access_grant",
["principal_type", "principal_id"],
'idx_access_grant_principal',
'access_grant',
['principal_type', 'principal_id'],
)
# Backfill existing access_control JSON data
@@ -65,13 +65,13 @@ def upgrade() -> None:
# 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"),
('knowledge', 'knowledge'),
('prompt', 'prompt'),
('tool', 'tool'),
('model', 'model'),
('note', 'note'),
('channel', 'channel'),
('file', 'file'),
]
now = int(time.time())
@@ -83,9 +83,7 @@ def upgrade() -> None:
# Query all rows
try:
result = conn.execute(
sa.text(f'SELECT id, access_control FROM "{table_name}"')
)
result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"'))
rows = result.fetchall()
except Exception:
continue
@@ -99,19 +97,16 @@ def upgrade() -> None:
# 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"
)
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":
if resource_type == 'file':
continue # Private - no entry needed
key = (resource_type, resource_id, "user", "*", "read")
key = (resource_type, resource_id, 'user', '*', 'read')
if key not in inserted:
try:
conn.execute(
@@ -120,13 +115,13 @@ def upgrade() -> None:
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,
'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)
@@ -149,28 +144,24 @@ def upgrade() -> None:
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", {})
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", []
)
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"]:
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)
for group_id in perm_data.get('group_ids', []):
key = (resource_type, resource_id, 'group', group_id, permission)
if key in inserted:
continue
try:
@@ -180,21 +171,21 @@ def upgrade() -> None:
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,
'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)
for user_id in perm_data.get('user_ids', []):
key = (resource_type, resource_id, 'user', user_id, permission)
if key in inserted:
continue
try:
@@ -204,13 +195,13 @@ def upgrade() -> None:
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,
'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)
@@ -223,7 +214,7 @@ def upgrade() -> None:
continue
try:
with op.batch_alter_table(table_name) as batch:
batch.drop_column("access_control")
batch.drop_column('access_control')
except Exception:
pass
@@ -235,20 +226,20 @@ def downgrade() -> None:
# Resource tables mapping: (table_name, resource_type)
resource_tables = [
("knowledge", "knowledge"),
("prompt", "prompt"),
("tool", "tool"),
("model", "model"),
("note", "note"),
("channel", "channel"),
("file", "file"),
('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))
batch.add_column(sa.Column('access_control', sa.JSON(), nullable=True))
except Exception:
pass
@@ -262,7 +253,7 @@ def downgrade() -> None:
FROM access_grant
WHERE resource_type = :resource_type
"""),
{"resource_type": resource_type},
{'resource_type': resource_type},
)
rows = result.fetchall()
except Exception:
@@ -278,49 +269,35 @@ def downgrade() -> None:
if resource_id not in resource_grants:
resource_grants[resource_id] = {
"is_public": False,
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
'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
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
)
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"]:
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"]
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({})
@@ -328,17 +305,15 @@ def downgrade() -> None:
# Custom permissions
access_control_value = json.dumps(
{
"read": grants["read"],
"write": grants["write"],
'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},
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
@@ -346,7 +321,7 @@ def downgrade() -> None:
# 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":
if resource_type != 'file':
try:
conn.execute(
sa.text(f"""
@@ -357,13 +332,13 @@ def downgrade() -> None:
)
AND access_control IS NULL
"""),
{"private_value": json.dumps({}), "resource_type": resource_type},
{'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")
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')
+209 -227
View File
@@ -3,8 +3,9 @@ import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, UniqueConstraint, or_, and_
@@ -19,28 +20,24 @@ log = logging.getLogger(__name__)
class AccessGrant(Base):
__tablename__ = "access_grant"
__tablename__ = 'access_grant'
id = Column(Text, primary_key=True)
resource_type = Column(
Text, nullable=False
) # "knowledge", "model", "prompt", "tool", "note", "channel", "file"
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)
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",
'resource_type',
'resource_id',
'principal_type',
'principal_id',
'permission',
name='uq_access_grant_grant',
),
)
@@ -66,7 +63,7 @@ class AccessGrantResponse(BaseModel):
permission: str
@classmethod
def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse":
def from_grant(cls, grant: 'AccessGrantModel') -> 'AccessGrantResponse':
return cls(
id=grant.id,
principal_type=grant.principal_type,
@@ -100,14 +97,14 @@ def access_control_to_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":
if resource_type != 'file':
grants.append(
{
"resource_type": resource_type,
"resource_id": resource_id,
"principal_type": "user",
"principal_id": "*",
"permission": "read",
'resource_type': resource_type,
'resource_id': resource_id,
'principal_type': 'user',
'principal_id': '*',
'permission': 'read',
}
)
return grants
@@ -117,30 +114,30 @@ def access_control_to_grants(
return grants
# Parse structured permissions
for permission in ["read", "write"]:
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", []):
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,
'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", []):
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,
'resource_type': resource_type,
'resource_id': resource_id,
'principal_type': 'user',
'principal_id': user_id,
'permission': permission,
}
)
@@ -164,27 +161,23 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]:
if not isinstance(grant, dict):
continue
principal_type = grant.get("principal_type")
principal_id = grant.get("principal_id")
permission = grant.get("permission")
principal_type = grant.get('principal_type')
principal_id = grant.get('principal_id')
permission = grant.get('permission')
if principal_type not in ("user", "group"):
if principal_type not in ('user', 'group'):
continue
if permission not in ("read", "write"):
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,
'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())
@@ -195,11 +188,17 @@ 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"
):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read':
return True
return False
def has_public_write_access_grant(access_grants: Optional[list]) -> bool:
"""
Returns True when a direct grant list includes wildcard public-write.
"""
for grant in normalize_access_grants(access_grants):
if grant['principal_type'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'write':
return True
return False
@@ -209,7 +208,7 @@ def has_user_access_grant(access_grants: Optional[list]) -> bool:
Returns True when a direct grant list includes any non-wildcard user grant.
"""
for grant in normalize_access_grants(access_grants):
if grant["principal_type"] == "user" and grant["principal_id"] != "*":
if grant['principal_type'] == 'user' and grant['principal_id'] != '*':
return True
return False
@@ -225,18 +224,9 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list:
grant
for grant in access_grants
if not (
(
grant.get("principal_type")
if isinstance(grant, dict)
else getattr(grant, "principal_type", None)
)
== "user"
and (
grant.get("principal_id")
if isinstance(grant, dict)
else getattr(grant, "principal_id", None)
)
!= "*"
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
== 'user'
and (grant.get('principal_id') if isinstance(grant, dict) else getattr(grant, 'principal_id', None)) != '*'
)
]
@@ -260,29 +250,25 @@ def grants_to_access_control(grants: list) -> Optional[dict]:
return {} # No grants = private/owner-only
result = {
"read": {"group_ids": [], "user_ids": []},
"write": {"group_ids": [], "user_ids": []},
'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"
):
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"):
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 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
@@ -296,29 +282,28 @@ def grants_to_access_control(grants: list) -> Optional[dict]:
class AccessGrantsTable:
def grant_access(
async def grant_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[AccessGrantModel]:
"""Add a single access grant. Idempotent (ignores duplicates)."""
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# Check for existing grant
existing = (
db.query(AccessGrant)
.filter_by(
result = await db.execute(
select(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
principal_type=principal_type,
principal_id=principal_id,
permission=permission,
)
.first()
)
existing = result.scalars().first()
if existing:
return AccessGrantModel.model_validate(existing)
@@ -332,76 +317,72 @@ class AccessGrantsTable:
created_at=int(time.time()),
)
db.add(grant)
db.commit()
db.refresh(grant)
await db.commit()
await db.refresh(grant)
return AccessGrantModel.model_validate(grant)
def revoke_access(
async def revoke_access(
self,
resource_type: str,
resource_id: str,
principal_type: str,
principal_id: str,
permission: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> bool:
"""Remove a single access grant."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
async with get_async_db_context(db) as db:
result = await db.execute(
delete(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
await db.commit()
return result.rowcount > 0
def revoke_all_access(
async def revoke_all_access(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> int:
"""Remove all access grants for a resource."""
with get_db_context(db) as db:
deleted = (
db.query(AccessGrant)
.filter_by(
async with get_async_db_context(db) as db:
result = await db.execute(
delete(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.delete()
)
db.commit()
return deleted
await db.commit()
return result.rowcount
def set_access_control(
async def set_access_control(
self,
resource_type: str,
resource_id: str,
access_control: Optional[dict],
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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:
async with get_async_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()
await db.execute(
delete(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
)
# Convert JSON to grant dicts
grant_dicts = access_control_to_grants(
resource_type, resource_id, access_control
)
grant_dicts = access_control_to_grants(resource_type, resource_id, access_control)
# Insert new grants
results = []
@@ -414,25 +395,27 @@ class AccessGrantsTable:
db.add(grant)
results.append(grant)
db.commit()
await db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def set_access_grants(
async def set_access_grants(
self,
resource_type: str,
resource_id: str,
access_grants: Optional[list],
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
await db.execute(
delete(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
)
normalized_grants = normalize_access_grants(access_grants)
@@ -442,90 +425,85 @@ class AccessGrantsTable:
id=str(uuid.uuid4()),
resource_type=resource_type,
resource_id=resource_id,
principal_type=grant_dict["principal_type"],
principal_id=grant_dict["principal_id"],
permission=grant_dict["permission"],
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()
await db.commit()
return [AccessGrantModel.model_validate(g) for g in results]
def get_access_control(
async def get_access_control(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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(
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
grants = result.scalars().all()
grant_models = [AccessGrantModel.model_validate(g) for g in grants]
return grants_to_access_control(grant_models)
def get_grants_by_resource(
async def get_grants_by_resource(
self,
resource_type: str,
resource_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[AccessGrantModel]:
"""Get all grants for a specific resource."""
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter_by(
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
)
.all()
)
grants = result.scalars().all()
return [AccessGrantModel.model_validate(g) for g in grants]
def get_grants_by_resources(
async def get_grants_by_resources(
self,
resource_type: str,
resource_ids: list[str],
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, list[AccessGrantModel]]:
"""Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}."""
if not resource_ids:
return {}
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter(
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant).filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id.in_(resource_ids),
)
.all()
)
result: dict[str, list[AccessGrantModel]] = {
rid: [] for rid in resource_ids
}
grants = result.scalars().all()
result_dict: dict[str, list[AccessGrantModel]] = {rid: [] for rid in resource_ids}
for g in grants:
result[g.resource_id].append(AccessGrantModel.model_validate(g))
return result
result_dict[g.resource_id].append(AccessGrantModel.model_validate(g))
return result_dict
def has_access(
async def has_access(
self,
user_id: str,
resource_type: str,
resource_id: str,
permission: str = "read",
permission: str = 'read',
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> bool:
"""
Check if a user has the specified permission on a resource.
@@ -535,17 +513,17 @@ class AccessGrantsTable:
- 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:
async with get_async_db_context(db) as db:
# Build conditions for matching grants
conditions = [
# Public access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
# Direct user access
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == user_id,
),
]
@@ -554,37 +532,38 @@ class AccessGrantsTable:
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_groups = await 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_type == 'group',
AccessGrant.principal_id.in_(user_group_ids),
)
)
exists = (
db.query(AccessGrant)
result = await db.execute(
select(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == resource_id,
AccessGrant.permission == permission,
or_(*conditions),
)
.first()
.limit(1)
)
return exists is not None
grant = result.scalars().first()
return grant is not None
def get_accessible_resource_ids(
async def get_accessible_resource_ids(
self,
user_id: str,
resource_type: str,
resource_ids: list[str],
permission: str = "read",
permission: str = 'read',
user_group_ids: Optional[set[str]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> set[str]:
"""
Batch check: return the subset of resource_ids that the user can access.
@@ -594,14 +573,14 @@ class AccessGrantsTable:
if not resource_ids:
return set()
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
conditions = [
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == user_id,
),
]
@@ -609,19 +588,19 @@ class AccessGrantsTable:
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_groups = await 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_type == 'group',
AccessGrant.principal_id.in_(user_group_ids),
)
)
rows = (
db.query(AccessGrant.resource_id)
result = await db.execute(
select(AccessGrant.resource_id)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id.in_(resource_ids),
@@ -629,16 +608,16 @@ class AccessGrantsTable:
or_(*conditions),
)
.distinct()
.all()
)
rows = result.all()
return {row[0] for row in rows}
def get_users_with_access(
async def get_users_with_access(
self,
resource_type: str,
resource_id: str,
permission: str = "read",
db: Optional[Session] = None,
permission: str = 'read',
db: Optional[AsyncSession] = None,
) -> list:
"""
Get all users who have the specified permission on a resource.
@@ -647,39 +626,36 @@ class AccessGrantsTable:
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(
async with get_async_db_context(db) as db:
result = await db.execute(
select(AccessGrant).filter_by(
resource_type=resource_type,
resource_id=resource_id,
permission=permission,
)
.all()
)
grants = result.scalars().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", [])
if grant.principal_type == 'user' and grant.principal_id == '*':
result = await 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":
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
)
elif grant.principal_type == 'group':
group_user_ids = await 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)
return await Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
def has_permission_filter(
self,
@@ -688,20 +664,22 @@ class AccessGrantsTable:
DocumentModel,
filter: dict,
resource_type: str,
permission: str = "read",
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
)
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
so it remains synchronous. The caller is responsible for executing the query
asynchronously with `await db.execute(...)`.
"""
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 = []
@@ -710,8 +688,8 @@ class AccessGrantsTable:
# Public access: user:* read
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
)
)
@@ -722,7 +700,7 @@ class AccessGrantsTable:
# Direct user grant
principal_conditions.append(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == user_id,
)
)
@@ -731,7 +709,7 @@ class AccessGrantsTable:
# Group grants
principal_conditions.append(
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_type == 'group',
AccessGrant.principal_id.in_(group_ids),
)
)
@@ -741,7 +719,7 @@ class AccessGrantsTable:
# 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
from sqlalchemy import exists as sa_exists
grant_exists = (
select(AccessGrant.id)
@@ -751,13 +729,13 @@ class AccessGrantsTable:
AccessGrant.permission == permission,
or_(
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == user_id,
)
]
@@ -767,7 +745,7 @@ class AccessGrantsTable:
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_type == 'group',
AccessGrant.principal_id.in_(group_ids),
)
]
@@ -799,11 +777,15 @@ class AccessGrantsTable:
"""
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
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
so it remains synchronous. The caller is responsible for executing the query
asynchronously with `await db.execute(...)`.
"""
group_ids = filter.get('group_ids', [])
user_id = filter.get('user_id')
from sqlalchemy import exists as sa_exists
# Has read grant (not public)
read_grant_exists = (
@@ -811,12 +793,12 @@ class AccessGrantsTable:
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
AccessGrant.permission == 'read',
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == user_id,
)
]
@@ -826,7 +808,7 @@ class AccessGrantsTable:
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_type == 'group',
AccessGrant.principal_id.in_(group_ids),
)
]
@@ -845,12 +827,12 @@ class AccessGrantsTable:
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "write",
AccessGrant.permission == 'write',
or_(
*(
[
and_(
AccessGrant.principal_type == "user",
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == user_id,
)
]
@@ -860,7 +842,7 @@ class AccessGrantsTable:
*(
[
and_(
AccessGrant.principal_type == "group",
AccessGrant.principal_type == 'group',
AccessGrant.principal_id.in_(group_ids),
)
]
@@ -879,9 +861,9 @@ class AccessGrantsTable:
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == "read",
AccessGrant.principal_type == "user",
AccessGrant.principal_id == "*",
AccessGrant.permission == 'read',
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
)
.correlate(DocumentModel)
.exists()
+52 -64
View File
@@ -2,8 +2,9 @@ import logging
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_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
@@ -17,7 +18,7 @@ log = logging.getLogger(__name__)
class Auth(Base):
__tablename__ = "auth"
__tablename__ = 'auth'
id = Column(String, primary_key=True, unique=True)
email = Column(String)
@@ -73,9 +74,9 @@ class SignupForm(BaseModel):
name: str
email: str
password: str
profile_image_url: Optional[str] = "/user.png"
profile_image_url: Optional[str] = '/user.png'
@field_validator("profile_image_url")
@field_validator('profile_image_url')
@classmethod
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
if v is not None:
@@ -84,55 +85,52 @@ class SignupForm(BaseModel):
class AddUserForm(SignupForm):
role: Optional[str] = "pending"
role: Optional[str] = 'pending'
class AuthsTable:
def insert_new_auth(
async def insert_new_auth(
self,
email: str,
password: str,
name: str,
profile_image_url: str = "/user.png",
role: str = "pending",
profile_image_url: str = '/user.png',
role: str = 'pending',
oauth: Optional[dict] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[UserModel]:
with get_db_context(db) as db:
log.info("insert_new_auth")
async with get_async_db_context(db) as db:
log.info('insert_new_auth')
id = str(uuid.uuid4())
auth = AuthModel(
**{"id": id, "email": email, "password": password, "active": True}
)
auth = AuthModel(**{'id': id, 'email': email, 'password': password, 'active': True})
result = Auth(**auth.model_dump())
db.add(result)
user = Users.insert_new_user(
id, name, email, profile_image_url, role, oauth=oauth, db=db
)
user = await Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result and user:
return user
else:
return None
def authenticate_user(
self, email: str, verify_password: callable, db: Optional[Session] = None
async def authenticate_user(
self, email: str, verify_password: callable, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}")
log.info(f'authenticate_user: {email}')
user = Users.get_user_by_email(email, db=db)
user = await Users.get_user_by_email(email, db=db)
if not user:
return None
try:
with get_db_context(db) as db:
auth = db.query(Auth).filter_by(id=user.id, active=True).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Auth).filter_by(id=user.id, active=True))
auth = result.scalars().first()
if auth:
if verify_password(auth.password):
return user
@@ -143,76 +141,66 @@ class AuthsTable:
except Exception:
return None
def authenticate_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
async def authenticate_user_by_api_key(
self, api_key: str, db: Optional[AsyncSession] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_api_key")
log.info(f'authenticate_user_by_api_key')
# if no api_key, return None
if not api_key:
return None
try:
user = Users.get_user_by_api_key(api_key, db=db)
user = await 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, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_email: {email}")
async def authenticate_user_by_email(self, email: str, db: Optional[AsyncSession] = None) -> Optional[UserModel]:
log.info(f'authenticate_user_by_email: {email}')
try:
with get_db_context(db) as db:
async with get_async_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()
result = await db.execute(
select(Auth, User).join(User, Auth.id == User.id).filter(Auth.email == email, Auth.active == True)
)
if result:
_, user = result
row = result.first()
if row:
_, user = row
return UserModel.model_validate(user)
return None
except Exception:
return None
def update_user_password_by_id(
self, id: str, new_password: str, db: Optional[Session] = None
) -> bool:
async def update_user_password_by_id(self, id: str, new_password: str, db: Optional[AsyncSession] = None) -> bool:
try:
with get_db_context(db) as db:
result = (
db.query(Auth).filter_by(id=id).update({"password": new_password})
)
db.commit()
return True if result == 1 else False
async with get_async_db_context(db) as db:
result = await db.execute(update(Auth).filter_by(id=id).values(password=new_password))
await db.commit()
return True if result.rowcount == 1 else False
except Exception:
return False
def update_email_by_id(
self, id: str, email: str, db: Optional[Session] = None
) -> bool:
async def update_email_by_id(self, id: str, email: str, db: Optional[AsyncSession] = None) -> bool:
try:
with get_db_context(db) as db:
result = db.query(Auth).filter_by(id=id).update({"email": email})
db.commit()
if result == 1:
Users.update_user_by_id(id, {"email": email}, db=db)
async with get_async_db_context(db) as db:
result = await db.execute(update(Auth).filter_by(id=id).values(email=email))
await db.commit()
if result.rowcount == 1:
await Users.update_user_by_id(id, {'email': email}, db=db)
return True
return False
except Exception:
return False
def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool:
async def delete_auth_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# Delete User
result = Users.delete_user_by_id(id, db=db)
result = await Users.delete_user_by_id(id, db=db)
if result:
db.query(Auth).filter_by(id=id).delete()
db.commit()
await db.execute(delete(Auth).filter_by(id=id))
await db.commit()
return True
else:
+421
View File
@@ -0,0 +1,421 @@
import time
import logging
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
log = logging.getLogger(__name__)
####################
# Automation DB Schema
####################
class Automation(Base):
__tablename__ = 'automation'
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
name = Column(Text, nullable=False)
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
meta = Column(JSON, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
last_run_at = Column(BigInteger, nullable=True)
next_run_at = Column(BigInteger, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
class AutomationRun(Base):
__tablename__ = 'automation_run'
id = Column(Text, primary_key=True)
automation_id = Column(Text, nullable=False)
chat_id = Column(Text, nullable=True)
status = Column(Text, nullable=False) # success | error
error = Column(Text, nullable=True)
created_at = Column(BigInteger, nullable=False)
__table_args__ = (
Index('ix_automation_run_automation_id', 'automation_id'),
Index('ix_automation_run_aid_created', 'automation_id', 'created_at'),
)
####################
# Pydantic Models
####################
class AutomationTerminalConfig(BaseModel):
server_id: str
cwd: Optional[str] = None
class AutomationData(BaseModel):
prompt: str
model_id: str
rrule: str
terminal: Optional[AutomationTerminalConfig] = None
class AutomationModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
name: str
data: dict
meta: Optional[dict] = None
is_active: bool
last_run_at: Optional[int] = None
next_run_at: Optional[int] = None
created_at: int
updated_at: int
class AutomationRunModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
automation_id: str
chat_id: Optional[str] = None
status: str
error: Optional[str] = None
created_at: int
class AutomationForm(BaseModel):
name: str
data: AutomationData
meta: Optional[dict] = None
is_active: Optional[bool] = True
class AutomationResponse(AutomationModel):
last_run: Optional[AutomationRunModel] = None
next_runs: Optional[list[int]] = None
class AutomationListResponse(BaseModel):
items: list[AutomationModel]
total: int
####################
# AutomationTable
####################
class AutomationTable:
async def insert(
self,
user_id: str,
form: AutomationForm,
next_run_at: int,
db: Optional[AsyncSession] = None,
) -> AutomationModel:
async with get_async_db_context(db) as db:
now = int(time.time_ns())
row = Automation(
id=str(uuid4()),
user_id=user_id,
name=form.name,
data=form.data.model_dump(),
meta=form.meta,
is_active=form.is_active,
next_run_at=next_run_at,
created_at=now,
updated_at=now,
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def count_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> int:
async with get_async_db_context(db) as db:
result = await db.execute(select(func.count()).select_from(Automation).filter_by(user_id=user_id))
return result.scalar()
async def get_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationModel]:
async with get_async_db_context(db) as db:
row = await db.get(Automation, id)
return AutomationModel.model_validate(row) if row else None
async def get_active_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[AutomationModel]:
"""Get active automations for a user (for calendar RRULE expansion)."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Automation).filter_by(user_id=user_id, is_active=True).order_by(Automation.created_at.desc())
)
return [AutomationModel.model_validate(r) for r in result.scalars().all()]
async def search_automations(
self,
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
) -> 'AutomationListResponse':
async with get_async_db_context(db) as db:
stmt = select(Automation).filter_by(user_id=user_id)
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
stmt = stmt.filter(
or_(
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
)
)
if status == 'active':
stmt = stmt.filter(Automation.is_active == True)
elif status == 'paused':
stmt = stmt.filter(Automation.is_active == False)
stmt = stmt.order_by(Automation.created_at.desc())
# Get total count
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
rows = result.scalars().all()
return AutomationListResponse(
items=[AutomationModel.model_validate(r) for r in rows],
total=total,
)
async def update_by_id(
self,
id: str,
form: AutomationForm,
next_run_at: int,
db: Optional[AsyncSession] = None,
) -> Optional[AutomationModel]:
async with get_async_db_context(db) as db:
row = await db.get(Automation, id)
if not row:
return None
row.name = form.name
row.data = form.data.model_dump()
row.meta = form.meta
if form.is_active is not None:
row.is_active = form.is_active
row.next_run_at = next_run_at
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def toggle(
self,
id: str,
next_run_at: Optional[int],
db: Optional[AsyncSession] = None,
) -> Optional[AutomationModel]:
async with get_async_db_context(db) as db:
row = await db.get(Automation, id)
if not row:
return None
row.is_active = not row.is_active
row.next_run_at = next_run_at if row.is_active else None
row.updated_at = int(time.time_ns())
await db.commit()
await db.refresh(row)
return AutomationModel.model_validate(row)
async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
row = await db.get(Automation, id)
if not row:
return False
await db.delete(row)
await db.commit()
return True
async def claim_due(self, now_ns: int, limit: int = 10, db: Optional[AsyncSession] = None) -> list[AutomationModel]:
"""
Atomically claim due automations for execution.
Advances next_run_at immediately so the row can never be
double-claimed. On PostgreSQL, uses FOR UPDATE SKIP LOCKED
for zero-contention distributed work claiming.
"""
async with get_async_db_context(db) as db:
stmt = (
select(Automation)
.where(
Automation.is_active == True,
Automation.next_run_at <= now_ns,
)
.order_by(Automation.next_run_at)
.limit(limit)
)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update(skip_locked=True)
result = await db.execute(stmt)
rows = result.scalars().all()
from open_webui.utils.automations import next_run_ns
# Batch-fetch user timezones so rescheduling respects each
# user's local timezone instead of falling back to server time.
user_ids = list({row.user_id for row in rows})
timezone_by_user_id: dict[str, Optional[str]] = {}
if user_ids:
from open_webui.models.users import User
tz_result = await db.execute(select(User.id, User.timezone).where(User.id.in_(user_ids)))
timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()}
for row in rows:
row.last_run_at = now_ns
row.next_run_at = next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id))
await db.commit()
return [AutomationModel.model_validate(r) for r in rows]
####################
# AutomationRunTable
####################
class AutomationRunTable:
async def insert(
self,
automation_id: str,
status: str,
chat_id: Optional[str] = None,
error: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> AutomationRunModel:
async with get_async_db_context(db) as db:
row = AutomationRun(
id=str(uuid4()),
automation_id=automation_id,
chat_id=chat_id,
status=status,
error=error,
created_at=int(time.time_ns()),
)
db.add(row)
await db.commit()
await db.refresh(row)
return AutomationRunModel.model_validate(row)
async def get_latest(self, automation_id: str, db: Optional[AsyncSession] = None) -> Optional[AutomationRunModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(AutomationRun)
.filter_by(automation_id=automation_id)
.order_by(AutomationRun.created_at.desc())
.limit(1)
)
row = result.scalars().first()
return AutomationRunModel.model_validate(row) if row else None
async def get_latest_batch(
self, automation_ids: list[str], db: Optional[AsyncSession] = None
) -> dict[str, AutomationRunModel]:
"""Fetch the latest run for each automation in a single query."""
if not automation_ids:
return {}
async with get_async_db_context(db) as db:
# Subquery: max created_at per automation_id
subq = (
select(
AutomationRun.automation_id,
func.max(AutomationRun.created_at).label('max_created'),
)
.filter(AutomationRun.automation_id.in_(automation_ids))
.group_by(AutomationRun.automation_id)
.subquery()
)
result = await db.execute(
select(AutomationRun).join(
subq,
(AutomationRun.automation_id == subq.c.automation_id)
& (AutomationRun.created_at == subq.c.max_created),
)
)
rows = result.scalars().all()
return {row.automation_id: AutomationRunModel.model_validate(row) for row in rows}
async def get_by_automation(
self,
automation_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> list[AutomationRunModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(AutomationRun)
.filter_by(automation_id=automation_id)
.order_by(AutomationRun.created_at.desc())
.offset(skip)
.limit(limit)
)
rows = result.scalars().all()
return [AutomationRunModel.model_validate(r) for r in rows]
async def delete_by_automation(self, automation_id: str, db: Optional[AsyncSession] = None) -> int:
async with get_async_db_context(db) as db:
result = await db.execute(delete(AutomationRun).filter_by(automation_id=automation_id))
await db.commit()
return result.rowcount
async def get_runs_by_user_range(
self,
user_id: str,
start_ns: int,
end_ns: int,
limit: int = 500,
db: Optional[AsyncSession] = None,
) -> list[tuple['AutomationRunModel', 'AutomationModel']]:
"""Get runs within a date range for a user, joined with parent automation."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(AutomationRun, Automation)
.join(Automation, Automation.id == AutomationRun.automation_id)
.filter(
Automation.user_id == user_id,
AutomationRun.created_at >= start_ns,
AutomationRun.created_at < end_ns,
)
.order_by(AutomationRun.created_at.desc())
.limit(limit)
)
return [
(AutomationRunModel.model_validate(run), AutomationModel.model_validate(auto))
for run, auto in result.all()
]
Automations = AutomationTable()
AutomationRuns = AutomationRunTable()
+824
View File
@@ -0,0 +1,824 @@
import time
import logging
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
Column,
Text,
JSON,
Boolean,
BigInteger,
Index,
UniqueConstraint,
select,
or_,
exists,
func,
delete,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
log = logging.getLogger(__name__)
####################
# Calendar DB Schema
####################
class Calendar(Base):
__tablename__ = 'calendar'
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
name = Column(Text, nullable=False)
color = Column(Text, nullable=True)
is_default = Column(Boolean, nullable=False, default=False)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_calendar_user', 'user_id'),)
class CalendarEvent(Base):
__tablename__ = 'calendar_event'
id = Column(Text, primary_key=True)
calendar_id = Column(Text, nullable=False)
user_id = Column(Text, nullable=False)
title = Column(Text, nullable=False)
description = Column(Text, nullable=True)
start_at = Column(BigInteger, nullable=False)
end_at = Column(BigInteger, nullable=True)
all_day = Column(Boolean, nullable=False, default=False)
rrule = Column(Text, nullable=True)
color = Column(Text, nullable=True)
location = Column(Text, nullable=True)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
is_cancelled = Column(Boolean, nullable=False, default=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'),
Index('ix_calendar_event_user_date', 'user_id', 'start_at'),
)
class CalendarEventAttendee(Base):
__tablename__ = 'calendar_event_attendee'
id = Column(Text, primary_key=True)
event_id = Column(Text, nullable=False)
user_id = Column(Text, nullable=False)
status = Column(Text, nullable=False, default='pending')
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
Index('ix_calendar_event_attendee_user', 'user_id', 'status'),
)
####################
# Pydantic Models
####################
class CalendarModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
name: str
color: Optional[str] = None
is_default: bool = False
is_system: bool = False
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int
updated_at: int
class CalendarEventModel(BaseModel):
model_config = ConfigDict(from_attributes=True, extra='allow')
id: str
calendar_id: str
user_id: str
title: str
description: Optional[str] = None
start_at: int
end_at: Optional[int] = None
all_day: bool = False
rrule: Optional[str] = None
color: Optional[str] = None
location: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
is_cancelled: bool = False
attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list)
created_at: int
updated_at: int
class CalendarEventAttendeeModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
event_id: str
user_id: str
status: str = 'pending'
meta: Optional[dict] = None
created_at: int
updated_at: int
####################
# Forms
####################
class CalendarForm(BaseModel):
name: str
color: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class CalendarUpdateForm(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class CalendarEventForm(BaseModel):
calendar_id: str
title: str
description: Optional[str] = None
start_at: int
end_at: Optional[int] = None
all_day: bool = False
rrule: Optional[str] = None
color: Optional[str] = None
location: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
attendees: Optional[list[dict]] = None
class CalendarEventUpdateForm(BaseModel):
calendar_id: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
start_at: Optional[int] = None
end_at: Optional[int] = None
all_day: Optional[bool] = None
rrule: Optional[str] = None
color: Optional[str] = None
location: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
is_cancelled: Optional[bool] = None
attendees: Optional[list[dict]] = None
class RSVPForm(BaseModel):
status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
####################
# Response Models
####################
class CalendarEventUserResponse(CalendarEventModel):
user: Optional[UserResponse] = None
class CalendarEventListResponse(BaseModel):
items: list[CalendarEventUserResponse]
total: int
####################
# Table Operations
####################
class CalendarTable:
async def _get_access_grants(self, calendar_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db)
async def _to_calendar_model(
self,
cal: Calendar,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarModel:
cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'})
cal_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db)
)
return CalendarModel.model_validate(cal_data)
async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Return user's calendars, creating 'Personal' default if none exist."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc())
)
calendars = result.scalars().all()
if calendars:
return [CalendarModel.model_validate(c) for c in calendars]
now = int(time.time_ns())
cal = Calendar(
id=str(uuid4()),
user_id=user_id,
name='Personal',
color='#3b82f6',
is_default=True,
created_at=now,
updated_at=now,
)
db.add(cal)
await db.commit()
return [CalendarModel.model_validate(cal)]
async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Owned + shared calendars."""
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [g.id for g in user_groups]
stmt = select(Calendar)
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
DocumentModel=Calendar,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='calendar',
permission='read',
)
stmt = stmt.order_by(Calendar.created_at.asc())
result = await db.execute(stmt)
calendars = result.scalars().all()
if not calendars:
return await self.get_or_create_defaults(user_id, db=db)
cal_ids = [c.id for c in calendars]
grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db)
return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars]
async def get_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Calendar).filter(Calendar.id == id))
cal = result.scalars().first()
return await self._to_calendar_model(cal, db=db) if cal else None
async def insert_new_calendar(
self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarModel]:
async with get_async_db_context(db) as db:
now = int(time.time_ns())
cal = Calendar(
id=str(uuid4()),
user_id=user_id,
name=form_data.name,
color=form_data.color,
is_default=False,
data=form_data.data,
meta=form_data.meta,
created_at=now,
updated_at=now,
)
db.add(cal)
await db.commit()
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db)
return await self._to_calendar_model(cal, db=db)
async def update_calendar_by_id(
self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Calendar).filter(Calendar.id == id))
cal = result.scalars().first()
if not cal:
return None
update_data = form_data.model_dump(exclude_unset=True)
if 'name' in update_data:
cal.name = update_data['name']
if 'color' in update_data:
cal.color = update_data['color']
if 'data' in update_data:
cal.data = {**(cal.data or {}), **update_data['data']}
if 'meta' in update_data:
cal.meta = {**(cal.meta or {}), **update_data['meta']}
if 'access_grants' in update_data:
await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db)
cal.updated_at = int(time.time_ns())
await db.commit()
return await self._to_calendar_model(cal, db=db)
async def set_default_calendar(
self, user_id: str, calendar_id: str, db: Optional[AsyncSession] = None
) -> Optional[CalendarModel]:
"""Set a calendar as the user's default, clearing all others."""
async with get_async_db_context(db) as db:
# Clear all defaults for this user
await db.execute(
update(Calendar)
.where(Calendar.user_id == user_id, Calendar.is_default == True)
.values(is_default=False)
)
# Set the new default
result = await db.execute(select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id))
cal = result.scalars().first()
if not cal:
return None
cal.is_default = True
cal.updated_at = int(time.time_ns())
await db.commit()
return await self._to_calendar_model(cal, db=db)
async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete a non-default calendar. Cascades to events, attendees, and grants."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Calendar).filter(Calendar.id == id))
cal = result.scalars().first()
if not cal or cal.is_default:
return False
# Delete attendees for all events in this calendar
event_ids_result = await db.execute(select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id))
event_ids = [r[0] for r in event_ids_result.all()]
if event_ids:
await db.execute(
delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
)
# Delete events
await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id))
# Delete calendar
await db.execute(delete(Calendar).filter(Calendar.id == id))
await db.commit()
# Revoke access grants in a separate transaction to avoid
# write-lock contention on SQLite when session sharing is off.
await AccessGrants.revoke_all_access('calendar', id)
return True
except Exception as e:
log.exception(f'Failed to delete calendar {id}: {e}')
return False
class CalendarEventTable:
async def _get_attendees(
self, event_id: str, db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
rows = result.scalars().all()
return [CalendarEventAttendeeModel.model_validate(r) for r in rows]
async def _to_event_model(
self,
event: CalendarEvent,
attendees: Optional[list[CalendarEventAttendeeModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarEventModel:
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
event_data['attendees'] = (
attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db)
)
return CalendarEventModel.model_validate(event_data)
async def insert_new_event(
self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarEventModel]:
async with get_async_db_context(db) as db:
now = int(time.time_ns())
event = CalendarEvent(
id=str(uuid4()),
calendar_id=form_data.calendar_id,
user_id=user_id,
title=form_data.title,
description=form_data.description,
start_at=form_data.start_at,
end_at=form_data.end_at,
all_day=form_data.all_day,
rrule=form_data.rrule,
color=form_data.color,
location=form_data.location,
data=form_data.data,
meta=form_data.meta,
is_cancelled=False,
created_at=now,
updated_at=now,
)
db.add(event)
await db.commit()
# Add attendees
if form_data.attendees:
await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db)
return await self._to_event_model(event, db=db)
async def get_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarEventModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
event = result.scalars().first()
return await self._to_event_model(event, db=db) if event else None
async def get_events_by_range(
self,
user_id: str,
start: int,
end: int,
calendar_ids: Optional[list[str]] = None,
db: Optional[AsyncSession] = None,
) -> list[CalendarEventUserResponse]:
"""Fetch events visible to user within a date range.
Visible events = events in owned/shared calendars + events user attends.
Recurring events are fetched if they have any rrule (expansion in Python).
"""
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [g.id for g in user_groups]
# Get calendar IDs accessible to user
cal_stmt = select(Calendar.id)
cal_stmt = AccessGrants.has_permission_filter(
db=db,
query=cal_stmt,
DocumentModel=Calendar,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='calendar',
permission='read',
)
cal_result = await db.execute(cal_stmt)
accessible_cal_ids = [r[0] for r in cal_result.all()]
if calendar_ids:
# Filter to requested calendars only
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
# Also get event IDs where user is an attendee
attendee_event_ids_result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
)
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
# Build conditions for accessible events
conditions = []
if accessible_cal_ids:
conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids))
if attendee_event_ids:
conditions.append(CalendarEvent.id.in_(attendee_event_ids))
if not conditions:
return []
# Build event query
stmt = (
select(CalendarEvent, User)
.outerjoin(User, User.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
or_(*conditions),
or_(
# Non-recurring: overlaps the range
(
CalendarEvent.rrule.is_(None)
& (CalendarEvent.start_at < end)
& or_(
CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start),
)
),
# Recurring: fetch all (expansion in Python)
CalendarEvent.rrule.isnot(None),
),
)
.order_by(CalendarEvent.start_at.asc())
)
result = await db.execute(stmt)
items = result.all()
if not items:
return []
# Batch-load attendees for all events in one query (avoid N+1)
event_ids = [event.id for event, _user in items]
att_result = await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
)
att_rows = att_result.scalars().all()
att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
for a in att_rows:
att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
events = []
for event, user in items:
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
event_data['attendees'] = att_map.get(event.id, [])
events.append(
CalendarEventUserResponse(
**event_data,
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return events
async def search_events(
self,
user_id: str,
query: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
) -> CalendarEventListResponse:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [g.id for g in user_groups]
# Get accessible calendar IDs
cal_stmt = select(Calendar.id)
cal_stmt = AccessGrants.has_permission_filter(
db=db,
query=cal_stmt,
DocumentModel=Calendar,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='calendar',
permission='read',
)
cal_result = await db.execute(cal_stmt)
accessible_cal_ids = [r[0] for r in cal_result.all()]
if not accessible_cal_ids:
return CalendarEventListResponse(items=[], total=0)
stmt = (
select(CalendarEvent, User)
.outerjoin(User, User.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.calendar_id.in_(accessible_cal_ids),
)
)
if query:
search = f'%{query}%'
stmt = stmt.filter(
or_(
CalendarEvent.title.ilike(search),
CalendarEvent.description.ilike(search),
CalendarEvent.location.ilike(search),
)
)
stmt = stmt.order_by(CalendarEvent.start_at.desc())
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
items = result.all()
if not items:
return CalendarEventListResponse(items=[], total=total)
# Batch-load attendees
event_ids = [event.id for event, _user in items]
att_result = await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
)
att_rows = att_result.scalars().all()
att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
for a in att_rows:
att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
events = []
for event, user in items:
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
event_data['attendees'] = att_map.get(event.id, [])
events.append(
CalendarEventUserResponse(
**event_data,
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return CalendarEventListResponse(items=events, total=total)
async def update_event_by_id(
self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarEventModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
event = result.scalars().first()
if not event:
return None
update_data = form_data.model_dump(exclude_unset=True)
for field in [
'calendar_id',
'title',
'description',
'start_at',
'end_at',
'all_day',
'rrule',
'color',
'location',
'is_cancelled',
]:
if field in update_data:
setattr(event, field, update_data[field])
if 'data' in update_data and update_data['data'] is not None:
event.data = {**(event.data or {}), **update_data['data']}
if 'meta' in update_data and update_data['meta'] is not None:
event.meta = {**(event.meta or {}), **update_data['meta']}
if 'attendees' in update_data and update_data['attendees'] is not None:
await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db)
event.updated_at = int(time.time_ns())
await db.commit()
return await self._to_event_model(event, db=db)
async def get_upcoming_events(
self,
now_ns: int,
default_lookahead_ns: int,
db: Optional[AsyncSession] = None,
) -> list[tuple[CalendarEventModel, Optional[str]]]:
"""Events starting between now and now + lookahead, for alert processing.
Per-event lookahead is read from meta.alert_minutes (falls back to
default_lookahead_ns). Returns (event, user_timezone) pairs.
"""
from open_webui.models.users import User as UserRow
# Use the maximum possible lookahead (60 min) to cast a wide net;
# per-event filtering happens in Python after fetching.
max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000)
upper = now_ns + max_lookahead_ns
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEvent, UserRow.timezone)
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.start_at >= now_ns,
CalendarEvent.start_at <= upper,
)
)
rows = result.all()
events = []
for event, tz in rows:
model = CalendarEventModel.model_validate(event)
# Determine per-event alert window
alert_minutes = None
if model.meta and 'alert_minutes' in model.meta:
alert_minutes = model.meta['alert_minutes']
if alert_minutes is not None:
if alert_minutes < 0:
# alert_minutes < 0 means "no alert"
continue
event_lookahead_ns = alert_minutes * 60 * 1_000_000_000
else:
event_lookahead_ns = default_lookahead_ns
if model.start_at <= now_ns + event_lookahead_ns:
events.append((model, tz))
return events
async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id))
await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id))
await db.commit()
return True
except Exception:
return False
class CalendarEventAttendeeTable:
async def set_attendees(
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
"""Replace all attendees for an event.
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
"""
async with get_async_db_context(db) as db:
# Remove existing
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
now = int(time.time_ns())
models = []
for att in attendees:
row = CalendarEventAttendee(
id=str(uuid4()),
event_id=event_id,
user_id=att['user_id'],
status=att.get('status', 'pending'),
meta=att.get('meta'),
created_at=now,
updated_at=now,
)
db.add(row)
models.append(CalendarEventAttendeeModel.model_validate(row))
await db.commit()
return models
async def update_rsvp(
self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None
) -> Optional[CalendarEventAttendeeModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEventAttendee).filter(
CalendarEventAttendee.event_id == event_id,
CalendarEventAttendee.user_id == user_id,
)
)
att = result.scalars().first()
if not att:
return None
att.status = status
att.updated_at = int(time.time_ns())
await db.commit()
return CalendarEventAttendeeModel.model_validate(att)
async def get_attendees_by_event(
self, event_id: str, db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()]
async def get_events_by_attendee(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
"""Return event IDs where user is an attendee."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
)
return [r[0] for r in result.all()]
Calendars = CalendarTable()
CalendarEvents = CalendarEventTable()
CalendarEventAttendees = CalendarEventAttendeeTable()
File diff suppressed because it is too large Load Diff
+338 -327
View File
@@ -3,8 +3,10 @@ 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 sqlalchemy import select, delete, func, cast, Integer
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import normalize_usage
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
@@ -15,7 +17,6 @@ from sqlalchemy import (
Text,
JSON,
Index,
func,
)
####################
@@ -41,19 +42,42 @@ def _normalize_timestamp(timestamp: int) -> float:
return timestamp
def get_usage(data: dict) -> Optional[dict]:
"""Extract and normalize usage from message data."""
usage = data.get('usage') or (data.get('info') or {}).get('usage')
return normalize_usage(usage) if usage else None
def _token_columns(dialect: str):
"""Return (input_tokens, output_tokens) SQL column expressions.
Falls back to OpenAI-style keys (prompt_tokens / completion_tokens)
when the normalized keys are absent.
"""
if dialect == 'sqlite':
extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer)
elif dialect == 'postgresql':
extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer)
else:
raise NotImplementedError(f'Unsupported dialect: {dialect}')
return (
func.coalesce(extract('input_tokens'), extract('prompt_tokens')),
func.coalesce(extract('output_tokens'), extract('completion_tokens')),
)
####################
# ChatMessage DB Schema
####################
class ChatMessage(Base):
__tablename__ = "chat_message"
__tablename__ = 'chat_message'
# Identity
id = Column(Text, primary_key=True)
chat_id = Column(
Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True
)
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(Text, index=True)
# Structure
@@ -85,9 +109,9 @@ class ChatMessage(Base):
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"),
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'),
)
@@ -124,527 +148,514 @@ class ChatMessageModel(BaseModel):
class ChatMessageTable:
def upsert_message(
async def upsert_message(
self,
message_id: str,
chat_id: str,
user_id: str,
data: dict,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[ChatMessageModel]:
"""Insert or update a chat message."""
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
now = int(time.time())
timestamp = data.get("timestamp", now)
timestamp = data.get('timestamp', now)
# Use composite ID: {chat_id}-{message_id}
composite_id = f"{chat_id}-{message_id}"
composite_id = f'{chat_id}-{message_id}'
existing = db.get(ChatMessage, composite_id)
existing = await 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 '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 and normalize usage
usage = get_usage(data)
if usage:
existing.usage = usage
# Deep-merge: preserve existing keys not present in new data
# This prevents background tasks (follow-ups, title, tags)
# from accidentally clearing the primary response's token counts
existing.usage = {**(existing.usage or {}), **usage}
existing.updated_at = now
db.commit()
db.refresh(existing)
await db.commit()
await 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
# Extract and normalize usage
usage = get_usage(data)
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"),
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)
await db.commit()
await 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)
async def get_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatMessageModel]:
async with get_async_db_context(db) as db:
message = await 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()
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(ChatMessage).filter_by(chat_id=chat_id).order_by(ChatMessage.created_at.asc())
)
messages = result.scalars().all()
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_user_id(
# DB column names that differ from the JSON message keys.
DB_TO_JSON_KEY_MAP = {
'parent_id': 'parentId',
'model_id': 'model',
'status_history': 'statusHistory',
'created_at': 'timestamp',
}
# DB-internal columns excluded from the reconstructed message dict.
EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'})
async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]:
"""Build a {message_id: message_dict} map from chat_message rows.
Returns the same shape as chat.history.messages so callers
(get_message_list, middleware) work unchanged. Returns None if
no rows exist for the chat (caller should fall back to the
embedded JSON blob for legacy chats).
"""
async with get_async_db_context(db) as db:
result = await db.execute(select(ChatMessage).filter_by(chat_id=chat_id))
rows = result.scalars().all()
if not rows:
return None
# Strip the composite-id prefix ("{chat_id}-") to recover the
# original message_id used as map key.
prefix = f'{chat_id}-'
prefix_len = len(prefix)
col_keys = [c.key for c in ChatMessage.__table__.columns]
messages_map: dict[str, dict] = {}
for row in rows:
msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id
msg: dict = {'id': msg_id}
for key in col_keys:
if key in self.EXCLUDED_COLUMNS:
continue
val = getattr(row, key)
if val is None:
continue
json_key = self.DB_TO_JSON_KEY_MAP.get(key, key)
msg[json_key] = val
# Ensure content always has a value
msg.setdefault('content', '')
# Mirror usage into info.usage for callers that read it there
if 'usage' in msg:
msg['info'] = {'usage': msg['usage']}
messages_map[msg_id] = msg
# Reconstruct childrenIds from parentId links so that the map
# is fully navigable (callers like the frontend rely on this).
for msg_id, msg in messages_map.items():
parent_id = msg.get('parentId')
if parent_id and parent_id in messages_map:
parent = messages_map[parent_id]
children = parent.get('childrenIds')
if children is None:
parent['childrenIds'] = [msg_id]
elif msg_id not in children:
children.append(msg_id)
# Ensure every message has a childrenIds list (leaf nodes get [])
for msg in messages_map.values():
if 'childrenIds' not in msg:
msg['childrenIds'] = []
return messages_map
async def get_messages_by_user_id(
self,
user_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
messages = (
db.query(ChatMessage)
async with get_async_db_context(db) as db:
result = await db.execute(
select(ChatMessage)
.filter_by(user_id=user_id)
.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
messages = result.scalars().all()
return [ChatMessageModel.model_validate(message) for message in messages]
def get_messages_by_model_id(
async 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,
db: Optional[AsyncSession] = None,
) -> list[ChatMessageModel]:
with get_db_context(db) as db:
query = db.query(ChatMessage).filter_by(model_id=model_id)
async with get_async_db_context(db) as db:
stmt = select(ChatMessage).filter_by(model_id=model_id)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
stmt = stmt.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()
)
stmt = stmt.filter(ChatMessage.created_at <= end_date)
stmt = stmt.order_by(ChatMessage.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
messages = result.scalars().all()
return [ChatMessageModel.model_validate(message) for message in messages]
def get_chat_ids_by_model_id(
async 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,
db: Optional[AsyncSession] = None,
) -> list[str]:
"""Get distinct chat_ids that used a specific model."""
with get_db_context(db) as db:
query = db.query(
async with get_async_db_context(db) as db:
stmt = select(
ChatMessage.chat_id,
func.max(ChatMessage.created_at).label("last_message_at"),
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)
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
stmt = stmt.filter(ChatMessage.created_at <= end_date)
# Group by chat_id and order by most recent message in each chat
# Secondary sort on chat_id ensures deterministic pagination
# (prevents duplicates across pages when timestamps tie)
chat_ids = (
query.group_by(ChatMessage.chat_id)
stmt = (
stmt.group_by(ChatMessage.chat_id)
.order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id)
.offset(skip)
.limit(limit)
.all()
)
result = await db.execute(stmt)
chat_ids = result.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()
async def delete_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
await db.execute(delete(ChatMessage).filter_by(chat_id=chat_id))
await db.commit()
return True
# Analytics methods
def get_message_count_by_model(
async 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,
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count")
).filter(
ChatMessage.role == "assistant",
stmt = select(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)
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
stmt = stmt.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))
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.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}
stmt = stmt.group_by(ChatMessage.model_id)
result = await db.execute(stmt)
return {row.model_id: row.count for row in result.all()}
def get_token_usage_by_model(
async 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,
db: Optional[AsyncSession] = 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
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
dialect = db.bind.dialect.name
# We need the dialect to determine JSON extraction syntax
# For async sessions, access via get_bind()
bind = await db.connection()
dialect = bind.dialect.name
if dialect == "sqlite":
input_tokens = cast(
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
)
output_tokens = cast(
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
)
elif dialect == "postgresql":
# 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}")
input_tokens, output_tokens = _token_columns(dialect)
query = db.query(
stmt = select(
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"),
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.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)
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
stmt = stmt.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))
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.model_id).all()
stmt = stmt.group_by(ChatMessage.model_id)
result = await db.execute(stmt)
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,
'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
for row in result.all()
}
def get_token_usage_by_user(
async def get_token_usage_by_user(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
dialect = db.bind.dialect.name
bind = await db.connection()
dialect = bind.dialect.name
if dialect == "sqlite":
input_tokens = cast(
func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer
)
output_tokens = cast(
func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer
)
elif dialect == "postgresql":
# 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}")
input_tokens, output_tokens = _token_columns(dialect)
query = db.query(
stmt = select(
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"),
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.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)
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
stmt = stmt.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))
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
results = query.group_by(ChatMessage.user_id).all()
stmt = stmt.group_by(ChatMessage.user_id)
result = await db.execute(stmt)
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,
'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
for row in result.all()
}
def get_message_count_by_user(
async 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,
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
with get_db_context(db) as db:
from sqlalchemy import func
async with get_async_db_context(db) as db:
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-%"),
stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.role == 'assistant',
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
stmt = stmt.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))
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
results = query.all()
stmt = stmt.group_by(ChatMessage.user_id)
result = await db.execute(stmt)
return {row.user_id: row.count for row in result.all()}
async def get_message_count_by_chat(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
group_id: Optional[str] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, int]:
async with get_async_db_context(db) as db:
from open_webui.models.groups import GroupMember
stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.role == 'assistant',
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
stmt = stmt.group_by(ChatMessage.chat_id)
result = await db.execute(stmt)
return {row.chat_id: row.count for row in result.all()}
async 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[AsyncSession] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by day and model."""
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
from open_webui.models.groups import GroupMember
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
)
if start_date:
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
stmt = stmt.filter(ChatMessage.created_at <= end_date)
if group_id:
group_users = select(GroupMember.user_id).filter(GroupMember.group_id == group_id).scalar_subquery()
stmt = stmt.filter(ChatMessage.user_id.in_(group_users))
result = await db.execute(stmt)
results = result.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")
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
)
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")
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(
async def get_hourly_message_counts_by_model(
self,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> dict[str, dict[str, int]]:
"""Get message counts grouped by hour and model."""
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
from datetime import datetime, timedelta
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant",
stmt = select(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)
stmt = stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
stmt = stmt.filter(ChatMessage.created_at <= end_date)
results = query.all()
result = await db.execute(stmt)
results = result.all()
# Group by hour -> model -> count
hourly_counts: dict[str, dict[str, int]] = {}
for timestamp, model_id in results:
hour_str = datetime.fromtimestamp(
_normalize_timestamp(timestamp)
).strftime("%Y-%m-%d %H:00")
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
)
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)
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")
hour_str = current.strftime('%Y-%m-%d %H:00')
if hour_str not in hourly_counts:
hourly_counts[hour_str] = {}
current += timedelta(hours=1)
File diff suppressed because it is too large Load Diff
+159 -183
View File
@@ -3,9 +3,10 @@ import time
import uuid
from typing import Optional
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 sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import User, UserModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean
@@ -19,7 +20,7 @@ log = logging.getLogger(__name__)
class Feedback(Base):
__tablename__ = "feedback"
__tablename__ = 'feedback'
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
version = Column(BigInteger, default=0)
@@ -81,7 +82,7 @@ class RatingData(BaseModel):
sibling_model_ids: Optional[list[str]] = None
reason: Optional[str] = None
comment: Optional[str] = None
model_config = ConfigDict(extra="allow", protected_namespaces=())
model_config = ConfigDict(extra='allow', protected_namespaces=())
class MetaData(BaseModel):
@@ -89,12 +90,12 @@ class MetaData(BaseModel):
chat_id: Optional[str] = None
message_id: Optional[str] = None
tags: Optional[list[str]] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
class SnapshotData(BaseModel):
chat: Optional[dict] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
class FeedbackForm(BaseModel):
@@ -102,14 +103,14 @@ class FeedbackForm(BaseModel):
data: Optional[RatingData] = None
meta: Optional[dict] = None
snapshot: Optional[SnapshotData] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
class UserResponse(BaseModel):
id: str
name: str
email: str
role: str = "pending"
role: str = 'pending'
last_active_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
@@ -139,156 +140,148 @@ class ModelHistoryResponse(BaseModel):
class FeedbackTable:
def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None
async def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None
) -> Optional[FeedbackModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
id = str(uuid.uuid4())
feedback = FeedbackModel(
**{
"id": id,
"user_id": user_id,
"version": 0,
'id': id,
'user_id': user_id,
'version': 0,
**form_data.model_dump(),
"created_at": int(time.time()),
"updated_at": int(time.time()),
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
)
try:
result = Feedback(**feedback.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return FeedbackModel.model_validate(result)
else:
return None
except Exception as e:
log.exception(f"Error creating a new feedback: {e}")
log.exception(f'Error creating a new feedback: {e}')
return None
def get_feedback_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
async def get_feedback_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FeedbackModel]:
try:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(id=id))
feedback = result.scalars().first()
if not feedback:
return None
return FeedbackModel.model_validate(feedback)
except Exception:
return None
def get_feedback_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
async def get_feedback_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[FeedbackModel]:
try:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id))
feedback = result.scalars().first()
if not feedback:
return None
return FeedbackModel.model_validate(feedback)
except Exception:
return None
def get_feedbacks_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
async def get_feedbacks_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
"""Get all feedbacks for a specific chat."""
try:
with get_db_context(db) as db:
async with get_async_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)
result = await db.execute(
select(Feedback)
.filter(Feedback.meta['chat_id'].as_string() == chat_id)
.order_by(Feedback.created_at.desc())
.all()
)
feedbacks = result.scalars().all()
return [FeedbackModel.model_validate(fb) for fb in feedbacks]
except Exception:
return []
def get_feedback_items(
async def get_feedback_items(
self,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> FeedbackListResponse:
with get_db_context(db) as db:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
async with get_async_db_context(db) as db:
stmt = select(Feedback, User).join(User, Feedback.user_id == User.id)
if filter:
order_by = filter.get("order_by")
direction = filter.get("direction")
# Apply model_id filter (exact match)
model_id = filter.get('model_id')
if model_id:
stmt = stmt.filter(Feedback.data['model_id'].as_string() == model_id)
if order_by == "username":
if direction == "asc":
query = query.order_by(User.name.asc())
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'username':
if direction == 'asc':
stmt = stmt.order_by(User.name.asc())
else:
query = query.order_by(User.name.desc())
elif order_by == "model_id":
# it's stored in feedback.data['model_id']
if direction == "asc":
query = query.order_by(
Feedback.data["model_id"].as_string().asc()
)
stmt = stmt.order_by(User.name.desc())
elif order_by == 'model_id':
if direction == 'asc':
stmt = stmt.order_by(Feedback.data['model_id'].as_string().asc())
else:
query = query.order_by(
Feedback.data["model_id"].as_string().desc()
)
elif order_by == "rating":
# it's stored in feedback.data['rating']
if direction == "asc":
query = query.order_by(
Feedback.data["rating"].as_string().asc()
)
stmt = stmt.order_by(Feedback.data['model_id'].as_string().desc())
elif order_by == 'rating':
if direction == 'asc':
stmt = stmt.order_by(Feedback.data['rating'].as_string().asc())
else:
query = query.order_by(
Feedback.data["rating"].as_string().desc()
)
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Feedback.updated_at.asc())
stmt = stmt.order_by(Feedback.data['rating'].as_string().desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Feedback.updated_at.asc())
else:
query = query.order_by(Feedback.updated_at.desc())
stmt = stmt.order_by(Feedback.updated_at.desc())
else:
query = query.order_by(Feedback.created_at.desc())
stmt = stmt.order_by(Feedback.created_at.desc())
# Count BEFORE pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
items = query.all()
result = await db.execute(stmt)
items = result.all()
feedbacks = []
for feedback, user in items:
feedback_model = FeedbackModel.model_validate(feedback)
user_model = UserResponse.model_validate(user)
feedbacks.append(
FeedbackUserResponse(**feedback_model.model_dump(), user=user_model)
)
feedbacks.append(FeedbackUserResponse(**feedback_model.model_dump(), user=user_model))
return FeedbackListResponse(items=feedbacks, total=total)
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)
.order_by(Feedback.updated_at.desc())
.all()
]
async def get_all_feedbacks(self, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).order_by(Feedback.updated_at.desc()))
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
def get_all_feedback_ids(
self, db: Optional[Session] = None
) -> list[FeedbackIdResponse]:
with get_db_context(db) as db:
async def get_all_feedback_ids(self, db: Optional[AsyncSession] = None) -> list[FeedbackIdResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(Feedback.id, Feedback.user_id, Feedback.created_at, Feedback.updated_at).order_by(
Feedback.updated_at.desc()
)
)
return [
FeedbackIdResponse(
id=row.id,
@@ -296,28 +289,28 @@ class FeedbackTable:
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()
for row in result.all()
]
def get_feedbacks_for_leaderboard(
self, db: Optional[Session] = None
) -> list[LeaderboardFeedbackData]:
async def get_distinct_model_ids(self, db: Optional[AsyncSession] = None) -> list[str]:
"""Get distinct model_ids from feedback data for filter dropdowns."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Feedback.data['model_id'].as_string())
.filter(Feedback.data['model_id'].as_string().isnot(None))
.distinct()
)
rows = result.all()
return sorted([row[0] for row in rows if row[0]])
async def get_feedbacks_for_leaderboard(self, db: Optional[AsyncSession] = 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()
]
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback.id, Feedback.data))
return [LeaderboardFeedbackData(id=row.id, data=row.data) for row in result.all()]
def get_model_evaluation_history(
self, model_id: str, days: int = 30, db: Optional[Session] = None
async def get_model_evaluation_history(
self, model_id: str, days: int = 30, db: Optional[AsyncSession] = None
) -> list[ModelHistoryEntry]:
"""
Get daily wins/losses for a specific model over the past N days.
@@ -327,36 +320,35 @@ class FeedbackTable:
from datetime import datetime, timedelta
from collections import defaultdict
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
if days == 0:
# All time - no cutoff
rows = db.query(Feedback.created_at, Feedback.data).all()
result = await db.execute(select(Feedback.created_at, Feedback.data))
else:
cutoff = int(time.time()) - (days * 86400)
rows = (
db.query(Feedback.created_at, Feedback.data)
.filter(Feedback.created_at >= cutoff)
.all()
result = await db.execute(
select(Feedback.created_at, Feedback.data).filter(Feedback.created_at >= cutoff)
)
rows = result.all()
daily_counts = defaultdict(lambda: {"won": 0, "lost": 0})
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:
if data.get('model_id') != model_id:
continue
rating_str = str(data.get("rating", ""))
if rating_str not in ("1", "-1"):
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
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
daily_counts[date_str]['lost'] += 1
# Track first date for this model
if first_date is None or date_str < first_date:
@@ -368,7 +360,7 @@ class FeedbackTable:
if days == 0 and first_date:
# All time: start from first feedback date
start_date = datetime.strptime(first_date, "%Y-%m-%d").date()
start_date = datetime.strptime(first_date, '%Y-%m-%d').date()
num_days = (today - start_date).days + 1
else:
# Fixed range
@@ -377,43 +369,28 @@ class FeedbackTable:
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"])
)
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)
.filter_by(type=type)
.order_by(Feedback.updated_at.desc())
.all()
]
async def get_feedbacks_by_type(self, type: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()))
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
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)
.filter_by(user_id=user_id)
.order_by(Feedback.updated_at.desc())
.all()
]
async def get_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FeedbackModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(user_id=user_id).order_by(Feedback.updated_at.desc()))
return [FeedbackModel.model_validate(feedback) for feedback in result.scalars().all()]
def update_feedback_by_id(
self, id: str, form_data: FeedbackForm, db: Optional[Session] = None
async def update_feedback_by_id(
self, id: str, form_data: FeedbackForm, db: Optional[AsyncSession] = None
) -> Optional[FeedbackModel]:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(id=id))
feedback = result.scalars().first()
if not feedback:
return None
@@ -426,18 +403,19 @@ class FeedbackTable:
feedback.updated_at = int(time.time())
db.commit()
await db.commit()
return FeedbackModel.model_validate(feedback)
def update_feedback_by_id_and_user_id(
async def update_feedback_by_id_and_user_id(
self,
id: str,
user_id: str,
form_data: FeedbackForm,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[FeedbackModel]:
with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id))
feedback = result.scalars().first()
if not feedback:
return None
@@ -450,42 +428,40 @@ class FeedbackTable:
feedback.updated_at = int(time.time())
db.commit()
await db.commit()
return FeedbackModel.model_validate(feedback)
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()
async def delete_feedback_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(id=id))
feedback = result.scalars().first()
if not feedback:
return False
db.delete(feedback)
db.commit()
await db.delete(feedback)
await db.commit()
return True
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()
async def delete_feedback_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(select(Feedback).filter_by(id=id, user_id=user_id))
feedback = result.scalars().first()
if not feedback:
return False
db.delete(feedback)
db.commit()
await db.delete(feedback)
await db.commit()
return True
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 result > 0
async def delete_feedbacks_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(delete(Feedback).filter_by(user_id=user_id))
await db.commit()
return result.rowcount > 0
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 result > 0
async def delete_all_feedbacks(self, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
result = await db.execute(delete(Feedback))
await db.commit()
return result.rowcount > 0
Feedbacks = FeedbackTable()
+140 -134
View File
@@ -2,8 +2,9 @@ import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.utils.misc import sanitize_metadata
from pydantic import BaseModel, ConfigDict, model_validator
from sqlalchemy import BigInteger, Column, String, Text, JSON
@@ -12,11 +13,13 @@ log = logging.getLogger(__name__)
####################
# Files DB Schema
# What is written here bears witness. Let the testimony
# remain as it was given, and let none tamper with it.
####################
class File(Base):
__tablename__ = "file"
__tablename__ = 'file'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
hash = Column(Text, nullable=True)
@@ -58,9 +61,9 @@ class FileMeta(BaseModel):
content_type: Optional[str] = None
size: Optional[int] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
@model_validator(mode="before")
@model_validator(mode='before')
@classmethod
def sanitize_meta(cls, data):
"""Sanitize metadata fields to handle malformed legacy data."""
@@ -68,14 +71,12 @@ class FileMeta(BaseModel):
return data
# Handle content_type that may be a list like ['application/pdf', None]
content_type = data.get("content_type")
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
)
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
data['content_type'] = None
return data
@@ -87,12 +88,12 @@ class FileModelResponse(BaseModel):
filename: str
data: Optional[dict] = None
meta: FileMeta
meta: Optional[FileMeta] = None
created_at: int # timestamp in epoch
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
class FileMetadataResponse(BaseModel):
@@ -103,6 +104,11 @@ class FileMetadataResponse(BaseModel):
updated_at: int # timestamp in epoch
class FileListResponse(BaseModel):
items: list[FileModelResponse]
total: int
class FileForm(BaseModel):
id: str
hash: Optional[str] = None
@@ -118,64 +124,58 @@ class FileUpdateForm(BaseModel):
meta: Optional[dict] = None
class FileListResponse(BaseModel):
items: list[FileModel]
total: int
class FilesTable:
def insert_new_file(
self, user_id: str, form_data: FileForm, db: Optional[Session] = None
async def insert_new_file(
self, user_id: str, form_data: FileForm, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
file_data = form_data.model_dump()
# Sanitize meta to remove non-JSON-serializable objects
# (e.g. callable tool functions, MCP client instances from middleware)
if file_data.get("meta"):
file_data["meta"] = sanitize_metadata(file_data["meta"])
if file_data.get('meta'):
file_data['meta'] = sanitize_metadata(file_data['meta'])
file = FileModel(
**{
**file_data,
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
'user_id': user_id,
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
)
try:
result = File(**file.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return FileModel.model_validate(result)
else:
return None
except Exception as e:
log.exception(f"Error inserting a new file: {e}")
log.exception(f'Error inserting a new file: {e}')
return None
def get_file_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FileModel]:
async def get_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FileModel]:
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.get(File, id)
return FileModel.model_validate(file)
file = await db.get(File, id)
return FileModel.model_validate(file) if file else None
except Exception:
return None
except Exception:
return None
def get_file_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
async def get_file_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id, user_id=user_id).first()
result = await db.execute(select(File).filter_by(id=id, user_id=user_id))
file = result.scalars().first()
if file:
return FileModel.model_validate(file)
else:
@@ -183,12 +183,14 @@ class FilesTable:
except Exception:
return None
def get_file_metadata_by_id(
self, id: str, db: Optional[Session] = None
async def get_file_metadata_by_id(
self, id: str, db: Optional[AsyncSession] = None
) -> Optional[FileMetadataResponse]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.get(File, id)
file = await db.get(File, id)
if not file:
return None
return FileMetadataResponse(
id=file.id,
hash=file.hash,
@@ -199,14 +201,13 @@ class FilesTable:
except Exception:
return None
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()]
async def get_files(self, db: Optional[AsyncSession] = None) -> list[FileModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(File))
return [FileModel.model_validate(file) for file in result.scalars().all()]
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)
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool:
file = await self.get_file_by_id(id, db=db)
if not file:
return False
if file.user_id == user_id:
@@ -214,46 +215,55 @@ class FilesTable:
# Implement additional access control logic here as needed
return False
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)
async def get_files_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[FileModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(File).filter(File.id.in_(ids)).order_by(File.updated_at.desc()))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def get_file_metadatas_by_ids(
self, ids: list[str], db: Optional[AsyncSession] = None
) -> list[FileMetadataResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
.filter(File.id.in_(ids))
.order_by(File.updated_at.desc())
.all()
]
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,
hash=file.hash,
meta=file.meta,
created_at=file.created_at,
updated_at=file.updated_at,
id=row.id,
hash=row.hash,
meta=row.meta,
created_at=row.created_at,
updated_at=row.updated_at,
)
for file in db.query(
File.id, File.hash, File.meta, File.created_at, File.updated_at
)
.filter(File.id.in_(ids))
.order_by(File.updated_at.desc())
.all()
for row in result.all()
]
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()
]
async def get_files_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FileModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(File).filter_by(user_id=user_id))
return [FileModel.model_validate(file) for file in result.scalars().all()]
async def get_file_list(
self,
user_id: Optional[str] = None,
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> 'FileListResponse':
async with get_async_db_context(db) as db:
stmt = select(File)
if user_id:
stmt = stmt.filter_by(user_id=user_id)
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
result = await db.execute(stmt.order_by(File.updated_at.desc(), File.id.desc()).offset(skip).limit(limit))
items = [FileModelResponse.model_validate(file, from_attributes=True) for file in result.scalars().all()]
return FileListResponse(items=items, total=total)
@staticmethod
def _glob_to_like_pattern(glob: str) -> str:
@@ -271,20 +281,20 @@ class FilesTable:
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("?", "_")
pattern = glob.replace('\\', '\\\\')
pattern = pattern.replace('%', '\\%')
pattern = pattern.replace('_', '\\_')
pattern = pattern.replace('*', '%')
pattern = pattern.replace('?', '_')
return pattern
def search_files(
async def search_files(
self,
user_id: Optional[str] = None,
filename: str = "*",
filename: str = '*',
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[FileModel]:
"""
Search files with glob pattern matching, optional user filter, and pagination.
@@ -299,30 +309,26 @@ class FilesTable:
Returns:
List of matching FileModel objects, ordered by created_at descending.
"""
with get_db_context(db) as db:
query = db.query(File)
async with get_async_db_context(db) as db:
stmt = select(File)
if user_id:
query = query.filter_by(user_id=user_id)
stmt = stmt.filter_by(user_id=user_id)
pattern = self._glob_to_like_pattern(filename)
if pattern != "%":
query = query.filter(File.filename.ilike(pattern, escape="\\"))
if pattern != '%':
stmt = stmt.filter(File.filename.ilike(pattern, escape='\\'))
return [
FileModel.model_validate(file)
for file in query.order_by(File.created_at.desc(), File.id.desc())
.offset(skip)
.limit(limit)
.all()
]
result = await db.execute(stmt.order_by(File.created_at.desc(), File.id.desc()).offset(skip).limit(limit))
return [FileModel.model_validate(file) for file in result.scalars().all()]
def update_file_by_id(
self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None
async def update_file_by_id(
self, id: str, form_data: FileUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
result = await db.execute(select(File).filter_by(id=id))
file = result.scalars().first()
if form_data.hash is not None:
file.hash = form_data.hash
@@ -334,70 +340,70 @@ class FilesTable:
file.meta = {**(file.meta if file.meta else {}), **form_data.meta}
file.updated_at = int(time.time())
db.commit()
await db.commit()
return FileModel.model_validate(file)
except Exception as e:
log.exception(f"Error updating file completely by id: {e}")
log.exception(f'Error updating file completely by id: {e}')
return None
def update_file_hash_by_id(
self, id: str, hash: Optional[str], db: Optional[Session] = None
async def update_file_hash_by_id(
self, id: str, hash: Optional[str], db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
result = await db.execute(select(File).filter_by(id=id))
file = result.scalars().first()
file.hash = hash
file.updated_at = int(time.time())
db.commit()
await db.commit()
return FileModel.model_validate(file)
except Exception:
return None
def update_file_data_by_id(
self, id: str, data: dict, db: Optional[Session] = None
async def update_file_data_by_id(
self, id: str, data: dict, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
result = await db.execute(select(File).filter_by(id=id))
file = result.scalars().first()
file.data = {**(file.data if file.data else {}), **data}
file.updated_at = int(time.time())
db.commit()
await db.commit()
return FileModel.model_validate(file)
except Exception as e:
return None
def update_file_metadata_by_id(
self, id: str, meta: dict, db: Optional[Session] = None
async def update_file_metadata_by_id(
self, id: str, meta: dict, db: Optional[AsyncSession] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
file = db.query(File).filter_by(id=id).first()
result = await db.execute(select(File).filter_by(id=id))
file = result.scalars().first()
file.meta = {**(file.meta if file.meta else {}), **meta}
file.updated_at = int(time.time())
db.commit()
await db.commit()
return FileModel.model_validate(file)
except Exception:
return None
return False
def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
async def delete_file_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
db.query(File).filter_by(id=id).delete()
db.commit()
await db.execute(delete(File).filter_by(id=id))
await db.commit()
return True
except Exception:
return False
def delete_all_files(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
async def delete_all_files(self, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
db.query(File).delete()
db.commit()
await db.execute(delete(File))
await db.commit()
return True
except Exception:
+110 -116
View File
@@ -6,21 +6,23 @@ import re
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from sqlalchemy.orm import Session
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func, select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.internal.db import Base, JSONField, get_async_db_context
log = logging.getLogger(__name__)
####################
# Folder DB Schema
# Let every room in this house shelter someone who needs it,
# and let no chamber stand empty while there is want.
####################
class Folder(Base):
__tablename__ = "folder"
__tablename__ = 'folder'
id = Column(Text, primary_key=True, unique=True)
parent_id = Column(Text, nullable=True)
user_id = Column(Text)
@@ -72,55 +74,56 @@ class FolderForm(BaseModel):
data: Optional[dict] = None
meta: Optional[dict] = None
parent_id: Optional[str] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='forbid')
class FolderUpdateForm(BaseModel):
name: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='forbid')
class FolderTable:
def insert_new_folder(
async def insert_new_folder(
self,
user_id: str,
form_data: FolderForm,
parent_id: Optional[str] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[FolderModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
id = str(uuid.uuid4())
folder = FolderModel(
**{
"id": id,
"user_id": user_id,
'id': id,
'user_id': user_id,
**(form_data.model_dump(exclude_unset=True) or {}),
"parent_id": parent_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
'parent_id': parent_id,
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
)
try:
result = Folder(**folder.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return FolderModel.model_validate(result)
else:
return None
except Exception as e:
log.exception(f"Error inserting a new folder: {e}")
log.exception(f'Error inserting a new folder: {e}')
return None
def get_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
async def get_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[FolderModel]:
try:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return None
@@ -129,85 +132,75 @@ class FolderTable:
except Exception:
return None
def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
async def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[list[FolderModel]]:
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
folders = []
def get_children(folder):
children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id, db=db
)
async def get_children(folder):
children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db)
for child in children:
get_children(child)
await get_children(child)
folders.append(child)
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return None
get_children(folder)
await get_children(folder)
return folders
except Exception:
return None
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()
]
async def get_folders_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[FolderModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(user_id=user_id))
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
def get_folder_by_parent_id_and_user_id_and_name(
async def get_folder_by_parent_id_and_user_id_and_name(
self,
parent_id: Optional[str],
user_id: str,
name: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[FolderModel]:
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# Check if folder exists
folder = (
db.query(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.filter(Folder.name.ilike(name))
.first()
result = await db.execute(
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name))
)
folder = result.scalars().first()
if not folder:
return None
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"get_folder_by_parent_id_and_user_id_and_name: {e}")
log.error(f'get_folder_by_parent_id_and_user_id_and_name: {e}')
return None
def get_folders_by_parent_id_and_user_id(
self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None
async def get_folders_by_parent_id_and_user_id(
self, parent_id: Optional[str], user_id: str, db: Optional[AsyncSession] = None
) -> list[FolderModel]:
with get_db_context(db) as db:
return [
FolderModel.model_validate(folder)
for folder in db.query(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.all()
]
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(parent_id=parent_id, user_id=user_id))
return [FolderModel.model_validate(folder) for folder in result.scalars().all()]
def update_folder_parent_id_by_id_and_user_id(
async def update_folder_parent_id_by_id_and_user_id(
self,
id: str,
user_id: str,
parent_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[FolderModel]:
try:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return None
@@ -215,69 +208,70 @@ class FolderTable:
folder.parent_id = parent_id
folder.updated_at = int(time.time())
db.commit()
await db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
log.error(f'update_folder: {e}')
return
def update_folder_by_id_and_user_id(
async def update_folder_by_id_and_user_id(
self,
id: str,
user_id: str,
form_data: FolderUpdateForm,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[FolderModel]:
try:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return None
form_data = form_data.model_dump(exclude_unset=True)
existing_folder = (
db.query(Folder)
.filter_by(
name=form_data.get("name"),
existing_result = await db.execute(
select(Folder).filter_by(
name=form_data.get('name'),
parent_id=folder.parent_id,
user_id=user_id,
)
.first()
)
existing_folder = existing_result.scalars().first()
if existing_folder and existing_folder.id != id:
return None
folder.name = form_data.get("name", folder.name)
if "data" in form_data:
folder.name = form_data.get('name', folder.name)
if 'data' in form_data:
folder.data = {
**(folder.data or {}),
**form_data["data"],
**form_data['data'],
}
if "meta" in form_data:
if 'meta' in form_data:
folder.meta = {
**(folder.meta or {}),
**form_data["meta"],
**form_data['meta'],
}
folder.updated_at = int(time.time())
db.commit()
await db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
log.error(f'update_folder: {e}')
return
def update_folder_is_expanded_by_id_and_user_id(
self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None
async def update_folder_is_expanded_by_id_and_user_id(
self, id: str, user_id: str, is_expanded: bool, db: Optional[AsyncSession] = None
) -> Optional[FolderModel]:
try:
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return None
@@ -285,54 +279,53 @@ class FolderTable:
folder.is_expanded = is_expanded
folder.updated_at = int(time.time())
db.commit()
await db.commit()
return FolderModel.model_validate(folder)
except Exception as e:
log.error(f"update_folder: {e}")
log.error(f'update_folder: {e}')
return
def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
async def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> list[str]:
try:
folder_ids = []
with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(id=id, user_id=user_id))
folder = result.scalars().first()
if not folder:
return folder_ids
folder_ids.append(folder.id)
# Delete all children folders
def delete_children(folder):
folder_children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id, db=db
)
async def delete_children(folder):
folder_children = await self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db)
for folder_child in folder_children:
delete_children(folder_child)
await delete_children(folder_child)
folder_ids.append(folder_child.id)
folder = db.query(Folder).filter_by(id=folder_child.id).first()
db.delete(folder)
db.commit()
child_result = await db.execute(select(Folder).filter_by(id=folder_child.id))
child_folder = child_result.scalars().first()
await db.delete(child_folder)
await db.commit()
delete_children(folder)
db.delete(folder)
db.commit()
await delete_children(folder)
await db.delete(folder)
await db.commit()
return folder_ids
except Exception as e:
log.error(f"delete_folder: {e}")
log.error(f'delete_folder: {e}')
return []
def normalize_folder_name(self, name: str) -> str:
# Replace _ and space with a single space, lower case, collapse multiple spaces
name = re.sub(r"[\s_]+", " ", name)
name = re.sub(r'[\s_]+', ' ', name)
return name.strip().lower()
def search_folders_by_names(
self, user_id: str, queries: list[str], db: Optional[Session] = None
async def search_folders_by_names(
self, user_id: str, queries: list[str], db: Optional[AsyncSession] = None
) -> list[FolderModel]:
"""
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
@@ -342,18 +335,18 @@ class FolderTable:
return []
results = {}
with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(user_id=user_id))
folders = result.scalars().all()
for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries:
results[folder.id] = FolderModel.model_validate(folder)
# get children folders
children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id, db=db
)
for child in children:
results[child.id] = child
children = await self.get_children_folders_by_id_and_user_id(folder.id, user_id, db=db)
if children:
for child in children:
results[child.id] = child
# Return the results as a list
if not results:
@@ -362,16 +355,17 @@ class FolderTable:
results = list(results.values())
return results
def search_folders_by_name_contains(
self, user_id: str, query: str, db: Optional[Session] = None
async def search_folders_by_name_contains(
self, user_id: str, query: str, db: Optional[AsyncSession] = None
) -> list[FolderModel]:
"""
Partial match: normalized name contains (as substring) the normalized query.
"""
normalized_query = self.normalize_folder_name(query)
results = []
with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all()
async with get_async_db_context(db) as db:
result = await db.execute(select(Folder).filter_by(user_id=user_id))
folders = result.scalars().all()
for folder in folders:
norm_name = self.normalize_folder_name(folder.name)
if normalized_query in norm_name:
+153 -176
View File
@@ -2,9 +2,10 @@ import logging
import time
from typing import Optional
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 sqlalchemy import select, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.users import Users, UserModel, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@@ -12,11 +13,13 @@ log = logging.getLogger(__name__)
####################
# Functions DB Schema
# Each function here is a promise made. Let no promise
# go unkept, and let none be called who cannot answer.
####################
class Function(Base):
__tablename__ = "function"
__tablename__ = 'function'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
@@ -30,13 +33,13 @@ class Function(Base):
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
__table_args__ = (Index("is_global_idx", "is_global"),)
__table_args__ = (Index('is_global_idx', 'is_global'),)
class FunctionMeta(BaseModel):
description: Optional[str] = None
manifest: Optional[dict] = {}
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
class FunctionModel(BaseModel):
@@ -75,10 +78,6 @@ class FunctionWithValvesModel(BaseModel):
####################
class FunctionUserResponse(FunctionModel):
user: Optional[UserModel] = None
class FunctionResponse(BaseModel):
id: str
user_id: str
@@ -90,6 +89,12 @@ class FunctionResponse(BaseModel):
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
class FunctionUserResponse(FunctionResponse):
user: Optional[UserResponse] = None
class FunctionForm(BaseModel):
id: str
@@ -103,48 +108,49 @@ class FunctionValves(BaseModel):
class FunctionsTable:
def insert_new_function(
async def insert_new_function(
self,
user_id: str,
type: str,
form_data: FunctionForm,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[FunctionModel]:
function = FunctionModel(
**{
**form_data.model_dump(),
"user_id": user_id,
"type": type,
"updated_at": int(time.time()),
"created_at": int(time.time()),
'user_id': user_id,
'type': type,
'updated_at': int(time.time()),
'created_at': int(time.time()),
}
)
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
result = Function(**function.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return FunctionModel.model_validate(result)
else:
return None
except Exception as e:
log.exception(f"Error creating a new function: {e}")
log.exception(f'Error creating a new function: {e}')
return None
def sync_functions(
async def sync_functions(
self,
user_id: str,
functions: list[FunctionWithValvesModel],
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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_context(db) as db:
async with get_async_db_context(db) as db:
# Get existing functions
existing_functions = db.query(Function).all()
result = await db.execute(select(Function))
existing_functions = result.scalars().all()
existing_ids = {func.id for func in existing_functions}
# Prepare a set of new function IDs
@@ -153,19 +159,21 @@ class FunctionsTable:
# Update or insert functions
for func in functions:
if func.id in existing_ids:
db.query(Function).filter_by(id=func.id).update(
{
await db.execute(
update(Function)
.filter_by(id=func.id)
.values(
**func.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
}
user_id=user_id,
updated_at=int(time.time()),
)
)
else:
new_func = Function(
**{
**func.model_dump(),
"user_id": user_id,
"updated_at": int(time.time()),
'user_id': user_id,
'updated_at': int(time.time()),
}
)
db.add(new_func)
@@ -173,31 +181,25 @@ class FunctionsTable:
# Remove functions that are no longer present
for func in existing_functions:
if func.id not in new_function_ids:
db.delete(func)
await db.delete(func)
db.commit()
await db.commit()
return [
FunctionModel.model_validate(func)
for func in db.query(Function).all()
]
result = await db.execute(select(Function))
return [FunctionModel.model_validate(func) for func in result.scalars().all()]
except Exception as e:
log.exception(f"Error syncing functions for user {user_id}: {e}")
log.exception(f'Error syncing functions for user {user_id}: {e}')
return []
def get_function_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FunctionModel]:
async def get_function_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[FunctionModel]:
try:
with get_db_context(db) as db:
function = db.get(Function, id)
return FunctionModel.model_validate(function)
async with get_async_db_context(db) as db:
function = await db.get(Function, id)
return FunctionModel.model_validate(function) if function else None
except Exception:
return None
def get_functions_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[FunctionModel]:
async def get_functions_by_ids(self, ids: list[str], db: Optional[AsyncSession] = 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).
@@ -205,8 +207,9 @@ class FunctionsTable:
if not ids:
return []
try:
with get_db_context(db) as db:
functions = db.query(Function).filter(Function.id.in_(ids)).all()
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).filter(Function.id.in_(ids)))
functions = result.scalars().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
@@ -214,42 +217,42 @@ class FunctionsTable:
except Exception:
return []
def get_functions(
self, active_only=False, include_valves=False, db: Optional[Session] = None
async def get_functions(
self, active_only=False, include_valves=False, db: Optional[AsyncSession] = None
) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
if active_only:
functions = db.query(Function).filter_by(is_active=True).all()
result = await db.execute(select(Function).filter_by(is_active=True))
else:
functions = db.query(Function).all()
result = await db.execute(select(Function))
functions = result.scalars().all()
if include_valves:
return [
FunctionWithValvesModel.model_validate(function)
for function in functions
]
return [FunctionWithValvesModel.model_validate(function) for function in functions]
else:
return [
FunctionModel.model_validate(function) for function in functions
]
return [FunctionModel.model_validate(function) for function in functions]
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()
async def get_function_list(self, db: Optional[AsyncSession] = None) -> list[FunctionUserResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).order_by(Function.updated_at.desc()))
functions = result.scalars().all()
user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
return [
FunctionUserResponse.model_validate(
{
**FunctionModel.model_validate(func).model_dump(),
"user": (
users_dict.get(func.user_id).model_dump()
**FunctionResponse.model_validate(func).model_dump(),
'user': (
UserResponse(
id=users_dict[func.user_id].id,
name=users_dict[func.user_id].name,
role=users_dict[func.user_id].role,
email=users_dict[func.user_id].email,
).model_dump()
if func.user_id in users_dict
else None
),
@@ -258,59 +261,36 @@ class FunctionsTable:
for func in functions
]
def get_functions_by_type(
self, type: str, active_only=False, db: Optional[Session] = None
async def get_functions_by_type(
self, type: str, active_only=False, db: Optional[AsyncSession] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
if active_only:
return [
FunctionModel.model_validate(function)
for function in db.query(Function)
.filter_by(type=type, is_active=True)
.all()
]
result = await db.execute(select(Function).filter_by(type=type, is_active=True))
else:
return [
FunctionModel.model_validate(function)
for function in db.query(Function).filter_by(type=type).all()
]
result = await db.execute(select(Function).filter_by(type=type))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
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)
.filter_by(type="filter", is_active=True, is_global=True)
.all()
]
async def get_global_filter_functions(self, db: Optional[AsyncSession] = None) -> list[FunctionModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
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)
.filter_by(type="action", is_active=True, is_global=True)
.all()
]
async def get_global_action_functions(self, db: Optional[AsyncSession] = None) -> list[FunctionModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True))
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
def get_function_valves_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
with get_db_context(db) as db:
async def get_function_valves_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[dict]:
async with get_async_db_context(db) as db:
try:
function = db.get(Function, id)
function = await db.get(Function, id)
return function.valves if function.valves else {}
except Exception as e:
log.exception(f"Error getting function valves by id {id}: {e}")
log.exception(f'Error getting function valves by id {id}: {e}')
return None
def get_function_valves_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> dict[str, dict]:
async def get_function_valves_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> dict[str, dict]:
"""
Batch fetch valves for multiple functions in a single query.
Returns a dict mapping function_id -> valves dict.
@@ -319,37 +299,34 @@ class FunctionsTable:
if not ids:
return {}
try:
with get_db_context(db) as db:
functions = (
db.query(Function.id, Function.valves)
.filter(Function.id.in_(ids))
.all()
)
async with get_async_db_context(db) as db:
result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids)))
functions = result.all()
return {f.id: (f.valves if f.valves else {}) for f in functions}
except Exception as e:
log.exception(f"Error batch-fetching function valves: {e}")
log.exception(f'Error batch-fetching function valves: {e}')
return {}
def update_function_valves_by_id(
self, id: str, valves: dict, db: Optional[Session] = None
async def update_function_valves_by_id(
self, id: str, valves: dict, db: Optional[AsyncSession] = None
) -> Optional[FunctionValves]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
function = db.get(Function, id)
function = await db.get(Function, id)
function.valves = valves
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
except Exception:
return None
def update_function_metadata_by_id(
self, id: str, metadata: dict, db: Optional[Session] = None
async def update_function_metadata_by_id(
self, id: str, metadata: dict, db: Optional[AsyncSession] = None
) -> Optional[FunctionModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
function = db.get(Function, id)
function = await db.get(Function, id)
if function:
if function.meta:
@@ -358,94 +335,94 @@ class FunctionsTable:
function.meta = metadata
function.updated_at = int(time.time())
db.commit()
db.refresh(function)
await db.commit()
await db.refresh(function)
return FunctionModel.model_validate(function)
else:
return None
except Exception as e:
log.exception(f"Error updating function metadata by id {id}: {e}")
log.exception(f'Error updating function metadata by id {id}: {e}')
return None
def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
async def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id, db=db)
user = await 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
if "functions" not in user_settings:
user_settings["functions"] = {}
if "valves" not in user_settings["functions"]:
user_settings["functions"]["valves"] = {}
if 'functions' not in user_settings:
user_settings['functions'] = {}
if 'valves' not in user_settings['functions']:
user_settings['functions']['valves'] = {}
return user_settings["functions"]["valves"].get(id, {})
return user_settings['functions']['valves'].get(id, {})
except Exception as e:
log.exception(f"Error getting user values by id {id} and user id {user_id}")
log.exception(f'Error getting user values by id {id} and user id {user_id}')
return None
def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
async def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict, db: Optional[AsyncSession] = None
) -> Optional[dict]:
try:
user = Users.get_user_by_id(user_id, db=db)
user = await 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
if "functions" not in user_settings:
user_settings["functions"] = {}
if "valves" not in user_settings["functions"]:
user_settings["functions"]["valves"] = {}
if 'functions' not in user_settings:
user_settings['functions'] = {}
if 'valves' not in user_settings['functions']:
user_settings['functions']['valves'] = {}
user_settings["functions"]["valves"][id] = valves
user_settings['functions']['valves'][id] = valves
# Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
await Users.update_user_by_id(user_id, {'settings': user_settings}, db=db)
return user_settings["functions"]["valves"][id]
return user_settings['functions']['valves'][id]
except Exception as e:
log.exception(
f"Error updating user valves by id {id} and user_id {user_id}: {e}"
)
log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}')
return None
def update_function_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
async def update_function_by_id(
self, id: str, updated: dict, db: Optional[AsyncSession] = None
) -> Optional[FunctionModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
db.query(Function).filter_by(id=id).update(
{
await db.execute(
update(Function)
.filter_by(id=id)
.values(
**updated,
"updated_at": int(time.time()),
}
updated_at=int(time.time()),
)
)
db.commit()
function = db.get(Function, id)
await db.commit()
function = await db.get(Function, id)
return FunctionModel.model_validate(function) if function else None
except Exception:
return None
def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]:
with get_db_context(db) as db:
async def deactivate_all_functions(self, db: Optional[AsyncSession] = None) -> Optional[bool]:
async with get_async_db_context(db) as db:
try:
db.query(Function).update(
{
"is_active": False,
"updated_at": int(time.time()),
}
await db.execute(
update(Function).values(
is_active=False,
updated_at=int(time.time()),
)
)
db.commit()
await db.commit()
return True
except Exception:
return None
def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
async def delete_function_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
db.query(Function).filter_by(id=id).delete()
db.commit()
await db.execute(delete(Function).filter_by(id=id))
await db.commit()
return True
except Exception:
+201 -234
View File
@@ -4,8 +4,9 @@ import time
from typing import Optional
import uuid
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, update, func, and_, or_, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION
from open_webui.models.files import FileMetadataResponse
@@ -15,26 +16,22 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
BigInteger,
Column,
String,
Text,
JSON,
and_,
func,
ForeignKey,
cast,
or_,
select,
)
log = logging.getLogger(__name__)
####################
# UserGroup DB Schema
# Let none who belong to this house be turned away,
# and let the covenant hold for every member.
####################
class Group(Base):
__tablename__ = "group"
__tablename__ = 'group'
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text)
@@ -70,12 +67,12 @@ class GroupModel(BaseModel):
class GroupMember(Base):
__tablename__ = "group_member"
__tablename__ = 'group_member'
id = Column(Text, unique=True, primary_key=True)
group_id = Column(
Text,
ForeignKey("group.id", ondelete="CASCADE"),
ForeignKey('group.id', ondelete='CASCADE'),
nullable=False,
)
user_id = Column(Text, nullable=False)
@@ -133,36 +130,34 @@ class GroupListResponse(BaseModel):
class GroupTable:
def _ensure_default_share_config(self, group_data: dict) -> dict:
"""Ensure the group data dict has a default share config if not already set."""
if "data" not in group_data or group_data["data"] is None:
group_data["data"] = {}
if "config" not in group_data["data"]:
group_data["data"]["config"] = {}
if "share" not in group_data["data"]["config"]:
group_data["data"]["config"]["share"] = DEFAULT_GROUP_SHARE_PERMISSION
if 'data' not in group_data or group_data['data'] is None:
group_data['data'] = {}
if 'config' not in group_data['data']:
group_data['data']['config'] = {}
if 'share' not in group_data['data']['config']:
group_data['data']['config']['share'] = DEFAULT_GROUP_SHARE_PERMISSION
return group_data
def insert_new_group(
self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
async def insert_new_group(
self, user_id: str, form_data: GroupForm, db: Optional[AsyncSession] = None
) -> Optional[GroupModel]:
with get_db_context(db) as db:
group_data = self._ensure_default_share_config(
form_data.model_dump(exclude_none=True)
)
async with get_async_db_context(db) as db:
group_data = self._ensure_default_share_config(form_data.model_dump(exclude_none=True))
group = GroupModel(
**{
**group_data,
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
'id': str(uuid.uuid4()),
'user_id': user_id,
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
)
try:
result = Group(**group.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return GroupModel.model_validate(result)
else:
@@ -171,31 +166,38 @@ class GroupTable:
except Exception:
return None
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()
async def get_all_groups(self, db: Optional[AsyncSession] = None) -> list[GroupModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).order_by(Group.updated_at.desc()))
groups = result.scalars().all()
return [GroupModel.model_validate(group) for group in groups]
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
with get_db_context(db) as db:
async def get_group_by_name(self, name: str, db: Optional[AsyncSession] = None) -> Optional[GroupModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter(Group.name == name))
group = result.scalars().first()
return GroupModel.model_validate(group) if group else None
async def get_groups(self, filter, db: Optional[AsyncSession] = None) -> list[GroupResponse]:
async with get_async_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")
.label('member_count')
)
query = db.query(Group, member_count)
stmt = select(Group, member_count)
if filter:
if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
if 'query' in filter:
stmt = stmt.filter(Group.name.ilike(f'%{filter["query"]}%'))
# When share filter is present, member check is handled in the share logic
if "share" in filter:
share_value = filter["share"]
member_id = filter.get("member_id")
json_share = Group.data["config"]["share"]
if 'share' in filter:
share_value = filter['share']
member_id = filter.get('member_id')
json_share = Group.data['config']['share']
json_share_str = json_share.as_string()
json_share_lower = func.lower(json_share_str)
@@ -203,191 +205,169 @@ class GroupTable:
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
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
)
member_groups_select = select(GroupMember.group_id).where(GroupMember.user_id == member_id)
members_only_and_is_member = and_(
json_share_lower == "members",
json_share_lower == 'members',
Group.id.in_(member_groups_select),
)
query = query.filter(
or_(anyone_can_share, members_only_and_is_member)
)
stmt = stmt.filter(or_(anyone_can_share, members_only_and_is_member))
else:
query = query.filter(anyone_can_share)
stmt = stmt.filter(anyone_can_share)
else:
query = query.filter(
and_(Group.data.isnot(None), json_share_lower == "false")
)
stmt = stmt.filter(and_(Group.data.isnot(None), json_share_lower == 'false'))
else:
# Only apply member_id filter when share filter is NOT present
if "member_id" in filter:
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
if 'member_id' in filter:
stmt = stmt.filter(
Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id']))
)
results = query.order_by(Group.updated_at.desc()).all()
result = await db.execute(stmt.order_by(Group.updated_at.desc()))
rows = result.all()
return [
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": count or 0,
'member_count': count or 0,
}
)
for group, count in results
for group, count in rows
]
def search_groups(
async def search_groups(
self,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> GroupListResponse:
with get_db_context(db) as db:
query = db.query(Group)
async with get_async_db_context(db) as db:
stmt = select(Group)
if filter:
if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
if "member_id" in filter:
query = query.filter(
Group.id.in_(
select(GroupMember.group_id).where(
GroupMember.user_id == filter["member_id"]
)
)
if 'query' in filter:
stmt = stmt.filter(Group.name.ilike(f'%{filter["query"]}%'))
if 'member_id' in filter:
stmt = stmt.filter(
Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id']))
)
if "share" in filter:
share_value = filter["share"]
query = query.filter(
Group.data.op("->>")("share") == str(share_value)
)
if 'share' in filter:
share_value = filter['share']
stmt = stmt.filter(Group.data.op('->>')('share') == str(share_value))
total = query.count()
# Get total count
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
member_count = (
select(func.count(GroupMember.user_id))
.where(GroupMember.group_id == Group.id)
.correlate(Group)
.scalar_subquery()
.label("member_count")
.label('member_count')
)
results = (
query.add_columns(member_count)
result = await db.execute(
select(Group, member_count)
.where(Group.id.in_(select(stmt.subquery().c.id)))
.order_by(Group.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
)
rows = result.all()
return {
"items": [
'items': [
GroupResponse.model_validate(
{
**GroupModel.model_validate(group).model_dump(),
"member_count": count or 0,
'member_count': count or 0,
}
)
for group, count in results
for group, count in rows
],
"total": total,
'total': total,
}
def get_groups_by_member_id(
self, user_id: str, db: Optional[Session] = None
) -> list[GroupModel]:
with get_db_context(db) as db:
return [
GroupModel.model_validate(group)
for group in db.query(Group)
async def get_groups_by_member_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[GroupModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
.order_by(Group.updated_at.desc())
.all()
]
)
return [GroupModel.model_validate(group) for group in result.scalars().all()]
def get_groups_by_member_ids(
self, user_ids: list[str], db: Optional[Session] = None
async def get_groups_by_member_ids(
self, user_ids: list[str], db: Optional[AsyncSession] = 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:
async with get_async_db_context(db) as db:
# Query GroupMember joined with Group, filtering by user_ids
results = (
db.query(GroupMember.user_id, Group)
result = await db.execute(
select(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()
)
rows = result.all()
# Group groups by user_id
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
for user_id, group in results:
for user_id, group in rows:
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]:
async def get_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[GroupModel]:
try:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter_by(id=id))
group = result.scalars().first()
return GroupModel.model_validate(group) if group else None
except Exception:
return None
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()
)
async def get_group_user_ids_by_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(GroupMember.user_id).filter(GroupMember.group_id == id))
members = result.all()
if not members:
return []
return [m[0] for m in members]
def get_group_user_ids_by_ids(
self, group_ids: list[str], db: Optional[Session] = None
async def get_group_user_ids_by_ids(
self, group_ids: list[str], db: Optional[AsyncSession] = 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))
.all()
async with get_async_db_context(db) as db:
result = await db.execute(
select(GroupMember.group_id, GroupMember.user_id).filter(GroupMember.group_id.in_(group_ids))
)
members = result.all()
group_user_ids: dict[str, list[str]] = {
group_id: [] for group_id in group_ids
}
group_user_ids: dict[str, list[str]] = {group_id: [] for group_id in group_ids}
for group_id, user_id in members:
group_user_ids[group_id].append(user_id)
return group_user_ids
def set_group_user_ids_by_id(
self, group_id: str, user_ids: list[str], db: Optional[Session] = None
async def set_group_user_ids_by_id(
self, group_id: str, user_ids: list[str], db: Optional[AsyncSession] = None
) -> None:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# Delete existing members
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
await db.execute(delete(GroupMember).filter(GroupMember.group_id == group_id))
# Insert new members
now = int(time.time())
@@ -403,124 +383,114 @@ class GroupTable:
]
db.add_all(new_members)
db.commit()
await db.commit()
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)
.scalar()
)
async def get_group_member_count_by_id(self, id: str, db: Optional[AsyncSession] = None) -> int:
async with get_async_db_context(db) as db:
result = await db.execute(select(func.count(GroupMember.user_id)).filter(GroupMember.group_id == id))
count = result.scalar()
return count if count else 0
def get_group_member_counts_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> dict[str, int]:
async def get_group_member_counts_by_ids(self, ids: list[str], db: Optional[AsyncSession] = 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))
async with get_async_db_context(db) as db:
result = await db.execute(
select(GroupMember.group_id, func.count(GroupMember.user_id))
.filter(GroupMember.group_id.in_(ids))
.group_by(GroupMember.group_id)
.all()
)
rows = result.all()
return {group_id: count for group_id, count in rows}
def update_group_by_id(
async def update_group_by_id(
self,
id: str,
form_data: GroupUpdateForm,
overwrite: bool = False,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[GroupModel]:
try:
with get_db_context(db) as db:
db.query(Group).filter_by(id=id).update(
{
async with get_async_db_context(db) as db:
await db.execute(
update(Group)
.filter_by(id=id)
.values(
**form_data.model_dump(exclude_none=True),
"updated_at": int(time.time()),
}
updated_at=int(time.time()),
)
)
db.commit()
return self.get_group_by_id(id=id, db=db)
await db.commit()
return await 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, db: Optional[Session] = None) -> bool:
async def delete_group_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
with get_db_context(db) as db:
db.query(Group).filter_by(id=id).delete()
db.commit()
async with get_async_db_context(db) as db:
await db.execute(delete(Group).filter_by(id=id))
await db.commit()
return True
except Exception:
return False
def delete_all_groups(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
async def delete_all_groups(self, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
db.query(Group).delete()
db.commit()
await db.execute(delete(Group))
await db.commit()
return True
except Exception:
return False
def remove_user_from_all_groups(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
async def remove_user_from_all_groups(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
# Find all groups the user belongs to
groups = (
db.query(Group)
result = await db.execute(
select(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
.all()
)
groups = result.scalars().all()
# Remove the user from each group
for group in groups:
db.query(GroupMember).filter(
GroupMember.group_id == group.id, GroupMember.user_id == user_id
).delete()
db.query(Group).filter_by(id=group.id).update(
{"updated_at": int(time.time())}
await db.execute(
delete(GroupMember).filter(GroupMember.group_id == group.id, GroupMember.user_id == user_id)
)
db.commit()
await db.execute(update(Group).filter_by(id=group.id).values(updated_at=int(time.time())))
await db.commit()
return True
except Exception:
db.rollback()
await db.rollback()
return False
def create_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[Session] = None
async def create_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None
) -> list[GroupModel]:
# check for existing groups
existing_groups = self.get_all_groups(db=db)
existing_groups = await self.get_all_groups(db=db)
existing_group_names = {group.name for group in existing_groups}
new_groups = []
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
for group_name in group_names:
if group_name not in existing_group_names:
new_group = GroupModel(
id=str(uuid.uuid4()),
user_id=user_id,
name=group_name,
description="",
description='',
data={
"config": {
"share": DEFAULT_GROUP_SHARE_PERMISSION,
'config': {
'share': DEFAULT_GROUP_SHARE_PERMISSION,
}
},
created_at=int(time.time()),
@@ -529,35 +499,33 @@ class GroupTable:
try:
result = Group(**new_group.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
new_groups.append(GroupModel.model_validate(result))
except Exception as e:
log.exception(e)
continue
return new_groups
def sync_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[Session] = None
async def sync_groups_by_group_names(
self, user_id: str, group_names: list[str], db: Optional[AsyncSession] = None
) -> bool:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
now = int(time.time())
# 1. Groups that SHOULD contain the user
target_groups = (
db.query(Group).filter(Group.name.in_(group_names)).all()
)
result = await db.execute(select(Group).filter(Group.name.in_(group_names)))
target_groups = result.scalars().all()
target_group_ids = {g.id for g in target_groups}
# 2. Groups the user is CURRENTLY in
existing_group_ids = {
g.id
for g in db.query(Group)
result = await db.execute(
select(Group)
.join(GroupMember, GroupMember.group_id == Group.id)
.filter(GroupMember.user_id == user_id)
.all()
}
)
existing_group_ids = {g.id for g in result.scalars().all()}
# 3. Determine adds + removals
groups_to_add = target_group_ids - existing_group_ids
@@ -565,15 +533,15 @@ class GroupTable:
# 4. Remove in one bulk delete
if groups_to_remove:
db.query(GroupMember).filter(
GroupMember.user_id == user_id,
GroupMember.group_id.in_(groups_to_remove),
).delete(synchronize_session=False)
db.query(Group).filter(Group.id.in_(groups_to_remove)).update(
{"updated_at": now}, synchronize_session=False
await db.execute(
delete(GroupMember).filter(
GroupMember.user_id == user_id,
GroupMember.group_id.in_(groups_to_remove),
)
)
await db.execute(update(Group).filter(Group.id.in_(groups_to_remove)).values(updated_at=now))
# 5. Bulk insert missing memberships
for group_id in groups_to_add:
db.add(
@@ -587,27 +555,26 @@ class GroupTable:
)
if groups_to_add:
db.query(Group).filter(Group.id.in_(groups_to_add)).update(
{"updated_at": now}, synchronize_session=False
)
await db.execute(update(Group).filter(Group.id.in_(groups_to_add)).values(updated_at=now))
db.commit()
await db.commit()
return True
except Exception as e:
log.exception(e)
db.rollback()
await db.rollback()
return False
def add_users_to_group(
async def add_users_to_group(
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[GroupModel]:
try:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter_by(id=id))
group = result.scalars().first()
if not group:
return None
@@ -624,15 +591,14 @@ class GroupTable:
updated_at=now,
)
)
db.flush() # Detect unique constraint violation early
await db.flush() # Detect unique constraint violation early
except Exception:
db.rollback() # Clear failed INSERT
db.begin() # Start a new transaction
await db.rollback() # Clear failed INSERT
continue # Duplicate → ignore
group.updated_at = now
db.commit()
db.refresh(group)
await db.commit()
await db.refresh(group)
return GroupModel.model_validate(group)
@@ -640,15 +606,16 @@ class GroupTable:
log.exception(e)
return None
def remove_users_from_group(
async def remove_users_from_group(
self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[GroupModel]:
try:
with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Group).filter_by(id=id))
group = result.scalars().first()
if not group:
return None
@@ -656,15 +623,15 @@ class GroupTable:
return GroupModel.model_validate(group)
# 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)
await db.execute(
delete(GroupMember).filter(GroupMember.group_id == id, GroupMember.user_id.in_(user_ids))
)
# Update group timestamp
group.updated_at = int(time.time())
db.commit()
db.refresh(group)
await db.commit()
await db.refresh(group)
return GroupModel.model_validate(group)
except Exception as e:
+259 -305
View File
@@ -4,8 +4,9 @@ import time
from typing import Optional
import uuid
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, update, or_, func, cast
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.files import (
File,
@@ -27,18 +28,19 @@ from sqlalchemy import (
Text,
JSON,
UniqueConstraint,
or_,
)
log = logging.getLogger(__name__)
####################
# Knowledge DB Schema
# Let what was gathered here outlast the one who gathered it,
# and still teach when the builder is gone.
####################
class Knowledge(Base):
__tablename__ = "knowledge"
__tablename__ = 'knowledge'
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text)
@@ -70,24 +72,18 @@ class KnowledgeModel(BaseModel):
class KnowledgeFile(Base):
__tablename__ = "knowledge_file"
__tablename__ = 'knowledge_file'
id = Column(Text, unique=True, primary_key=True)
knowledge_id = Column(
Text, ForeignKey("knowledge.id", ondelete="CASCADE"), nullable=False
)
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
knowledge_id = Column(Text, ForeignKey('knowledge.id', ondelete='CASCADE'), nullable=False)
file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Text, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint(
"knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file"
),
)
__table_args__ = (UniqueConstraint('knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file'),)
class KnowledgeFileModel(BaseModel):
@@ -138,72 +134,61 @@ 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)
async def _get_access_grants(self, knowledge_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('knowledge', knowledge_id, db=db)
def _to_knowledge_model(
async def _to_knowledge_model(
self,
knowledge: Knowledge,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
exclude={"access_grants"}
)
knowledge_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(knowledge_data["id"], db=db)
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(exclude={'access_grants'})
knowledge_data['access_grants'] = (
access_grants if access_grants is not None else await 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, db: Optional[Session] = None
async def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm, db: Optional[AsyncSession] = None
) -> Optional[KnowledgeModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
knowledge = KnowledgeModel(
**{
**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": [],
**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(exclude={"access_grants"}))
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
)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('knowledge', result.id, form_data.access_grants, db=db)
if result:
return self._to_knowledge_model(result, db=db)
return await 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, db: Optional[Session] = None
async def get_knowledge_bases(
self, skip: int = 0, limit: int = 30, db: Optional[AsyncSession] = None
) -> list[KnowledgeUserModel]:
with get_db_context(db) as db:
all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
)
async with get_async_db_context(db) as db:
result = await db.execute(select(Knowledge).order_by(Knowledge.updated_at.desc()))
all_knowledge = result.scalars().all()
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
knowledge_ids = [knowledge.id for knowledge in all_knowledge]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
knowledge_bases = []
for knowledge in all_knowledge:
@@ -211,89 +196,87 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
**(
await self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
)
).model_dump(),
"user": user.model_dump() if user else None,
'user': user.model_dump() if user else None,
}
)
)
return knowledge_bases
def search_knowledge_bases(
async def search_knowledge_bases(
self,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> KnowledgeListResponse:
try:
with get_db_context(db) as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
async with get_async_db_context(db) as db:
stmt = select(Knowledge, User).outerjoin(User, User.id == Knowledge.user_id)
if filter:
query_key = filter.get("query")
query_key = filter.get('query')
if query_key:
query = query.filter(
stmt = stmt.filter(
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
User.username.ilike(f"%{query_key}%"),
Knowledge.name.ilike(f'%{query_key}%'),
Knowledge.description.ilike(f'%{query_key}%'),
User.name.ilike(f'%{query_key}%'),
User.email.ilike(f'%{query_key}%'),
User.username.ilike(f'%{query_key}%'),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Knowledge.user_id == user_id)
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Knowledge.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Knowledge.user_id != user_id)
query = AccessGrants.has_permission_filter(
stmt = AccessGrants.has_permission_filter(
db=db,
query=query,
query=stmt,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
resource_type='knowledge',
permission='read',
)
query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
stmt = stmt.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc())
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
items = query.all()
result = await db.execute(stmt)
items = result.all()
knowledge_ids = [kb.id for kb, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(
knowledge_base,
access_grants=grants_map.get(knowledge_base.id, []),
db=db,
**(
await self._to_knowledge_model(
knowledge_base,
access_grants=grants_map.get(knowledge_base.id, []),
db=db,
)
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
if user
else None
),
'user': (UserModel.model_validate(user).model_dump() if user else None),
}
)
)
@@ -303,169 +286,159 @@ class KnowledgeTable:
print(e)
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[Session] = None
async def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[AsyncSession] = None
) -> KnowledgeFileListResponse:
"""
Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists.
"""
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User, Knowledge)
stmt = (
select(File, User, Knowledge)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
)
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = AccessGrants.has_permission_filter(
stmt = AccessGrants.has_permission_filter(
db=db,
query=query,
query=stmt,
DocumentModel=Knowledge,
filter=filter,
resource_type="knowledge",
permission="read",
resource_type='knowledge',
permission='read',
)
# Apply filename search
# Apply filename / content search
if filter:
q = filter.get("query")
q = filter.get('query')
if q:
query = query.filter(File.filename.ilike(f"%{q}%"))
stmt = stmt.filter(
or_(
File.filename.ilike(f'%{q}%'),
cast(File.data['content'], Text).ilike(f'%{q}%'),
)
)
# Order by file changes
query = query.order_by(File.updated_at.desc(), File.id.asc())
stmt = stmt.order_by(File.updated_at.desc(), File.id.asc())
# Count before pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
rows = query.all()
result = await db.execute(stmt)
rows = result.all()
items = []
for file, user, knowledge in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
collection=self._to_knowledge_model(
knowledge, db=db
).model_dump(),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
collection=(await self._to_knowledge_model(knowledge, db=db)).model_dump(),
)
)
return KnowledgeFileListResponse(items=items, total=total)
except Exception as e:
print("search_knowledge_files error:", e)
print('search_knowledge_files error:', e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(
self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
knowledge = self.get_knowledge_by_id(id, db=db)
async def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[AsyncSession] = None) -> bool:
knowledge = await 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, db=db)
}
return AccessGrants.has_access(
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
return await AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
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", db: Optional[Session] = None
async def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
) -> list[KnowledgeUserModel]:
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 AccessGrants.has_access(
knowledge_bases = await self.get_knowledge_bases(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user_id:
result.append(knowledge_base)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_type='knowledge',
resource_id=knowledge_base.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
):
result.append(knowledge_base)
return result
def get_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
async def get_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
try:
with get_db_context(db) as db:
knowledge = db.query(Knowledge).filter_by(id=id).first()
return self._to_knowledge_model(knowledge, db=db) if knowledge else None
async with get_async_db_context(db) as db:
result = await db.execute(select(Knowledge).filter_by(id=id))
knowledge = result.scalars().first()
return await 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, db: Optional[Session] = None
async def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id, db=db)
knowledge = await self.get_knowledge_by_id(id, db=db)
if not knowledge:
return None
if knowledge.user_id == user_id:
return knowledge
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
if AccessGrants.has_access(
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
if await AccessGrants.has_access(
user_id=user_id,
resource_type="knowledge",
resource_type='knowledge',
resource_id=knowledge.id,
permission="write",
permission='write',
user_group_ids=user_group_ids,
db=db,
):
return knowledge
return None
def get_knowledges_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[KnowledgeModel]:
async def get_knowledges_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[KnowledgeModel]:
try:
with get_db_context(db) as db:
knowledges = (
db.query(Knowledge)
async with get_async_db_context(db) as db:
result = await db.execute(
select(Knowledge)
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
.filter(KnowledgeFile.file_id == file_id)
.all()
)
knowledges = result.scalars().all()
knowledge_ids = [k.id for k in knowledges]
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
return [
self._to_knowledge_model(
await self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
@@ -475,19 +448,19 @@ class KnowledgeTable:
except Exception:
return []
def search_files_by_id(
async def search_files_by_id(
self,
knowledge_id: str,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> KnowledgeFileListResponse:
try:
with get_db_context(db) as db:
query = (
db.query(File, User)
async with get_async_db_context(db) as db:
stmt = (
select(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
@@ -497,58 +470,53 @@ class KnowledgeTable:
primary_sort = File.updated_at.desc()
if filter:
query_key = filter.get("query")
query_key = filter.get('query')
if query_key:
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(KnowledgeFile.user_id == user_id)
elif view_option == "shared":
query = query.filter(KnowledgeFile.user_id != user_id)
order_by = filter.get("order_by")
direction = filter.get("direction")
is_asc = direction == "asc"
if order_by == "name":
primary_sort = (
File.filename.asc() if is_asc else File.filename.desc()
)
elif order_by == "created_at":
primary_sort = (
File.created_at.asc() if is_asc else File.created_at.desc()
)
elif order_by == "updated_at":
primary_sort = (
File.updated_at.asc() if is_asc else File.updated_at.desc()
stmt = stmt.filter(
or_(
File.filename.ilike(f'%{query_key}%'),
cast(File.data['content'], Text).ilike(f'%{query_key}%'),
)
)
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(KnowledgeFile.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(KnowledgeFile.user_id != user_id)
order_by = filter.get('order_by')
direction = filter.get('direction')
is_asc = direction == 'asc'
if order_by == 'name':
primary_sort = File.filename.asc() if is_asc else File.filename.desc()
elif order_by == 'created_at':
primary_sort = File.created_at.asc() if is_asc else File.created_at.desc()
elif order_by == 'updated_at':
primary_sort = File.updated_at.asc() if is_asc else File.updated_at.desc()
# Apply sort with secondary key for deterministic pagination
query = query.order_by(primary_sort, File.id.asc())
stmt = stmt.order_by(primary_sort, File.id.asc())
# Count BEFORE pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
items = query.all()
result = await db.execute(stmt)
items = result.all()
files = []
for file, user in items:
files.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
@@ -557,55 +525,52 @@ class KnowledgeTable:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileModel]:
async def get_files_by_id(self, knowledge_id: str, db: Optional[AsyncSession] = None) -> list[FileModel]:
try:
with get_db_context(db) as db:
files = (
db.query(File)
async with get_async_db_context(db) as db:
result = await db.execute(
select(File)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
.all()
)
files = result.scalars().all()
return [FileModel.model_validate(file) for file in files]
except Exception:
return []
def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[Session] = None
async def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[AsyncSession] = None
) -> list[FileMetadataResponse]:
try:
with get_db_context(db) as db:
files = self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files]
files = await 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(
async def add_file_to_knowledge_by_id(
self,
knowledge_id: str,
file_id: str,
user_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[KnowledgeFileModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
knowledge_file = KnowledgeFileModel(
**{
"id": str(uuid.uuid4()),
"knowledge_id": knowledge_id,
"file_id": file_id,
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
'id': str(uuid.uuid4()),
'knowledge_id': knowledge_id,
'file_id': file_id,
'user_id': user_id,
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
)
try:
result = KnowledgeFile(**knowledge_file.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return KnowledgeFileModel.model_validate(result)
else:
@@ -613,118 +578,107 @@ class KnowledgeTable:
except Exception:
return None
def has_file(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
async def has_file(self, knowledge_id: str, file_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Check whether a file belongs to a knowledge base."""
try:
with get_db_context(db) as db:
return (
db.query(KnowledgeFile)
.filter_by(knowledge_id=knowledge_id, file_id=file_id)
.first()
is not None
async with get_async_db_context(db) as db:
result = await db.execute(
select(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id).limit(1)
)
return result.scalars().first() is not None
except Exception:
return False
def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
async def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[AsyncSession] = None
) -> bool:
try:
with get_db_context(db) as db:
db.query(KnowledgeFile).filter_by(
knowledge_id=knowledge_id, file_id=file_id
).delete()
db.commit()
async with get_async_db_context(db) as db:
await db.execute(delete(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id))
await db.commit()
return True
except Exception:
return False
def reset_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
async def reset_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[KnowledgeModel]:
try:
with get_db_context(db) as db:
async with get_async_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()
await db.execute(delete(KnowledgeFile).filter_by(knowledge_id=id))
await db.commit()
# Update the knowledge entry's updated_at timestamp
db.query(Knowledge).filter_by(id=id).update(
{
"updated_at": int(time.time()),
}
)
db.commit()
await db.execute(update(Knowledge).filter_by(id=id).values(updated_at=int(time.time())))
await db.commit()
return self.get_knowledge_by_id(id=id, db=db)
return await self.get_knowledge_by_id(id=id, db=db)
except Exception as e:
log.exception(e)
return None
def update_knowledge_by_id(
async def update_knowledge_by_id(
self,
id: str,
form_data: KnowledgeForm,
overwrite: bool = False,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[KnowledgeModel]:
try:
with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
**form_data.model_dump(exclude={"access_grants"}),
"updated_at": int(time.time()),
}
)
db.commit()
if form_data.access_grants is not None:
AccessGrants.set_access_grants(
"knowledge", id, form_data.access_grants, db=db
async with get_async_db_context(db) as db:
await db.execute(
update(Knowledge)
.filter_by(id=id)
.values(
**form_data.model_dump(exclude={'access_grants'}),
updated_at=int(time.time()),
)
return self.get_knowledge_by_id(id=id, db=db)
)
await db.commit()
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
return await 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, db: Optional[Session] = None
async def update_knowledge_data_by_id(
self, id: str, data: dict, db: Optional[AsyncSession] = None
) -> Optional[KnowledgeModel]:
try:
with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update(
{
"data": data,
"updated_at": int(time.time()),
}
async with get_async_db_context(db) as db:
await db.execute(
update(Knowledge)
.filter_by(id=id)
.values(
data=data,
updated_at=int(time.time()),
)
)
db.commit()
return self.get_knowledge_by_id(id=id, db=db)
await db.commit()
return await 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, db: Optional[Session] = None) -> bool:
async def delete_knowledge_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
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()
async with get_async_db_context(db) as db:
await AccessGrants.revoke_all_access('knowledge', id, db=db)
await db.execute(delete(Knowledge).filter_by(id=id))
await db.commit()
return True
except Exception:
return False
def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
async def delete_all_knowledge(self, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()]
result = await db.execute(select(Knowledge.id))
knowledge_ids = [row[0] for row in result.all()]
for knowledge_id in knowledge_ids:
AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db)
db.query(Knowledge).delete()
db.commit()
await AccessGrants.revoke_all_access('knowledge', knowledge_id, db=db)
await db.execute(delete(Knowledge))
await db.commit()
return True
except Exception:
+48 -51
View File
@@ -2,21 +2,24 @@ import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
####################
# Memory DB Schema
# What was learned at cost should not need to be paid
# for again. Let the memory hold.
####################
class Memory(Base):
__tablename__ = "memory"
__tablename__ = 'memory'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
user_id = Column(String, index=True)
content = Column(Text)
updated_at = Column(BigInteger)
created_at = Column(BigInteger)
@@ -38,118 +41,112 @@ class MemoryModel(BaseModel):
class MemoriesTable:
def insert_new_memory(
async def insert_new_memory(
self,
user_id: str,
content: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[MemoryModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
id = str(uuid.uuid4())
memory = MemoryModel(
**{
"id": id,
"user_id": user_id,
"content": content,
"created_at": int(time.time()),
"updated_at": int(time.time()),
'id': id,
'user_id': user_id,
'content': content,
'created_at': int(time.time()),
'updated_at': int(time.time()),
}
)
result = Memory(**memory.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
return MemoryModel.model_validate(result)
else:
return None
def update_memory_by_id_and_user_id(
async def update_memory_by_id_and_user_id(
self,
id: str,
user_id: str,
content: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[MemoryModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
try:
memory = db.get(Memory, id)
memory = await db.get(Memory, id)
if not memory or memory.user_id != user_id:
return None
memory.content = content
memory.updated_at = int(time.time())
db.commit()
db.refresh(memory)
await db.commit()
await db.refresh(memory)
return MemoryModel.model_validate(memory)
except Exception:
return None
def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]:
with get_db_context(db) as db:
async def get_memories(self, db: Optional[AsyncSession] = None) -> list[MemoryModel]:
async with get_async_db_context(db) as db:
try:
memories = db.query(Memory).all()
result = await db.execute(select(Memory))
memories = result.scalars().all()
return [MemoryModel.model_validate(memory) for memory in memories]
except Exception:
return None
def get_memories_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[MemoryModel]:
with get_db_context(db) as db:
async def get_memories_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[MemoryModel]:
async with get_async_db_context(db) as db:
try:
memories = db.query(Memory).filter_by(user_id=user_id).all()
result = await db.execute(select(Memory).filter_by(user_id=user_id))
memories = result.scalars().all()
return [MemoryModel.model_validate(memory) for memory in memories]
except Exception:
return None
def get_memory_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[MemoryModel]:
with get_db_context(db) as db:
async def get_memory_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[MemoryModel]:
async with get_async_db_context(db) as db:
try:
memory = db.get(Memory, id)
return MemoryModel.model_validate(memory)
memory = await db.get(Memory, id)
return MemoryModel.model_validate(memory) if memory else None
except Exception:
return None
def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
async def delete_memory_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
db.query(Memory).filter_by(id=id).delete()
db.commit()
await db.execute(delete(Memory).filter_by(id=id))
await db.commit()
return True
except Exception:
return False
def delete_memories_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
async def delete_memories_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
db.query(Memory).filter_by(user_id=user_id).delete()
db.commit()
await db.execute(delete(Memory).filter_by(user_id=user_id))
await db.commit()
return True
except Exception:
return False
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:
async def delete_memory_by_id_and_user_id(self, id: str, user_id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
try:
memory = db.get(Memory, id)
memory = await db.get(Memory, id)
if not memory or memory.user_id != user_id:
return None
# Delete the memory
db.delete(memory)
db.commit()
await db.delete(memory)
await db.commit()
return True
except Exception:
+179 -248
View File
@@ -3,8 +3,9 @@ import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_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
@@ -12,7 +13,7 @@ from open_webui.models.channels import Channels, ChannelMember
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy import or_, func, and_, text
from sqlalchemy.sql import exists
####################
@@ -21,7 +22,7 @@ from sqlalchemy.sql import exists
class MessageReaction(Base):
__tablename__ = "message_reaction"
__tablename__ = 'message_reaction'
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
message_id = Column(Text)
@@ -40,7 +41,7 @@ class MessageReactionModel(BaseModel):
class Message(Base):
__tablename__ = "message"
__tablename__ = 'message'
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
@@ -112,7 +113,7 @@ class MessageUserResponse(MessageModel):
class MessageUserSlimResponse(MessageUserResponse):
data: bool | None = None
@field_validator("data", mode="before")
@field_validator('data', mode='before')
def convert_data_to_bool(cls, v):
# No data or not a dict → False
if not isinstance(v, dict):
@@ -137,248 +138,211 @@ class MessageResponse(MessageReplyToResponse):
class MessageTable:
def insert_new_message(
async def insert_new_message(
self,
form_data: MessageForm,
channel_id: str,
user_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[MessageModel]:
with get_db_context(db) as db:
channel_member = Channels.join_channel(channel_id, user_id)
async with get_async_db_context(db) as db:
channel_member = await Channels.join_channel(channel_id, user_id)
id = str(uuid.uuid4())
ts = int(time.time_ns())
message = MessageModel(
**{
"id": id,
"user_id": user_id,
"channel_id": channel_id,
"reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id,
"is_pinned": False,
"pinned_at": None,
"pinned_by": None,
"content": form_data.content,
"data": form_data.data,
"meta": form_data.meta,
"created_at": ts,
"updated_at": ts,
'id': id,
'user_id': user_id,
'channel_id': channel_id,
'reply_to_id': form_data.reply_to_id,
'parent_id': form_data.parent_id,
'is_pinned': False,
'pinned_at': None,
'pinned_by': None,
'content': form_data.content,
'data': form_data.data,
'meta': form_data.meta,
'created_at': ts,
'updated_at': ts,
}
)
result = Message(**message.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
return MessageModel.model_validate(result) if result else None
def get_message_by_id(
async def get_message_by_id(
self,
id: str,
include_thread_replies: Optional[bool] = True,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[MessageResponse]:
with get_db_context(db) as db:
message = db.get(Message, id)
async with get_async_db_context(db) as db:
message = await db.get(Message, id)
if not message:
return None
reply_to_message = (
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
await 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, db=db)
reactions = await 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)
thread_replies = await 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"):
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)
webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
'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",
'id': webhook_info.get('id'),
'name': 'Deleted Webhook',
'role': 'webhook',
}
else:
user = Users.get_user_by_id(message.user_id, db=db)
user = await Users.get_user_by_id(message.user_id, db=db)
user_info = user.model_dump() if user else None
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
"latest_reply_at": (
thread_replies[0].created_at if thread_replies else None
),
"reply_count": len(thread_replies),
"reactions": reactions,
'user': user_info,
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
'latest_reply_at': (thread_replies[0].created_at if thread_replies else None),
'reply_count': len(thread_replies),
'reactions': reactions,
}
)
def get_thread_replies_by_message_id(
self, id: str, db: Optional[Session] = None
async def _resolve_user_info(self, message: Message, db: AsyncSession) -> Optional[dict]:
"""Resolve user info from message, handling webhook messages."""
webhook_info = message.meta.get('webhook') if message.meta else None
if webhook_info and webhook_info.get('id'):
webhook = await Channels.get_webhook_by_id(webhook_info.get('id'), db=db)
if webhook:
return {
'id': webhook.id,
'name': webhook.name,
'role': 'webhook',
}
else:
return {
'id': webhook_info.get('id'),
'name': 'Deleted Webhook',
'role': 'webhook',
}
return None
async def get_thread_replies_by_message_id(
self, id: str, db: Optional[AsyncSession] = None
) -> list[MessageReplyToResponse]:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
.filter_by(parent_id=id)
.order_by(Message.created_at.desc())
.all()
)
async with get_async_db_context(db) as db:
result = await db.execute(select(Message).filter_by(parent_id=id).order_by(Message.created_at.desc()))
all_messages = result.scalars().all()
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
await 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",
}
user_info = await self._resolve_user_info(message, db)
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
else None
),
'user': user_info,
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
}
)
)
return messages
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()
]
async def get_reply_user_ids_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Message.user_id).filter_by(parent_id=id))
return [row[0] for row in result.all()]
def get_messages_by_channel_id(
async def get_messages_by_channel_id(
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[MessageReplyToResponse]:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
async with get_async_db_context(db) as db:
result = await db.execute(
select(Message)
.filter_by(channel_id=channel_id, parent_id=None)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
all_messages = result.scalars().all()
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
await 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",
}
user_info = await self._resolve_user_info(message, db)
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
else None
),
'user': user_info,
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
}
)
)
return messages
def get_messages_by_parent_id(
async def get_messages_by_parent_id(
self,
channel_id: str,
parent_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[MessageReplyToResponse]:
with get_db_context(db) as db:
message = db.get(Message, parent_id)
async with get_async_db_context(db) as db:
message = await db.get(Message, parent_id)
if not message:
return []
all_messages = (
db.query(Message)
result = await db.execute(
select(Message)
.filter_by(channel_id=channel_id, parent_id=parent_id)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
all_messages = list(result.scalars().all())
# If length of all_messages is less than limit, then add the parent message
if len(all_messages) < limit:
@@ -387,80 +351,57 @@ class MessageTable:
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
await 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",
}
user_info = await self._resolve_user_info(message, db)
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
else None
),
'user': user_info,
'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None),
}
)
)
return messages
def get_last_message_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
async def get_last_message_by_channel_id(
self, channel_id: str, db: Optional[AsyncSession] = None
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = (
db.query(Message)
.filter_by(channel_id=channel_id)
.order_by(Message.created_at.desc())
.first()
async with get_async_db_context(db) as db:
result = await db.execute(
select(Message).filter_by(channel_id=channel_id).order_by(Message.created_at.desc()).limit(1)
)
message = result.scalars().first()
return MessageModel.model_validate(message) if message else None
def get_pinned_messages_by_channel_id(
async def get_pinned_messages_by_channel_id(
self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[MessageModel]:
with get_db_context(db) as db:
all_messages = (
db.query(Message)
async with get_async_db_context(db) as db:
result = await db.execute(
select(Message)
.filter_by(channel_id=channel_id, is_pinned=True)
.order_by(Message.pinned_at.desc())
.offset(skip)
.limit(limit)
.all()
)
all_messages = result.scalars().all()
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
self, id: str, form_data: MessageForm, db: Optional[Session] = None
async def update_message_by_id(
self, id: str, form_data: MessageForm, db: Optional[AsyncSession] = None
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = db.get(Message, id)
async with get_async_db_context(db) as db:
message = await db.get(Message, id)
message.content = form_data.content
message.data = {
**(message.data if message.data else {}),
@@ -471,53 +412,51 @@ class MessageTable:
**(form_data.meta if form_data.meta else {}),
}
message.updated_at = int(time.time_ns())
db.commit()
db.refresh(message)
await db.commit()
await db.refresh(message)
return MessageModel.model_validate(message) if message else None
def update_is_pinned_by_id(
async def update_is_pinned_by_id(
self,
id: str,
is_pinned: bool,
pinned_by: Optional[str] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = db.get(Message, id)
async with get_async_db_context(db) as db:
message = await db.get(Message, id)
message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None
message.pinned_by = pinned_by if is_pinned else None
db.commit()
db.refresh(message)
await db.commit()
await db.refresh(message)
return MessageModel.model_validate(message) if message else None
def get_unread_message_count(
async def get_unread_message_count(
self,
channel_id: str,
user_id: str,
last_read_at: Optional[int] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> int:
with get_db_context(db) as db:
query = db.query(Message).filter(
async with get_async_db_context(db) as db:
stmt = select(func.count(Message.id)).filter(
Message.channel_id == channel_id,
Message.parent_id == None, # only count top-level messages
Message.created_at > (last_read_at if last_read_at else 0),
)
if user_id:
query = query.filter(Message.user_id != user_id)
return query.count()
stmt = stmt.filter(Message.user_id != user_id)
result = await db.execute(stmt)
return result.scalar()
def add_reaction_to_message(
self, id: str, user_id: str, name: str, db: Optional[Session] = None
async def add_reaction_to_message(
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
) -> Optional[MessageReactionModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# check for existing reaction
existing_reaction = (
db.query(MessageReaction)
.filter_by(message_id=id, user_id=user_id, name=name)
.first()
)
result = await db.execute(select(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name))
existing_reaction = result.scalars().first()
if existing_reaction:
return MessageReactionModel.model_validate(existing_reaction)
@@ -531,102 +470,94 @@ class MessageTable:
)
result = MessageReaction(**reaction.model_dump())
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(
self, id: str, db: Optional[Session] = None
) -> list[Reactions]:
with get_db_context(db) as db:
async def get_reactions_by_message_id(self, id: str, db: Optional[AsyncSession] = None) -> list[Reactions]:
async with get_async_db_context(db) as db:
# JOIN User so all user info is fetched in one query
results = (
db.query(MessageReaction, User)
result = await db.execute(
select(MessageReaction, User)
.join(User, MessageReaction.user_id == User.id)
.filter(MessageReaction.message_id == id)
.all()
)
results = result.all()
reactions = {}
for reaction, user in results:
if reaction.name not in reactions:
reactions[reaction.name] = {
"name": reaction.name,
"users": [],
"count": 0,
'name': reaction.name,
'users': [],
'count': 0,
}
reactions[reaction.name]["users"].append(
reactions[reaction.name]['users'].append(
{
"id": user.id,
"name": user.name,
'id': user.id,
'name': user.name,
}
)
reactions[reaction.name]["count"] += 1
reactions[reaction.name]['count'] += 1
return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str, db: Optional[Session] = None
async def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
) -> bool:
with get_db_context(db) as db:
db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name
).delete()
db.commit()
async with get_async_db_context(db) as db:
await db.execute(delete(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name))
await db.commit()
return True
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()
async def delete_reactions_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
await db.execute(delete(MessageReaction).filter_by(message_id=id))
await db.commit()
return True
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()
async def delete_replies_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
await db.execute(delete(Message).filter_by(parent_id=id))
await db.commit()
return True
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()
async def delete_message_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
async with get_async_db_context(db) as db:
await db.execute(delete(Message).filter_by(id=id))
# Delete all reactions to this message
db.query(MessageReaction).filter_by(message_id=id).delete()
await db.execute(delete(MessageReaction).filter_by(message_id=id))
db.commit()
await db.commit()
return True
def search_messages_by_channel_ids(
async 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,
db: Optional[AsyncSession] = None,
) -> list[MessageModel]:
"""Search messages in specified channels by content."""
with get_db_context(db) as db:
query_builder = db.query(Message).filter(
async with get_async_db_context(db) as db:
stmt = select(Message).filter(
Message.channel_id.in_(channel_ids),
Message.content.ilike(f"%{query}%"),
Message.content.ilike(f'%{query}%'),
)
if start_timestamp:
query_builder = query_builder.filter(
Message.created_at >= start_timestamp
)
stmt = stmt.filter(Message.created_at >= start_timestamp)
if end_timestamp:
query_builder = query_builder.filter(
Message.created_at <= end_timestamp
)
stmt = stmt.filter(Message.created_at <= end_timestamp)
messages = (
query_builder.order_by(Message.created_at.desc()).limit(limit).all()
)
stmt = stmt.order_by(Message.created_at.desc()).limit(limit)
result = await db.execute(stmt)
messages = result.scalars().all()
return [MessageModel.model_validate(msg) for msg in messages]
+279 -206
View File
@@ -1,19 +1,18 @@
import json
import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, update, or_, func, String, cast
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_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, Field
from sqlalchemy import String, cast, or_, and_, func
from sqlalchemy.dialects import postgresql, sqlite
from pydantic import BaseModel, ConfigDict, Field, model_validator
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Column, Text, Boolean
@@ -23,18 +22,20 @@ log = logging.getLogger(__name__)
####################
# Models DB Schema
# A misconfigured model wastes the time of everyone
# who trusts it. Let what is set here be set with care.
####################
# ModelParams is a model for the data stored in the params field of the Model table
class ModelParams(BaseModel):
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
pass
# ModelMeta is a model for the data stored in the meta field of the Model table
class ModelMeta(BaseModel):
profile_image_url: Optional[str] = "/static/favicon.png"
profile_image_url: Optional[str] = '/static/favicon.png'
description: Optional[str] = None
"""
@@ -43,13 +44,26 @@ class ModelMeta(BaseModel):
capabilities: Optional[dict] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
pass
@model_validator(mode='before')
@classmethod
def normalize_tags(cls, data):
if isinstance(data, dict) and 'tags' in data:
raw_tags = data['tags']
if isinstance(raw_tags, list):
normalized = []
for tag in raw_tags:
if isinstance(tag, str):
normalized.append({'name': tag})
elif isinstance(tag, dict) and 'name' in tag:
normalized.append(tag)
data['tags'] = normalized
return data
class Model(Base):
__tablename__ = "model"
__tablename__ = 'model'
id = Column(Text, primary_key=True, unique=True)
"""
@@ -129,6 +143,8 @@ class ModelAccessListResponse(BaseModel):
class ModelForm(BaseModel):
model_config = ConfigDict(extra='ignore')
id: str
base_model_id: Optional[str] = None
name: str
@@ -139,77 +155,69 @@ class ModelForm(BaseModel):
class ModelsTable:
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)
async def _get_access_grants(self, model_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('model', model_id, db=db)
def _to_model_model(
async def _to_model_model(
self,
model: Model,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(
exclude={"access_grants"}
)
model_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(model_data["id"], db=db)
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
model_data['access_grants'] = (
access_grants if access_grants is not None else await 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
async def insert_new_model(
self, form_data: ModelForm, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[ModelModel]:
try:
with get_db_context(db) as db:
async with get_async_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()),
**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
)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db)
if result:
return self._to_model_model(result, db=db)
return await self._to_model_model(result, db=db)
else:
return None
except Exception as e:
log.exception(f"Failed to insert a new model: {e}")
log.exception(f'Failed to insert a new model: {e}')
return None
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
all_models = db.query(Model).all()
async def get_all_models(self, db: Optional[AsyncSession] = None) -> list[ModelModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model))
all_models = result.scalars().all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
return [
self._to_model_model(
model, access_grants=grants_map.get(model.id, []), db=db
)
await self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db)
for model in all_models
]
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()
async def get_models(self, db: Optional[AsyncSession] = None) -> list[ModelUserResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter(Model.base_model_id != None))
all_models = result.scalars().all()
user_ids = list(set(model.user_id for model in all_models))
model_ids = [model.id for model in all_models]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
models = []
for model in all_models:
@@ -217,184 +225,235 @@ class ModelsTable:
models.append(
ModelUserResponse.model_validate(
{
**self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
**(
await self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
)
).model_dump(),
"user": user.model_dump() if user else None,
'user': user.model_dump() if user else None,
}
)
)
return models
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
all_models = db.query(Model).filter(Model.base_model_id == None).all()
async def get_base_models(self, db: Optional[AsyncSession] = None) -> list[ModelModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter(Model.base_model_id == None))
all_models = result.scalars().all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
return [
self._to_model_model(
model, access_grants=grants_map.get(model.id, []), db=db
)
await self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db)
for model in all_models
]
def get_models_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
async def get_models_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
) -> list[ModelUserResponse]:
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 AccessGrants.has_access(
models = await self.get_models(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
result = []
for model in models:
if model.user_id == user_id:
result.append(model)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type="model",
resource_type='model',
resource_id=model.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
):
result.append(model)
return result
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Model,
filter=filter,
resource_type="model",
resource_type='model',
permission=permission,
)
def search_models(
async def search_models(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> ModelListResponse:
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)
async with get_async_db_context(db) as db:
stmt = select(Model, User).outerjoin(User, User.id == Model.user_id)
stmt = stmt.filter(Model.base_model_id != None)
if filter:
query_key = filter.get("query")
query_key = filter.get('query')
if query_key:
query = query.filter(
stmt = stmt.filter(
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}%"),
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}%'),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Model.user_id == user_id)
elif view_option == "shared":
query = query.filter(Model.user_id != user_id)
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Model.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Model.user_id != user_id)
# Apply access control filtering
query = self._has_permission(
stmt = self._has_permission(
db,
query,
stmt,
filter,
permission="read",
permission='read',
)
tag = filter.get("tag")
tag = filter.get('tag')
if tag:
# TODO: This is a simple implementation and should be improved for performance
like_pattern = f'%"{tag.lower()}"%' # `"tag"` inside JSON array
meta_text = func.lower(cast(Model.meta, String))
query = query.filter(meta_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(Model.name.asc())
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
# stores literal Unicode. Use the right pattern for each.
if db.bind.dialect.name == 'sqlite':
if tag.isascii():
meta_text = func.lower(cast(Model.meta, String))
pattern = f'%{json.dumps(tag.lower())}%'
else:
meta_text = cast(Model.meta, String)
pattern = f'%{json.dumps(tag)}%'
else:
query = query.order_by(Model.name.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Model.created_at.asc())
meta_text = func.lower(cast(Model.meta, String))
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
stmt = stmt.filter(meta_text.like(pattern))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Model.name.asc())
else:
query = query.order_by(Model.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Model.updated_at.asc())
stmt = stmt.order_by(Model.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Model.created_at.asc())
else:
query = query.order_by(Model.updated_at.desc())
stmt = stmt.order_by(Model.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Model.updated_at.asc())
else:
stmt = stmt.order_by(Model.updated_at.desc())
else:
query = query.order_by(Model.created_at.desc())
stmt = stmt.order_by(Model.created_at.desc())
# Count BEFORE pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
items = query.all()
result = await db.execute(stmt)
items = result.all()
model_ids = [model.id for model, _ in items]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
models = []
for model, user in items:
models.append(
ModelUserResponse(
**self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
**(
await self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
)
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return ModelListResponse(items=models, total=total)
def get_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
async def get_model_meta_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[tuple[dict, int]]:
"""Return (meta, updated_at) for a model, skipping access grant resolution."""
try:
with get_db_context(db) as db:
model = db.get(Model, id)
return self._to_model_model(model, db=db) if model else None
async with get_async_db_context(db) as db:
result = await db.execute(select(Model.meta, Model.updated_at).filter_by(id=id))
return result.first()
except Exception:
return None
def get_models_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[ModelModel]:
async def get_all_tags(
self,
user_id: str,
is_admin: bool = False,
db: Optional[AsyncSession] = None,
) -> set[str]:
"""Extract unique tag names from model meta, querying only the meta column."""
async with get_async_db_context(db) as db:
stmt = select(Model.meta).filter(Model.base_model_id != None)
if not is_admin:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
filter_dict = {'user_id': user_id}
if user_group_ids:
filter_dict['group_ids'] = user_group_ids
stmt = self._has_permission(db, stmt, filter_dict, permission='read')
result = await db.execute(stmt)
rows = result.scalars().all()
tags_set: set[str] = set()
for meta in rows:
if not meta:
continue
for tag in meta.get('tags', []):
try:
name = tag.get('name') if isinstance(tag, dict) else str(tag)
if name:
tags_set.add(name)
except Exception:
continue
return tags_set
async def get_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]:
try:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
async with get_async_db_context(db) as db:
model = await db.get(Model, id)
return await self._to_model_model(model, db=db) if model else None
except Exception:
return None
async def get_models_by_ids(self, ids: list[str], db: Optional[AsyncSession] = None) -> list[ModelModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter(Model.id.in_(ids)))
models = result.scalars().all()
model_ids = [model.id for model in models]
grants_map = AccessGrants.get_grants_by_resources(
"model", model_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
return [
self._to_model_model(
await self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
@@ -404,75 +463,90 @@ class ModelsTable:
except Exception:
return []
def toggle_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
with get_db_context(db) as db:
async def toggle_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]:
async with get_async_db_context(db) as db:
try:
model = db.query(Model).filter_by(id=id).first()
result = await db.execute(select(Model).filter_by(id=id))
model = result.scalars().first()
if not model:
return None
model.is_active = not model.is_active
model.updated_at = int(time.time())
db.commit()
db.refresh(model)
await db.commit()
await db.refresh(model)
return self._to_model_model(model, db=db)
return await self._to_model_model(model, db=db)
except Exception:
return None
def update_model_by_id(
self, id: str, model: ModelForm, db: Optional[Session] = None
async def update_model_by_id(
self, id: str, model: ModelForm, db: Optional[AsyncSession] = None
) -> Optional[ModelModel]:
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# update only the fields that are present in the model
data = model.model_dump(exclude={"id", "access_grants"})
result = db.query(Model).filter_by(id=id).update(data)
data = model.model_dump(exclude={'id', 'access_grants'})
data['updated_at'] = int(time.time())
await db.execute(update(Model).filter_by(id=id).values(**data))
db.commit()
await db.commit()
if model.access_grants is not None:
AccessGrants.set_access_grants(
"model", id, model.access_grants, db=db
)
await AccessGrants.set_access_grants('model', id, model.access_grants, db=db)
return self.get_model_by_id(id, db=db)
return await self.get_model_by_id(id, db=db)
except Exception as e:
log.exception(f"Failed to update the model by id {id}: {e}")
log.exception(f'Failed to update the model by id {id}: {e}')
return None
def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
async def update_model_updated_at_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ModelModel]:
try:
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()
async with get_async_db_context(db) as db:
result = await db.execute(select(Model).filter_by(id=id))
model_obj = result.scalars().first()
if not model_obj:
return None
model_obj.updated_at = int(time.time())
await db.commit()
await db.refresh(model_obj)
return await self._to_model_model(model_obj, db=db)
except Exception as e:
log.exception(f'Failed to update the model updated_at by id {id}: {e}')
return None
async def delete_model_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
await AccessGrants.revoke_all_access('model', id, db=db)
await db.execute(delete(Model).filter_by(id=id))
await db.commit()
return True
except Exception:
return False
def delete_all_models(self, db: Optional[Session] = None) -> bool:
async def delete_all_models(self, db: Optional[AsyncSession] = None) -> bool:
try:
with get_db_context(db) as db:
model_ids = [row[0] for row in db.query(Model.id).all()]
async with get_async_db_context(db) as db:
result = await db.execute(select(Model.id))
model_ids = [row[0] for row in result.all()]
for model_id in model_ids:
AccessGrants.revoke_all_access("model", model_id, db=db)
db.query(Model).delete()
db.commit()
await AccessGrants.revoke_all_access('model', model_id, db=db)
await db.execute(delete(Model))
await db.commit()
return True
except Exception:
return False
def sync_models(
self, user_id: str, models: list[ModelModel], db: Optional[Session] = None
async def sync_models(
self, user_id: str, models: list[ModelModel], db: Optional[AsyncSession] = None
) -> list[ModelModel]:
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
# Get existing models
existing_models = db.query(Model).all()
result = await db.execute(select(Model))
existing_models = result.scalars().all()
existing_ids = {model.id for model in existing_models}
# Prepare a set of new model IDs
@@ -481,41 +555,40 @@ class ModelsTable:
# Update or insert models
for model in models:
if model.id in existing_ids:
db.query(Model).filter_by(id=model.id).update(
{
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
}
await db.execute(
update(Model)
.filter_by(id=model.id)
.values(
**model.model_dump(exclude={'access_grants'}),
user_id=user_id,
updated_at=int(time.time()),
)
)
else:
new_model = Model(
**{
**model.model_dump(exclude={"access_grants"}),
"user_id": user_id,
"updated_at": int(time.time()),
**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
)
await 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)
await AccessGrants.revoke_all_access('model', model.id, db=db)
await db.delete(model)
db.commit()
await db.commit()
all_models = db.query(Model).all()
result = await db.execute(select(Model))
all_models = result.scalars().all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources(
"model", model_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('model', model_ids, db=db)
return [
self._to_model_model(
await self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
@@ -523,7 +596,7 @@ class ModelsTable:
for model in all_models
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
log.exception(f'Error syncing models for user {user_id}: {e}')
return []
+203 -155
View File
@@ -4,16 +4,16 @@ import uuid
from typing import Optional
from functools import lru_cache
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from sqlalchemy import Boolean, select, delete, update, or_, func, cast
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_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, Field
from sqlalchemy import BigInteger, Column, Text, JSON
from sqlalchemy import or_, func, cast
from sqlalchemy import BigInteger, Column, Text, JSON, ForeignKey
####################
# Note DB Schema
@@ -21,7 +21,7 @@ from sqlalchemy import or_, func, cast
class Note(Base):
__tablename__ = "note"
__tablename__ = 'note'
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text)
@@ -43,6 +43,7 @@ class NoteModel(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
is_pinned: Optional[bool] = False
access_grants: list[AccessGrantModel] = Field(default_factory=list)
@@ -50,6 +51,15 @@ class NoteModel(BaseModel):
updated_at: int # timestamp in epoch
class PinnedNote(Base):
__tablename__ = 'pinned_note'
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
note_id = Column(Text, ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
created_at = Column(BigInteger, nullable=False)
####################
# Forms
####################
@@ -77,6 +87,7 @@ class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
is_pinned: Optional[bool] = False
updated_at: int
created_at: int
user: Optional[UserResponse] = None
@@ -88,259 +99,296 @@ 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)
async def _get_access_grants(self, note_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('note', note_id, db=db)
def _to_note_model(
async def _to_note_model(
self,
note: Note,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> NoteModel:
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
note_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(note_data["id"], db=db)
# We exclude access_grants to inject them
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
note_data['access_grants'] = (
access_grants if access_grants is not None else await 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"):
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
return AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Note,
filter=filter,
resource_type="note",
resource_type='note',
permission=permission,
)
def insert_new_note(
self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
async def insert_new_note(
self, user_id: str, form_data: NoteForm, db: Optional[AsyncSession] = None
) -> Optional[NoteModel]:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
note = NoteModel(
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(exclude={"access_grants"}),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
"access_grants": [],
'id': str(uuid.uuid4()),
'user_id': user_id,
**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(exclude={"access_grants"}))
new_note = Note(**note.model_dump(exclude={'access_grants'}))
db.add(new_note)
db.commit()
AccessGrants.set_access_grants(
"note", note.id, form_data.access_grants, db=db
)
return self._to_note_model(new_note, db=db)
await db.commit()
await AccessGrants.set_access_grants('note', note.id, form_data.access_grants, db=db)
return await self._to_note_model(new_note, db=db)
def get_notes(
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[NoteModel]:
with get_db_context(db) as db:
query = db.query(Note).order_by(Note.updated_at.desc())
async def get_notes(self, skip: int = 0, limit: int = 50, db: Optional[AsyncSession] = None) -> list[NoteModel]:
async with get_async_db_context(db) as db:
stmt = select(Note).order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
stmt = stmt.limit(limit)
result = await db.execute(stmt)
notes = result.scalars().all()
note_ids = [note.id for note in notes]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
return [
self._to_note_model(
note, access_grants=grants_map.get(note.id, []), db=db
)
for note in notes
]
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
def search_notes(
async def search_notes(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> NoteListResponse:
with get_db_context(db) as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
async with get_async_db_context(db) as db:
stmt = select(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
query_key = filter.get('query')
if query_key:
# Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do")
normalized_query = query_key.replace("-", "").replace(" ", "")
query = query.filter(
or_(
func.replace(
func.replace(Note.title, "-", ""), " ", ""
).ilike(f"%{normalized_query}%"),
func.replace(
# Split query into individual words and normalize each
# (strip hyphens so "todo" matches "to-do").
# All words must match somewhere in title OR content (AND semantics).
search_words = query_key.split()
normalized_words = [w.replace('-', '') for w in search_words if w.replace('-', '')]
for word in normalized_words:
stmt = stmt.filter(
or_(
func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{word}%'),
func.replace(
cast(Note.data["content"]["md"], Text), "-", ""
),
" ",
"",
).ilike(f"%{normalized_query}%"),
func.replace(cast(Note.data['content']['md'], Text), '-', ''),
' ',
'',
).ilike(f'%{word}%'),
)
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Note.user_id == user_id)
elif view_option == "shared":
query = query.filter(Note.user_id != user_id)
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Note.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Note.user_id != user_id)
# Apply access control filtering
if "permission" in filter:
permission = filter["permission"]
if 'permission' in filter:
permission = filter['permission']
else:
permission = "write"
permission = 'write'
query = self._has_permission(
stmt = self._has_permission(
db,
query,
stmt,
filter,
permission=permission,
)
order_by = filter.get("order_by")
direction = filter.get("direction")
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == "name":
if direction == "asc":
query = query.order_by(Note.title.asc())
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Note.title.asc())
else:
query = query.order_by(Note.title.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Note.created_at.asc())
stmt = stmt.order_by(Note.title.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Note.created_at.asc())
else:
query = query.order_by(Note.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Note.updated_at.asc())
stmt = stmt.order_by(Note.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Note.updated_at.asc())
else:
query = query.order_by(Note.updated_at.desc())
stmt = stmt.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
stmt = stmt.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
stmt = stmt.order_by(Note.updated_at.desc())
# Count BEFORE pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
items = query.all()
result = await db.execute(stmt)
items = result.all()
note_ids = [note.id for note, _ in items]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**self._to_note_model(
note,
access_grants=grants_map.get(note.id, []),
db=db,
**(
await self._to_note_model(
note,
access_grants=grants_map.get(note.id, []),
db=db,
)
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return NoteListResponse(items=notes, total=total)
def get_notes_by_user_id(
async def get_notes_by_user_id(
self,
user_id: str,
permission: str = "read",
permission: str = 'read',
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> list[NoteModel]:
with get_db_context(db) as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
]
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
query = db.query(Note).order_by(Note.updated_at.desc())
query = self._has_permission(
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
)
stmt = select(Note).order_by(Note.updated_at.desc())
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
if skip is not None:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit is not None:
query = query.limit(limit)
stmt = stmt.limit(limit)
notes = query.all()
result = await db.execute(stmt)
notes = result.scalars().all()
note_ids = [note.id for note in notes]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
return [
self._to_note_model(
note, access_grants=grants_map.get(note.id, []), db=db
)
for note in notes
]
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
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 self._to_note_model(note, db=db) if note else None
async def get_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Note).filter(Note.id == id))
note = result.scalars().first()
return await self._to_note_model(note, db=db) if note else None
def update_note_by_id(
self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
async def update_note_by_id(
self, id: str, form_data: NoteUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[NoteModel]:
with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Note).filter(Note.id == id))
note = result.scalars().first()
if not note:
return None
form_data = form_data.model_dump(exclude_unset=True)
if "title" in form_data:
note.title = form_data["title"]
if "data" in form_data:
note.data = {**note.data, **form_data["data"]}
if "meta" in form_data:
note.meta = {**note.meta, **form_data["meta"]}
if 'title' in form_data:
note.title = form_data['title']
if 'data' in form_data:
note.data = {**note.data, **form_data['data']}
if 'meta' in form_data:
note.meta = {**note.meta, **form_data['meta']}
if "access_grants" in form_data:
AccessGrants.set_access_grants(
"note", id, form_data["access_grants"], db=db
)
if 'access_grants' in form_data:
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
note.updated_at = int(time.time_ns())
db.commit()
return self._to_note_model(note, db=db) if note else None
await db.commit()
return await self._to_note_model(note, db=db) if note else None
def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
async def toggle_note_pinned_by_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[NoteModel]:
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()
async with get_async_db_context(db) as db:
result = await db.execute(select(Note).filter(Note.id == id))
note = result.scalars().first()
if not note:
return None
# Check if already pinned
pin_result = await db.execute(select(PinnedNote).filter_by(user_id=user_id, note_id=id))
pinned_note = pin_result.scalars().first()
if pinned_note:
await db.execute(delete(PinnedNote).filter_by(user_id=user_id, note_id=id))
else:
new_pin = PinnedNote(
id=str(uuid.uuid4()), user_id=user_id, note_id=id, created_at=int(time.time_ns())
)
db.add(new_pin)
await db.commit()
return await self._to_note_model(note, db=db)
except Exception:
return None
async def get_pinned_notes_by_user_id(
self,
user_id: str,
permission: str = 'read',
db: Optional[AsyncSession] = None,
) -> list[NoteModel]:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
stmt = (
select(Note)
.join(PinnedNote, PinnedNote.note_id == Note.id)
.filter(PinnedNote.user_id == user_id)
.order_by(PinnedNote.created_at.desc())
)
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
result = await db.execute(stmt)
notes = result.scalars().all()
note_ids = [note.id for note in notes]
grants_map = await AccessGrants.get_grants_by_resources('note', note_ids, db=db)
return [await self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes]
async def delete_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
await AccessGrants.revoke_all_access('note', id, db=db)
await db.execute(delete(PinnedNote).filter(PinnedNote.note_id == id))
await db.execute(delete(Note).filter(Note.id == id))
await db.commit()
return True
except Exception:
return False
async def get_pinned_note_ids(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
async with get_async_db_context(db) as db:
result = await db.execute(select(PinnedNote.note_id).filter_by(user_id=user_id))
return result.scalars().all()
Notes = NoteTable()
+153 -107
View File
@@ -8,8 +8,9 @@ import json
from cryptography.fernet import Fernet
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from sqlalchemy import select, delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
@@ -23,23 +24,21 @@ log = logging.getLogger(__name__)
class OAuthSession(Base):
__tablename__ = "oauth_session"
__tablename__ = 'oauth_session'
id = Column(Text, primary_key=True, unique=True)
user_id = Column(Text, nullable=False)
provider = Column(Text, nullable=False)
token = Column(
Text, nullable=False
) # JSON with access_token, id_token, refresh_token
token = Column(Text, nullable=False) # JSON with access_token, id_token, refresh_token
expires_at = Column(BigInteger, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
# Add indexes for better performance
__table_args__ = (
Index("idx_oauth_session_user_id", "user_id"),
Index("idx_oauth_session_expires_at", "expires_at"),
Index("idx_oauth_session_user_provider", "user_id", "provider"),
Index('idx_oauth_session_user_id', 'user_id'),
Index('idx_oauth_session_expires_at', 'expires_at'),
Index('idx_oauth_session_user_provider', 'user_id', 'provider'),
)
@@ -71,7 +70,7 @@ class OAuthSessionTable:
def __init__(self):
self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
if not self.encryption_key:
raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
raise Exception('OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set')
# check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
if len(self.encryption_key) != 44:
@@ -83,7 +82,7 @@ class OAuthSessionTable:
try:
self.fernet = Fernet(self.encryption_key)
except Exception as e:
log.error(f"Error initializing Fernet with provided key: {e}")
log.error(f'Error initializing Fernet with provided key: {e}')
raise
def _encrypt_token(self, token) -> str:
@@ -93,7 +92,7 @@ class OAuthSessionTable:
encrypted = self.fernet.encrypt(token_json.encode()).decode()
return encrypted
except Exception as e:
log.error(f"Error encrypting tokens: {e}")
log.error(f'Error encrypting tokens: {e}')
raise
def _decrypt_token(self, token: str):
@@ -102,200 +101,247 @@ class OAuthSessionTable:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting tokens: {type(e).__name__}: {e}")
log.error(f'Error decrypting tokens: {type(e).__name__}: {e}')
raise
def create_session(
async def create_session(
self,
user_id: str,
provider: str,
token: dict,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session"""
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
current_time = int(time.time())
id = str(uuid.uuid4())
result = OAuthSession(
**{
"id": id,
"user_id": user_id,
"provider": provider,
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"created_at": current_time,
"updated_at": current_time,
'id': id,
'user_id': user_id,
'provider': provider,
'token': self._encrypt_token(token),
'expires_at': token.get('expires_at') or int(time.time() + 3600),
'created_at': current_time,
'updated_at': current_time,
}
)
db.add(result)
db.commit()
db.refresh(result)
await db.commit()
await db.refresh(result)
if result:
db.expunge(result) # Detach so dict swap is never flushed
result.token = token # Return decrypted token
return OAuthSessionModel.model_validate(result)
# Make a copy of the model data before closing session
model = OAuthSessionModel(
id=result.id,
user_id=result.user_id,
provider=result.provider,
token=token, # Return decrypted token
expires_at=result.expires_at,
created_at=result.created_at,
updated_at=result.updated_at,
)
return model
else:
return None
except Exception as e:
log.error(f"Error creating OAuth session: {e}")
log.error(f'Error creating OAuth session: {e}')
return None
def get_session_by_id(
self, session_id: str, db: Optional[Session] = None
async def get_session_by_id(
self, session_id: str, db: Optional[AsyncSession] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID"""
try:
with get_db_context(db) as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(OAuthSession).filter_by(id=session_id))
session = result.scalars().first()
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return OAuthSessionModel(
id=session.id,
user_id=session.user_id,
provider=session.provider,
token=self._decrypt_token(session.token),
expires_at=session.expires_at,
created_at=session.created_at,
updated_at=session.updated_at,
)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
log.error(f'Error getting OAuth session by ID: {e}')
return None
def get_session_by_id_and_user_id(
self, session_id: str, user_id: str, db: Optional[Session] = None
async def get_session_by_id_and_user_id(
self, session_id: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID"""
try:
with get_db_context(db) as db:
session = (
db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id)
.first()
)
async with get_async_db_context(db) as db:
result = await db.execute(select(OAuthSession).filter_by(id=session_id, user_id=user_id))
session = result.scalars().first()
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return OAuthSessionModel(
id=session.id,
user_id=session.user_id,
provider=session.provider,
token=self._decrypt_token(session.token),
expires_at=session.expires_at,
created_at=session.created_at,
updated_at=session.updated_at,
)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
log.error(f'Error getting OAuth session by ID: {e}')
return None
def get_session_by_provider_and_user_id(
self, provider: str, user_id: str, db: Optional[Session] = None
async def get_session_by_provider_and_user_id(
self, provider: str, user_id: str, db: Optional[AsyncSession] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by provider and user ID"""
try:
with get_db_context(db) as db:
session = (
db.query(OAuthSession)
async with get_async_db_context(db) as db:
result = await db.execute(
select(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
.order_by(OAuthSession.created_at.desc())
.first()
)
session = result.scalars().first()
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return OAuthSessionModel(
id=session.id,
user_id=session.user_id,
provider=session.provider,
token=self._decrypt_token(session.token),
expires_at=session.expires_at,
created_at=session.created_at,
updated_at=session.updated_at,
)
return None
except Exception as e:
log.error(f"Error getting OAuth session by provider and user ID: {e}")
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, db: Optional[Session] = None
) -> List[OAuthSessionModel]:
async def get_sessions_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
with get_db_context(db) as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
async with get_async_db_context(db) as db:
result = await db.execute(select(OAuthSession).filter_by(user_id=user_id))
sessions = result.scalars().all()
results = []
for session in sessions:
try:
db.expunge(session)
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
results.append(
OAuthSessionModel(
id=session.id,
user_id=session.user_id,
provider=session.provider,
token=self._decrypt_token(session.token),
expires_at=session.expires_at,
created_at=session.created_at,
updated_at=session.updated_at,
)
)
except Exception as e:
log.warning(
f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}"
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()
await db.execute(delete(OAuthSession).filter_by(id=session.id))
await db.commit()
return results
except Exception as e:
log.error(f"Error getting OAuth sessions by user ID: {e}")
log.error(f'Error getting OAuth sessions by user ID: {e}')
return []
def update_session_by_id(
self, session_id: str, token: dict, db: Optional[Session] = None
async def update_session_by_id(
self, session_id: str, token: dict, db: Optional[AsyncSession] = None
) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens"""
try:
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update(
{
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"updated_at": current_time,
}
await db.execute(
update(OAuthSession)
.filter_by(id=session_id)
.values(
token=self._encrypt_token(token),
expires_at=token.get('expires_at') or int(time.time() + 3600),
updated_at=current_time,
)
)
db.commit()
session = db.query(OAuthSession).filter_by(id=session_id).first()
await db.commit()
result = await db.execute(select(OAuthSession).filter_by(id=session_id))
session = result.scalars().first()
if session:
db.expunge(session)
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return OAuthSessionModel(
id=session.id,
user_id=session.user_id,
provider=session.provider,
token=self._decrypt_token(session.token),
expires_at=session.expires_at,
created_at=session.created_at,
updated_at=session.updated_at,
)
return None
except Exception as e:
log.error(f"Error updating OAuth session tokens: {e}")
log.error(f'Error updating OAuth session tokens: {e}')
return None
def delete_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> bool:
async def delete_session_by_id(self, session_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete an OAuth session"""
try:
with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit()
return result > 0
async with get_async_db_context(db) as db:
result = await db.execute(delete(OAuthSession).filter_by(id=session_id))
await db.commit()
return result.rowcount > 0
except Exception as e:
log.error(f"Error deleting OAuth session: {e}")
log.error(f'Error deleting OAuth session: {e}')
return False
def delete_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
async def delete_sessions_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all OAuth sessions for a user"""
try:
with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit()
async with get_async_db_context(db) as db:
await db.execute(delete(OAuthSession).filter_by(user_id=user_id))
await db.commit()
return True
except Exception as e:
log.error(f"Error deleting OAuth sessions by user ID: {e}")
log.error(f'Error deleting OAuth sessions by user ID: {e}')
return False
def delete_sessions_by_provider(
self, provider: str, db: Optional[Session] = None
async def delete_sessions_by_user_id_and_provider(
self, user_id: str, provider: str, db: Optional[AsyncSession] = None
) -> bool:
"""Delete all OAuth sessions for a specific user and provider"""
try:
async with get_async_db_context(db) as db:
result = await db.execute(delete(OAuthSession).filter_by(user_id=user_id, provider=provider))
await db.commit()
return result.rowcount > 0
except Exception as e:
log.error(f'Error deleting OAuth sessions for user {user_id} and provider {provider}: {e}')
return False
async def delete_sessions_by_provider(self, provider: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all OAuth sessions for a provider"""
try:
with get_db_context(db) as db:
db.query(OAuthSession).filter_by(provider=provider).delete()
db.commit()
async with get_async_db_context(db) as db:
await db.execute(delete(OAuthSession).filter_by(provider=provider))
await db.commit()
return True
except Exception as e:
log.error(f"Error deleting OAuth sessions by provider {provider}: {e}")
log.error(f'Error deleting OAuth sessions by provider {provider}: {e}')
return False
+67 -71
View File
@@ -6,8 +6,9 @@ from typing import Optional
import json
import difflib
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db_context
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
@@ -19,7 +20,7 @@ from sqlalchemy import BigInteger, Column, Text, JSON, Index
class PromptHistory(Base):
__tablename__ = "prompt_history"
__tablename__ = 'prompt_history'
id = Column(Text, primary_key=True)
prompt_id = Column(Text, nullable=False, index=True)
@@ -49,17 +50,17 @@ class PromptHistoryResponse(PromptHistoryModel):
class PromptHistoryTable:
def create_history_entry(
async 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,
db: Optional[AsyncSession] = None,
) -> Optional[PromptHistoryModel]:
"""Create a new history entry (commit) for a prompt."""
with get_db_context(db) as db:
async with get_async_db_context(db) as db:
history = PromptHistory(
id=str(uuid.uuid4()),
prompt_id=prompt_id,
@@ -70,101 +71,96 @@ class PromptHistoryTable:
created_at=int(time.time()),
)
db.add(history)
db.commit()
db.refresh(history)
await db.commit()
await db.refresh(history)
return PromptHistoryModel.model_validate(history)
def get_history_by_prompt_id(
async def get_history_by_prompt_id(
self,
prompt_id: str,
limit: int = 50,
offset: int = 0,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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)
async with get_async_db_context(db) as db:
result = await db.execute(
select(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
entries = result.scalars().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 = await 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
),
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(
async def get_history_entry_by_id(
self,
history_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
)
async with get_async_db_context(db) as db:
result = await db.execute(select(PromptHistory).filter(PromptHistory.id == history_id))
entry = result.scalars().first()
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_latest_history_entry(
async def get_latest_history_entry(
self,
prompt_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[PromptHistoryModel]:
"""Get the most recent history entry for a prompt."""
with get_db_context(db) as db:
entry = (
db.query(PromptHistory)
async with get_async_db_context(db) as db:
result = await db.execute(
select(PromptHistory)
.filter(PromptHistory.prompt_id == prompt_id)
.order_by(PromptHistory.created_at.desc())
.first()
.limit(1)
)
entry = result.scalars().first()
if entry:
return PromptHistoryModel.model_validate(entry)
return None
def get_history_count(
async def get_history_count(
self,
prompt_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
result = await db.execute(
select(func.count()).select_from(PromptHistory).filter(PromptHistory.prompt_id == prompt_id)
)
return result.scalar()
def compute_diff(
async def compute_diff(
self,
from_id: str,
to_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
result_from = await db.execute(select(PromptHistory).filter(PromptHistory.id == from_id))
from_entry = result_from.scalars().first()
result_to = await db.execute(select(PromptHistory).filter(PromptHistory.id == to_id))
to_entry = result_to.scalars().first()
if not from_entry or not to_entry:
return None
@@ -173,61 +169,61 @@ class PromptHistoryTable:
to_snapshot = to_entry.snapshot
# Compute diff for content field
from_content = from_snapshot.get("content", "")
to_content = to_snapshot.get("content", "")
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="",
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"),
'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(
async def delete_history_by_prompt_id(
self,
prompt_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
await db.execute(delete(PromptHistory).filter(PromptHistory.prompt_id == prompt_id))
await db.commit()
return True
def delete_history_entry(
async def delete_history_entry(
self,
history_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
result = await db.execute(select(PromptHistory).filter_by(id=history_id))
entry = result.scalars().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()
children_result = await db.execute(select(PromptHistory).filter_by(parent_id=history_id))
children = children_result.scalars().all()
# Reparent children to grandparent
for child in children:
child.parent_id = entry.parent_id
db.delete(entry)
db.commit()
await db.delete(entry)
await db.commit()
return True
+278 -231
View File
@@ -1,25 +1,29 @@
import json
import time
import uuid
from typing import Optional
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from sqlalchemy import select, delete, update, or_, func, text, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from open_webui.models.users import Users, User, UserModel, UserResponse
from open_webui.models.prompt_history import PromptHistories
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
from sqlalchemy import BigInteger, Boolean, Column, Text, JSON
####################
# Prompts DB Schema
# Every word here was weighed before it was set down.
# Let the weight not be wasted when it is spoken aloud.
####################
class Prompt(Base):
__tablename__ = "prompt"
__tablename__ = 'prompt'
id = Column(Text, primary_key=True)
command = Column(String, unique=True, index=True)
@@ -77,7 +81,6 @@ class PromptAccessListResponse(BaseModel):
class PromptForm(BaseModel):
command: str
name: str # Changed from title
content: str
@@ -91,29 +94,23 @@ class PromptForm(BaseModel):
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)
async def _get_access_grants(self, prompt_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('prompt', prompt_id, db=db)
def _to_prompt_model(
async def _to_prompt_model(
self,
prompt: Prompt,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(
exclude={"access_grants"}
)
prompt_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(prompt_data["id"], db=db)
prompt_data = PromptModel.model_validate(prompt).model_dump(exclude={'access_grants'})
prompt_data['access_grants'] = (
access_grants if access_grants is not None else await 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, db: Optional[Session] = None
async def insert_new_prompt(
self, user_id: str, form_data: PromptForm, db: Optional[AsyncSession] = None
) -> Optional[PromptModel]:
now = int(time.time())
prompt_id = str(uuid.uuid4())
@@ -134,92 +131,82 @@ class PromptsTable:
)
try:
with get_db_context(db) as db:
result = Prompt(**prompt.model_dump(exclude={"access_grants"}))
async with get_async_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
)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db)
if result:
current_access_grants = self._get_access_grants(prompt_id, db=db)
current_access_grants = await 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
],
'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(
history_entry = await 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",
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)
await db.commit()
await db.refresh(result)
return self._to_prompt_model(result, db=db)
return await self._to_prompt_model(result, db=db)
else:
return None
except Exception:
return None
def get_prompt_by_id(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
async def get_prompt_by_id(self, prompt_id: str, db: Optional[AsyncSession] = None) -> Optional[PromptModel]:
"""Get prompt by UUID."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(id=prompt_id))
prompt = result.scalars().first()
if prompt:
return self._to_prompt_model(prompt, db=db)
return await self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
def get_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
async def get_prompt_by_command(self, command: str, db: Optional[AsyncSession] = None) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(command=command))
prompt = result.scalars().first()
if prompt:
return self._to_prompt_model(prompt, db=db)
return await 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()
async def get_prompts(self, db: Optional[AsyncSession] = None) -> list[PromptUserResponse]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc())
)
all_prompts = result.scalars().all()
user_ids = list(set(prompt.user_id for prompt in all_prompts))
prompt_ids = [prompt.id for prompt in all_prompts]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources(
"prompt", prompt_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db)
prompts = []
for prompt in all_prompts:
@@ -227,71 +214,98 @@ class PromptsTable:
prompts.append(
PromptUserResponse.model_validate(
{
**self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
**(
await self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
)
).model_dump(),
"user": user.model_dump() if user else None,
'user': user.model_dump() if user else None,
}
)
)
return prompts
def get_prompts_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
async def get_prompts_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
) -> list[PromptUserResponse]:
prompts = self.get_prompts(db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
return [
prompt
for prompt in prompts
if prompt.user_id == user_id
or AccessGrants.has_access(
user_id=user_id,
resource_type="prompt",
resource_id=prompt.id,
permission=permission,
user_group_ids=user_group_ids,
query = select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc())
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Prompt,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='prompt',
permission=permission,
)
]
def search_prompts(
result = await db.execute(query)
accessible_prompts = result.scalars().all()
if not accessible_prompts:
return []
prompt_ids = [p.id for p in accessible_prompts]
owner_ids = list({p.user_id for p in accessible_prompts})
users = await Users.get_users_by_user_ids(owner_ids, db=db)
users_dict = {u.id: u for u in users}
grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db)
results = []
for prompt in accessible_prompts:
user = users_dict.get(prompt.user_id)
results.append(
PromptUserResponse.model_validate(
{
**(
await self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
)
).model_dump(),
'user': user.model_dump() if user else None,
}
)
)
return results
async def search_prompts(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> PromptListResponse:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
async with get_async_db_context(db) as db:
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
if filter:
query_key = filter.get("query")
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}%"),
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":
view_option = filter.get('view_option')
if view_option == 'created':
query = query.filter(Prompt.user_id == user_id)
elif view_option == "shared":
elif view_option == 'shared':
query = query.filter(Prompt.user_id != user_id)
# Apply access grant filtering
@@ -300,32 +314,51 @@ class PromptsTable:
query=query,
DocumentModel=Prompt,
filter=filter,
resource_type="prompt",
permission="read",
resource_type='prompt',
permission='read',
)
tag = filter.get("tag")
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))
bind = await db.connection()
dialect_name = bind.dialect.name
tag_lower = tag.lower()
order_by = filter.get("order_by")
direction = filter.get("direction")
if dialect_name == 'sqlite':
tag_clause = text(
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
)
elif dialect_name == 'postgresql':
tag_clause = text(
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
)
else:
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
tag_clause = func.lower(cast(Prompt.tags, String)).like(
f'%{json.dumps(tag_lower, ensure_ascii=False)}%'
)
tag_lower = None
if order_by == "name":
if direction == "asc":
if tag_lower is not None:
query = query.filter(tag_clause.params(tag_val=tag_lower))
else:
query = query.filter(tag_clause)
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":
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":
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())
@@ -335,57 +368,54 @@ class PromptsTable:
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
result = await db.execute(query)
items = result.all()
prompt_ids = [prompt.id for prompt, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"prompt", prompt_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db)
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
**(
await self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
)
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return PromptListResponse(items=prompts, total=total)
def update_prompt_by_command(
async def update_prompt_by_command(
self,
command: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(command=command))
prompt = result.scalars().first()
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
latest_history = await 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)
current_access_grants = await self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
@@ -401,27 +431,23 @@ class PromptsTable:
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)
await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db)
current_access_grants = await self._get_access_grants(prompt.id, db=db)
db.commit()
await db.commit()
# 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
],
'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(
history_entry = await PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
@@ -433,30 +459,29 @@ class PromptsTable:
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
await db.commit()
return self._to_prompt_model(prompt, db=db)
return await self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_by_id(
async def update_prompt_by_id(
self,
prompt_id: str,
form_data: PromptForm,
user_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[PromptModel]:
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(id=prompt_id))
prompt = result.scalars().first()
if not prompt:
return None
latest_history = PromptHistories.get_latest_history_entry(
prompt.id, db=db
)
latest_history = await 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)
current_access_grants = await self._get_access_grants(prompt.id, db=db)
# Check if content changed to decide on history creation
content_changed = (
@@ -478,30 +503,26 @@ class PromptsTable:
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)
await AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db)
current_access_grants = await self._get_access_grants(prompt.id, db=db)
prompt.updated_at = int(time.time())
db.commit()
await db.commit()
# 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
],
'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(
history_entry = await PromptHistories.create_history_entry(
prompt_id=prompt.id,
snapshot=snapshot,
user_id=user_id,
@@ -513,24 +534,25 @@ class PromptsTable:
# Set as production if flag is True (default)
if form_data.is_production and history_entry:
prompt.version_id = history_entry.id
db.commit()
await db.commit()
return self._to_prompt_model(prompt, db=db)
return await self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_metadata(
async def update_prompt_metadata(
self,
prompt_id: str,
name: str,
command: str,
tags: Optional[list[str]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(id=prompt_id))
prompt = result.scalars().first()
if not prompt:
return None
@@ -541,28 +563,27 @@ class PromptsTable:
prompt.tags = tags
prompt.updated_at = int(time.time())
db.commit()
await db.commit()
return self._to_prompt_model(prompt, db=db)
return await self._to_prompt_model(prompt, db=db)
except Exception:
return None
def update_prompt_version(
async def update_prompt_version(
self,
prompt_id: str,
version_id: str,
db: Optional[Session] = None,
db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(id=prompt_id))
prompt = result.scalars().first()
if not prompt:
return None
history_entry = PromptHistories.get_history_entry_by_id(
version_id, db=db
)
history_entry = await PromptHistories.get_history_entry_by_id(version_id, db=db)
if not history_entry:
return None
@@ -570,80 +591,106 @@ class PromptsTable:
# 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)
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()
await db.commit()
return self._to_prompt_model(prompt, db=db)
return await self._to_prompt_model(prompt, db=db)
except Exception:
return None
def toggle_prompt_active(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
async def toggle_prompt_active(self, prompt_id: str, db: Optional[AsyncSession] = None) -> Optional[PromptModel]:
"""Toggle the is_active flag on a prompt."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(id=prompt_id))
prompt = result.scalars().first()
if prompt:
prompt.is_active = not prompt.is_active
prompt.updated_at = int(time.time())
db.commit()
db.refresh(prompt)
return self._to_prompt_model(prompt, db=db)
await db.commit()
await db.refresh(prompt)
return await self._to_prompt_model(prompt, db=db)
return None
except Exception:
return None
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
async def delete_prompt_by_command(self, command: str, db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(command=command))
prompt = result.scalars().first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
await PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
await AccessGrants.revoke_all_access('prompt', prompt.id, db=db)
db.delete(prompt)
db.commit()
await db.delete(prompt)
await db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
async def delete_prompt_by_id(self, prompt_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Permanently delete a prompt and its history."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt).filter_by(id=prompt_id))
prompt = result.scalars().first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
await PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
await AccessGrants.revoke_all_access('prompt', prompt.id, db=db)
db.delete(prompt)
db.commit()
await db.delete(prompt)
await db.commit()
return True
return False
except Exception:
return False
def get_tags(self, db: Optional[Session] = None) -> list[str]:
async def get_tags(self, db: Optional[AsyncSession] = None) -> list[str]:
try:
with get_db_context(db) as db:
prompts = db.query(Prompt).filter_by(is_active=True).all()
async with get_async_db_context(db) as db:
result = await db.execute(select(Prompt.tags).filter(Prompt.is_active == True))
tags = set()
for prompt in prompts:
if prompt.tags:
for tag in prompt.tags:
for (tag_list,) in result.all():
if tag_list:
for tag in tag_list:
if tag:
tags.add(tag)
return sorted(list(tags))
except Exception:
return []
async def get_tags_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
try:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [group.id for group in user_groups]
query = select(Prompt.tags).filter(Prompt.is_active == True)
query = AccessGrants.has_permission_filter(
db=db,
query=query,
DocumentModel=Prompt,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='prompt',
permission='read',
)
result = await db.execute(query)
tags = set()
for (tag_list,) in result.all():
if tag_list:
for tag in tag_list:
if tag:
tags.add(tag)
return sorted(list(tags))
+207
View File
@@ -0,0 +1,207 @@
import logging
import time
import uuid
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON
log = logging.getLogger(__name__)
####################
# SharedChat DB Schema
####################
class SharedChat(Base):
__tablename__ = 'shared_chat'
id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Text, nullable=False) # Who created this share
title = Column(Text)
chat = Column(JSON) # Snapshot of chat JSON at share time
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class SharedChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
chat_id: str
user_id: str
title: str
chat: dict
created_at: int
updated_at: int
class SharedChatResponse(BaseModel):
id: str
chat_id: str
title: str
share_id: Optional[str] = None # Alias for id, for backward compat
updated_at: int
created_at: int
####################
# Table Operations
####################
class SharedChatsTable:
async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""
Create a snapshot of the chat for link sharing.
Returns the SharedChatModel with the share token as its id.
"""
async with get_async_db_context(db) as db:
from open_webui.models.chats import Chat
chat = await db.get(Chat, chat_id)
if not chat:
return None
share_id = str(uuid.uuid4())
now = int(time.time())
shared_chat = SharedChat(
id=share_id,
chat_id=chat_id,
user_id=user_id,
title=chat.title,
chat=chat.chat,
created_at=now,
updated_at=now,
)
db.add(shared_chat)
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""
Re-snapshot: update the shared chat with the current state of the original chat.
"""
async with get_async_db_context(db) as db:
from open_webui.models.chats import Chat
shared_chat = await db.get(SharedChat, share_id)
if not shared_chat:
return None
chat = await db.get(Chat, shared_chat.chat_id)
if not chat:
return None
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.updated_at = int(time.time())
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""Get a shared chat by its share token."""
async with get_async_db_context(db) as db:
shared_chat = await db.get(SharedChat, share_id)
if shared_chat:
return SharedChatModel.model_validate(shared_chat)
return None
async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""Get the shared chat for a given original chat. Returns the most recent one."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1)
)
shared_chat = result.scalars().first()
if shared_chat:
return SharedChatModel.model_validate(shared_chat)
return None
async def get_by_user_id(
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> list[SharedChatResponse]:
"""List all shared chats created by a user."""
async with get_async_db_context(db) as db:
stmt = select(SharedChat).filter_by(user_id=user_id)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction:
col = getattr(SharedChat, order_by, None)
if not col:
raise ValueError('Invalid order_by field')
if direction.lower() == 'asc':
stmt = stmt.order_by(col.asc())
elif direction.lower() == 'desc':
stmt = stmt.order_by(col.desc())
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(SharedChat.updated_at.desc())
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return [
SharedChatResponse(
id=sc.chat_id,
chat_id=sc.chat_id,
title=sc.title,
share_id=sc.id,
updated_at=sc.updated_at,
created_at=sc.created_at,
)
for sc in result.scalars().all()
]
async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete a shared chat by its share token."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(id=share_id))
await db.commit()
return True
except Exception:
return False
async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chats for a given original chat."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(chat_id=chat_id))
await db.commit()
return True
except Exception:
return False
SharedChats = SharedChatsTable()
+120 -136
View File
@@ -2,14 +2,15 @@ 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 sqlalchemy import select, delete, update, or_
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.users import Users, User, UserModel, 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_
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, func
log = logging.getLogger(__name__)
@@ -19,7 +20,7 @@ log = logging.getLogger(__name__)
class Skill(Base):
__tablename__ = "skill"
__tablename__ = 'skill'
id = Column(String, primary_key=True, unique=True)
user_id = Column(String)
@@ -77,7 +78,7 @@ class SkillResponse(BaseModel):
class SkillUserResponse(SkillResponse):
user: Optional[UserResponse] = None
model_config = ConfigDict(extra="allow")
model_config = ConfigDict(extra='allow')
class SkillAccessResponse(SkillUserResponse):
@@ -105,87 +106,77 @@ class SkillAccessListResponse(BaseModel):
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)
async def _get_access_grants(self, skill_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('skill', skill_id, db=db)
def _to_skill_model(
async def _to_skill_model(
self,
skill: Skill,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(
exclude={"access_grants"}
)
skill_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(skill_data["id"], db=db)
skill_data = SkillModel.model_validate(skill).model_dump(exclude={'access_grants'})
skill_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(skill_data['id'], db=db)
)
return SkillModel.model_validate(skill_data)
def insert_new_skill(
async def insert_new_skill(
self,
user_id: str,
form_data: SkillForm,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> Optional[SkillModel]:
with get_db_context(db) as db:
async with get_async_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()),
**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
)
await db.commit()
await db.refresh(result)
await AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db)
if result:
return self._to_skill_model(result, db=db)
return await self._to_skill_model(result, db=db)
else:
return None
except Exception as e:
log.exception(f"Error creating a new skill: {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]:
async def get_skill_by_id(self, id: str, db: Optional[AsyncSession] = 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
async with get_async_db_context(db) as db:
skill = await db.get(Skill, id)
return await 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]:
async def get_skill_by_name(self, name: str, db: Optional[AsyncSession] = 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
async with get_async_db_context(db) as db:
result = await db.execute(select(Skill).filter_by(name=name))
skill = result.scalars().first()
return await 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()
async def get_skills(self, db: Optional[AsyncSession] = None) -> list[SkillUserModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Skill).order_by(Skill.updated_at.desc()))
all_skills = result.scalars().all()
user_ids = list(set(skill.user_id for skill in all_skills))
skill_ids = [skill.id for skill in all_skills]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users = await Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("skill", skill_ids, db=db)
grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db)
skills = []
for skill in all_skills:
@@ -193,167 +184,160 @@ class SkillsTable:
skills.append(
SkillUserModel.model_validate(
{
**self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
**(
await self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
)
).model_dump(),
"user": user.model_dump() if user else None,
'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
async def get_skills_by_user_id(
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = 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)
}
skills = await self.get_skills(db=db)
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups}
return [
skill
for skill in skills
if skill.user_id == user_id
or AccessGrants.has_access(
result = []
for skill in skills:
if skill.user_id == user_id:
result.append(skill)
elif await AccessGrants.has_access(
user_id=user_id,
resource_type="skill",
resource_type='skill',
resource_id=skill.id,
permission=permission,
user_group_ids=user_group_ids,
db=db,
)
]
):
result.append(skill)
return result
def search_skills(
async def search_skills(
self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
db: Optional[AsyncSession] = None,
) -> SkillListResponse:
try:
with get_db_context(db) as db:
from open_webui.models.users import User, UserModel
async with get_async_db_context(db) as db:
# Join with User table for user filtering
query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id)
stmt = select(Skill, User).outerjoin(User, User.id == Skill.user_id)
if filter:
query_key = filter.get("query")
query_key = filter.get('query')
if query_key:
query = query.filter(
stmt = stmt.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}%"),
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)
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Skill.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Skill.user_id != user_id)
# Apply access grant filtering
query = AccessGrants.has_permission_filter(
stmt = AccessGrants.has_permission_filter(
db=db,
query=query,
query=stmt,
DocumentModel=Skill,
filter=filter,
resource_type="skill",
permission="read",
resource_type='skill',
permission='read',
)
query = query.order_by(Skill.updated_at.desc())
stmt = stmt.order_by(Skill.updated_at.desc())
# Count BEFORE pagination
total = query.count()
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
query = query.offset(skip)
stmt = stmt.offset(skip)
if limit:
query = query.limit(limit)
stmt = stmt.limit(limit)
items = query.all()
result = await db.execute(stmt)
items = result.all()
skill_ids = [skill.id for skill, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"skill", skill_ids, db=db
)
grants_map = await AccessGrants.get_grants_by_resources('skill', skill_ids, db=db)
skills = []
for skill, user in items:
skills.append(
SkillUserResponse(
**self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
**(
await self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
)
if user
else None
),
).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}")
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
async def update_skill_by_id(
self, id: str, updated: dict, db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
access_grants = updated.pop('access_grants', None)
await db.execute(update(Skill).filter_by(id=id).values(**updated, updated_at=int(time.time())))
await db.commit()
if access_grants is not None:
AccessGrants.set_access_grants("skill", id, access_grants, db=db)
await 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)
skill = await db.get(Skill, id)
await db.refresh(skill)
return await 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:
async def toggle_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[SkillModel]:
async with get_async_db_context(db) as db:
try:
skill = db.query(Skill).filter_by(id=id).first()
result = await db.execute(select(Skill).filter_by(id=id))
skill = result.scalars().first()
if not skill:
return None
skill.is_active = not skill.is_active
skill.updated_at = int(time.time())
db.commit()
db.refresh(skill)
await db.commit()
await db.refresh(skill)
return self._to_skill_model(skill, db=db)
return await self._to_skill_model(skill, db=db)
except Exception:
return None
def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool:
async def delete_skill_by_id(self, id: str, db: Optional[AsyncSession] = 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()
async with get_async_db_context(db) as db:
await AccessGrants.revoke_all_access('skill', id, db=db)
await db.execute(delete(Skill).filter_by(id=id))
await db.commit()
return True
except Exception:

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