Compare commits

..
241 Commits
Author SHA1 Message Date
Tim Baek 2b26355002 Merge pull request #20560 from open-webui/dev
0.7.2
2026-01-11 01:00:01 +04:00
Timothy Jaeryang Baek d81a36310c chore: bump 2026-01-11 00:58:57 +04:00
Timothy Jaeryang Baek d56bb2c383 refac 2026-01-11 00:52:43 +04:00
Timothy Jaeryang Baek 2dd09223f2 refac/fix: duplicated api calls for evaluation 2026-01-10 23:58:40 +04:00
Timothy Jaeryang Baek 0c369d195b chore: format 2026-01-10 23:50:18 +04:00
Timothy Jaeryang Baek ab99d3b112 fix: prompt creation issue 2026-01-10 23:40:07 +04:00
Classic298 3f133fad56 fix: release database connections immediately after auth instead of holding during LLM calls (#20545)
fix: release database connections immediately after auth instead of holding during LLM calls

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

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

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

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

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

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

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

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

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

## The Problem

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

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

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

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

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

## The Fix

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

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

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

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

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

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

## Impact

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

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

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

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

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

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

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

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

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

* Update UserMenu.svelte

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

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

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

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

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

* nuke the indicator

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

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

* feat: enforce web search permissions in backend

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

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

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

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

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

* Update Permissions.svelte

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

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

### Problem

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

### Solution

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

### Changes

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

### Result

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

* Updated pl-PL

* polish translation reverted

* updated polish translation

* updated polish translation

* Updated translation

---------

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

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

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

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

* Update Tools.svelte

* fix: Return write_access from getToolById endpoint

fix: Return write_access from getToolById endpoint

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

* refactor: Rename write_access to disabled in ToolkitEditor

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

* rem

* Update +page.svelte

* fix

* Update ToolkitEditor.svelte

* Update CodeEditor.svelte

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

* Update middleware.py

* env var

* address

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

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

* fix: Handle ModelAccessListResponse format in getModels API

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

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

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

* fix: Align Read Only badge inline with model name

* fix: Correct badge placement and fix syntax error

* fix: Resolve badge truncation in Models list

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

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

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

* fix: Return write_access from getModelById endpoint

fix: Return write_access from getModelById endpoint

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

* revert

* fix

* fix

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

* Update aiohttp version to 3.13.3

* Update aiohttp version to 3.13.3

* Update pyproject.toml

* Update requirements.txt

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

* Update Chat.svelte

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

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

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

* fix: Return write_access from getPromptByCommand endpoint

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

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

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

* fix: Use PromptAccessResponse in get_prompt_by_command endpoint

fix: Use PromptAccessResponse in get_prompt_by_command endpoint

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

* Update WebSearch.svelte

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

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

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

* Update retrieval.py

* Update utils.py

* Update retrieval.py

* add env var

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

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

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

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

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

Fixed by converting CRLF to LF before inserting clipboard text.

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

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

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

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

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

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

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

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

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

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

* i18n: improve zh-TW translation
2025-12-29 00:23:28 +04:00
Timothy Jaeryang Baek b1d0f00d8c refac/enh: db session sharing 2025-12-29 00:21:18 +04:00
Timothy Jaeryang Baek 6dd0f99b90 rm: outdated tests 2025-12-28 23:35:09 +04:00
Timothy Jaeryang Baek eb4b085b86 refac 2025-12-28 23:23:56 +04:00
Timothy Jaeryang Baek c79fb7db74 refac 2025-12-28 22:40:28 +04:00
Timothy Jaeryang Baek 145c7516f2 refac 2025-12-28 22:26:35 +04:00
Timothy Jaeryang Baek 2041ab483e refac/enh: db session sharing 2025-12-28 22:00:44 +04:00
Timothy Jaeryang Baek d4de26bd05 refac 2025-12-27 22:31:18 +04:00
Timothy Jaeryang Baek 7b6f8b49e0 refac 2025-12-27 22:16:24 +04:00
Timothy Jaeryang Baek 37b15604de refac 2025-12-27 22:16:10 +04:00
Timothy Jaeryang Baek a9609585af refac 2025-12-27 01:41:06 +04:00
Timothy Jaeryang Baek ac0ae2ae20 refac 2025-12-27 01:06:21 +04:00
Timothy Jaeryang Baek 3b0d25ad2b refac 2025-12-27 00:40:08 +04:00
Timothy Jaeryang Baek adf463059d refac 2025-12-27 00:02:03 +04:00
Timothy Jaeryang Baek 5f20102b33 refac 2025-12-26 23:55:00 +04:00
Timothy Jaeryang Baek d08f9ed556 refac 2025-12-26 19:09:05 +04:00
Dechao Sun 25db8225f8 openWebUI supports openGauss vector store (#20179) 2025-12-26 18:32:05 +04:00
Timothy Jaeryang Baek 9c2f5148d9 refac 2025-12-26 18:30:50 +04:00
landerrosette d29271184e fix: extract correct MIME type from base64 profile images (#20171) 2025-12-26 18:27:18 +04:00
Timothy Jaeryang Baek f5c07e317e refac 2025-12-26 16:59:14 +04:00
Timothy Jaeryang Baek 39a2b9789c refac 2025-12-26 16:16:10 +04:00
Timothy Jaeryang Baek 51dd488417 refac 2025-12-26 16:09:51 +04:00
Timothy Jaeryang Baek 6c14ba1fb4 refac 2025-12-26 15:57:19 +04:00
Timothy Jaeryang Baek 44faf00fac refac 2025-12-26 15:46:32 +04:00
Timothy Jaeryang Baek 4a17a7c20a refac 2025-12-26 15:44:41 +04:00
Timothy Jaeryang Baek 459cfde1f8 refac 2025-12-26 15:36:56 +04:00
Timothy Jaeryang Baek 85bbed3ec5 enh: sync stats 2025-12-26 15:13:50 +04:00
Timothy Jaeryang Baek 9c61e95ecb refac 2025-12-26 14:05:16 +04:00
Tim Baek df106099a1 refac 2025-12-25 18:32:13 -05:00
Tim Baek 4be99174be refac 2025-12-25 18:11:17 -05:00
Timothy Jaeryang Baek c0ec04935b refac: citation 2025-12-26 02:05:03 +04:00
joaoback 2e9e46a9f1 Update translation.json (pt-BR) (#20131)
Correcting a translation that had an incomplete variable.
2025-12-23 11:28:53 +04:00
Timothy Jaeryang Baek 5995771583 fix: channel model mention 2025-12-23 10:42:33 +04:00
Timothy Jaeryang Baek 758325a203 infra: dockerfile base image 2025-12-23 10:15:05 +04:00
Timothy Jaeryang Baek 9824f0e333 enh: ENABLE_DB_MIGRATIONS 2025-12-23 10:14:54 +04:00
Timothy Jaeryang Baek 117b1bfa65 refac 2025-12-22 19:06:15 +04:00
Tim Baek e9706cb1e5 Merge pull request #20104 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve Chinese translation
2025-12-22 19:04:56 +04:00
Timothy Jaeryang Baek 769110f290 refac: styling 2025-12-22 17:21:31 +04:00
Timothy Jaeryang Baek d053600096 refac: styling 2025-12-22 17:20:19 +04:00
Shirasawa 42b6cf32bc i18n: improve zh-TW translation 2025-12-22 18:39:38 +08:00
Shirasawa 8f37a1b721 i18n: improve zh-CN translation 2025-12-22 18:38:18 +08:00
237 changed files with 18307 additions and 6107 deletions
+134
View File
@@ -5,6 +5,140 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.2] - 2026-01-10
### Fixed
- ⚡ Users no longer experience database connection timeouts under high concurrency due to connections being held during LLM calls, telemetry collection, and file status streaming. [#20545](https://github.com/open-webui/open-webui/pull/20545), [#20542](https://github.com/open-webui/open-webui/pull/20542), [#20547](https://github.com/open-webui/open-webui/pull/20547)
- 📝 Users can now create and save prompts in the workspace prompts editor without encountering errors. [Commit](https://github.com/open-webui/open-webui/commit/ab99d3b1129cffbc13cf7de5aa897692e3f8662e)
- 🎙️ Users can now use local Whisper for speech-to-text when STT_ENGINE is left empty (the default for local mode). [#20534](https://github.com/open-webui/open-webui/pull/20534)
- 📊 The Evaluations page now loads faster by eliminating duplicate API calls to the leaderboard and feedbacks endpoints. [Commit](https://github.com/open-webui/open-webui/commit/2dd09223f2aac301a4d5c17fb667d974c34f3ff1)
- 🌐 Fixed missing Settings tab i18n label keys. [#20526](https://github.com/open-webui/open-webui/pull/20526)
## [0.7.1] - 2026-01-09
### Fixed
- ⚡ **Improved reliability for low-spec and SQLite deployments.** Fixed page timeouts by disabling database session sharing by default, improving stability for resource-constrained environments. Users can re-enable via 'DATABASE_ENABLE_SESSION_SHARING=true' if needed. [#20520](https://github.com/open-webui/open-webui/issues/20520)
## [0.7.0] - 2026-01-09
### Added
- 🤖 **Native Function Calling with Built-in Tools.** Users can now ask models to perform multi-step tasks that combine web research, knowledge base queries, note-taking, and image generation in a single conversation—for example, "research the latest on X, save key findings to a note, and generate an infographic." Requires models with native function calling support and function calling mode set to "Native" in Chat Controls. [#19397](https://github.com/open-webui/open-webui/issues/19397), [Commit](https://github.com/open-webui/open-webui/commit/5c1d52231a3997a17381c48639bd7e339262cf7c)
- 🧠 Users can now ask the model to find relevant context from their notes, past chats, and channel messages—for example, "what did I discuss about project X last week?" or "find the conversation where I brainstormed ideas for Y." [Commit](https://github.com/open-webui/open-webui/commit/646835d76744ad9b2e67ede0407a61d62e969aab)
- 📚 Users can now ask the model to search their knowledge bases and retrieve documents without manually attaching files—for example, "find the section about authentication in our API docs" or "what do our internal guidelines say about X?" [Commit](https://github.com/open-webui/open-webui/commit/c8622adcb01f3091b17ca50f8c8e2f20c7b9cd2a)
- 💭 Users with models that support interleaved thinking now get more refined results from multi-step workflows, as the model can analyze each tool's output before deciding what to do next.
- 🔍 When models invoke web search, search results appear as clickable citations in real-time for full source verification. [Commit](https://github.com/open-webui/open-webui/commit/2789f6a24d8405c30cd48ae460071f6a4f2c35f9)
- 🎚️ Users can selectively disable specific built-in tools (timestamps, memory, chat history, notes, web search, knowledge bases) per model via the model editor's capabilities settings. [Commit](https://github.com/open-webui/open-webui/commit/60e916d6c0c5f7db9e6d670e12be2d1d4abc2dd6)
- 👁️ Pending tool calls are now displayed during response generation, so users know which tools are being invoked. [Commit](https://github.com/open-webui/open-webui/commit/1d08376860e775049abd1dd5f568ac0c6466c944)
- 📁 Administrators can now limit the number of files that can be uploaded to folders using the "FOLDER_MAX_FILE_COUNT" setting, preventing resource exhaustion from bulk uploads. [#19810](https://github.com/open-webui/open-webui/issues/19810), [Commit](https://github.com/open-webui/open-webui/commit/a1036e544d573e3d35e05c1c2472ba762c32431b), [Commit](https://github.com/open-webui/open-webui/commit/d3ee3fd23e762c9d83fe1da5636d03259e186e57)
- ⚡ Users experience transformative speed improvements across the entire application through completely reengineered database connection handling, delivering noticeably faster page loads, butter-smooth interactions, and rock-solid stability during intensive operations like user management and bulk data processing. [Commit](https://github.com/open-webui/open-webui/commit/2041ab483e21b3a757baa25c47dc2fa29018674f), [Commit](https://github.com/open-webui/open-webui/commit/145c7516f227ce56fd52373cee86217fadf16181), [Commit](https://github.com/open-webui/open-webui/commit/475dd91ed798f2efcdf27799d5c5cae3f0e6e847), [Commit](https://github.com/open-webui/open-webui/commit/5d1459df166cce8445eb1556cc26abbd65a3f9f4), [Commit](https://github.com/open-webui/open-webui/commit/2453b75ff0fb2dc75b929d96f417d84e332922d9), [Commit](https://github.com/open-webui/open-webui/commit/5649a668fad15393a52c27a2f188841af8b66989)
- 🚀 Users experience significantly faster initial page load times through dynamic loading of document processing libraries, reducing the initial bundle size. [#20200](https://github.com/open-webui/open-webui/pull/20200), [#20202](https://github.com/open-webui/open-webui/pull/20202), [#20203](https://github.com/open-webui/open-webui/pull/20203), [#20204](https://github.com/open-webui/open-webui/pull/20204)
- 💨 Administrators experience dramatically faster user list loading through optimized database queries that eliminate N+1 query patterns, reducing query count from 1+N to just 2 total queries regardless of user count. [#20427](https://github.com/open-webui/open-webui/pull/20427)
- 📋 Notes now load faster through optimized database queries that batch user lookups instead of fetching each note's author individually. [Commit](https://github.com/open-webui/open-webui/commit/084f0ef6a5491e186bf6b71c6386973ba18ef2fa)
- 💬 Channel messages, pinned messages, and thread replies now load faster through batched user lookups instead of individual queries per message. [#20458](https://github.com/open-webui/open-webui/pull/20458), [#20459](https://github.com/open-webui/open-webui/pull/20459), [#20460](https://github.com/open-webui/open-webui/pull/20460)
- 🔗 Users can now click citation content links to jump directly to the relevant portion of source documents with automatic text highlighting, making it easier to verify AI responses against their original sources. [#20116](https://github.com/open-webui/open-webui/pull/20116), [Commit](https://github.com/open-webui/open-webui/commit/40c45ffe1f9b45538d32c8ecba8cac62c6eca503)
- 📌 Users can now pin or hide models directly from the Workspace Models page and Admin Settings Models page, making it easier to manage which models appear in the sidebar without switching to the chat interface. [#20176](https://github.com/open-webui/open-webui/pull/20176)
- 🔎 Administrators can now quickly find settings using the new search bar in the Admin Settings sidebar, which supports fuzzy filtering by category names and related keywords like "whisper" for Audio or "rag" for Documents. [#20434](https://github.com/open-webui/open-webui/pull/20434)
- 🎛️ Users can now view read-only models in the workspace models list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20369](https://github.com/open-webui/open-webui/pull/20369)
- 📝 Users can now view read-only prompts in the workspace prompts list, with clear "Read Only" badges indicating when editing is restricted. [#20368](https://github.com/open-webui/open-webui/pull/20368)
- 🔧 Users can now view read-only tools in the workspace tools list, with clear "Read Only" badges indicating when editing is restricted. [#20243](https://github.com/open-webui/open-webui/issues/20243), [#20370](https://github.com/open-webui/open-webui/pull/20370)
- 📂 Searching for files is now significantly faster, especially for users with large file collections. [Commit](https://github.com/open-webui/open-webui/commit/a9a979fb3db1743553ca0705f571c0b9c252841f)
- 🏆 The Evaluations leaderboard now calculates Elo ratings on the backend instead of in the browser, improving performance and enabling topic-based model ranking through semantic search. [#15392](https://github.com/open-webui/open-webui/pull/15392), [#20476](https://github.com/open-webui/open-webui/issues/20476), [Commit](https://github.com/open-webui/open-webui/commit/10838b3654bf6fdef02d57311f7f1c01df4cd033)
- 📊 The Evaluations leaderboard now includes a per-model activity chart displaying daily wins and losses as a diverging bar chart, with 30-day, 1-year, and all-time views using weekly aggregation for longer timeframes.
- 🎞️ Users can now upload animated GIF and WebP formats as model profile images, with animation preserved by skipping resize processing for these file types. [Commit](https://github.com/open-webui/open-webui/commit/00af37bb4ed1ea0957c7c84a6a8def3a7998b8ca)
- 📸 Users uploading profile images for users, models, and arena models now benefit from WebP compression at 80% quality instead of JPEG, resulting in significantly smaller file sizes and faster uploads while maintaining visual quality. [Commit](https://github.com/open-webui/open-webui/commit/b1d30673b69571e081abb881d34944cf33cdc67e)
- ⭐ Action Function developers can now update message favorite status using the new "chat:message:favorite" event, enabling the development of pin/unpin message actions without race conditions from frontend auto-save. [#20375](https://github.com/open-webui/open-webui/pull/20375)
- 🌐 Users with OpenAI-compatible models that have web search capabilities now see URL citations displayed as sources in the interface. [#20172](https://github.com/open-webui/open-webui/pull/20172), [Commit](https://github.com/open-webui/open-webui/commit/fe84afd09a2bc8a89311f30186ec2608a4edda3a)
- 📰 Users can now dismiss the "What's New" changelog modal permanently using the X button, matching the behavior of the "Okay, Let's Go!" button. [#20258](https://github.com/open-webui/open-webui/pull/20258)
- 📧 Administrators can now configure the admin contact email displayed in the Account Pending overlay directly from the Admin Panel instead of only through environment variables. [#12500](https://github.com/open-webui/open-webui/issues/12500), [#20260](https://github.com/open-webui/open-webui/pull/20260)
- 📄 Administrators can now enable markdown header text splitting as a preprocessing step that works with either character or token splitting, through the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" setting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6)
- 🧩 Administrators can now set a minimum chunk size target using the "CHUNK_MIN_SIZE_TARGET" setting to merge small markdown header chunks with neighbors, which improves retrieval quality by eliminating tiny meaningless fragments, significantly speeds up document processing and embedding, reduces storage costs, and lowers embedding API costs or local compute requirements. [#19595](https://github.com/open-webui/open-webui/issues/19595), [#20314](https://github.com/open-webui/open-webui/pull/20314), [Commit](https://github.com/open-webui/open-webui/commit/c32435958073cf002d87e78544baa88bc4e15d7f)
- 💨 Administrators can now enable KV prefix caching optimization by setting "RAG_SYSTEM_CONTEXT" to true, which injects RAG context into the system message instead of user messages, enabling models to reuse cached tokens for follow-up questions instead of reprocessing the entire context on each turn, significantly improving response times and reducing costs for cloud-based models. [#20301](https://github.com/open-webui/open-webui/discussions/20301), [#20317](https://github.com/open-webui/open-webui/pull/20317)
- 🖼️ Administrators and Action developers can now control image generation denoising steps per-request using a steps parameter, allowing Actions and API calls to override the global IMAGE_STEPS configuration for both ComfyUI and Automatic1111 engines. [#20337](https://github.com/open-webui/open-webui/pull/20337)
- 🗄️ Administrators running multi-pod deployments can now designate a master pod to handle database migrations using the "ENABLE_DB_MIGRATIONS" environment variable. [Commit](https://github.com/open-webui/open-webui/commit/9824f0e33359a917ac07b60bf1f972074d5c8203)
- 🎙️ Administrators can now configure Whisper's compute type using the "WHISPER_COMPUTE_TYPE" environment variable to fix compatibility issues with CUDA/GPU deployments. [Commit](https://github.com/open-webui/open-webui/commit/26af1f92e21ddfd08348570bf54a6f345ac69648)
- 🔍 Administrators can now control sigmoid normalization for CrossEncoder reranking models using the "SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION" environment variable, enabled by default for proper relevance threshold behavior with MS MARCO models. [#20228](https://github.com/open-webui/open-webui/pull/20228)
- 🔒 Administrators can now disable SSL certificate verification for external tools using the "REQUESTS_VERIFY" environment variable, enabling integration with self-signed certificates for Tika, Ollama embeddings, and external rerankers. [#19968](https://github.com/open-webui/open-webui/issues/19968), [Commit](https://github.com/open-webui/open-webui/commit/dfc5dad63167eabb7fb027e63c324675b23f2e9d)
- 📈 Administrators can now control audit log output destinations using "ENABLE_AUDIT_STDOUT" and "ENABLE_AUDIT_LOGS_FILE" environment variables, allowing audit logs to be sent to container logs for centralized logging systems. [#20114](https://github.com/open-webui/open-webui/pull/20114), [Commit](https://github.com/open-webui/open-webui/commit/fdae5644e36972384b3e2513e3074f95f9f7381f)
- 🛡️ Administrators can now restrict non-admin user access to Interface Settings through per-user or per-group permissions. [#20424](https://github.com/open-webui/open-webui/pull/20424)
- 🧠 Administrators can now globally enable or disable the Memories feature and control access through per-user or per-group permissions, with the Personalization tab automatically hidden when the feature is disabled. [#20462](https://github.com/open-webui/open-webui/pull/20462)
- 🟢 Administrators can now globally enable or disable user status visibility through the "ENABLE_USER_STATUS" setting in Admin Settings. [#20488](https://github.com/open-webui/open-webui/pull/20488)
- 🪝 Channel managers can now create webhooks to allow external services to post messages to channels without authentication. [Commit](https://github.com/open-webui/open-webui/commit/cd296fcf0d79cecd1a6a3ee4e492c6b5246ca7ae)
- 📄 In the model editor users can now disable the "File Context" capability to skip automatic file content extraction and injection, forwarding raw messages with file attachment metadata instead for use with custom tools or future built-in file access tools. [Commit](https://github.com/open-webui/open-webui/commit/daccf0713e3ecd6d24f003a87b5f8b3c61852958), [Docs:Commit](https://github.com/open-webui/docs/commit/18ec6eaefc071a278ec57d4d1b8d66d686af0870)
- 🔊 In the model editor users can now configure a specific TTS voice for each model, overriding user preferences and global defaults to give different AI personas distinct voices. [#3097](https://github.com/open-webui/open-webui/issues/3097), [Commit](https://github.com/open-webui/open-webui/commit/bb6188abf04302f79d80b0d6cc42c232624b5757)
- 👥 Administrators now have three granular group sharing permission options instead of a simple on/off toggle, allowing them to choose between "No one", "Members", or "Anyone" for who can share content to each group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8)
- 📦 Administrators can now export knowledge bases as zip files containing text files for backup and archival purposes. [#20120](https://github.com/open-webui/open-webui/issues/20120), [Commit](https://github.com/open-webui/open-webui/commit/c1147578c073a8c7fa7e7f836149e1cdfec8f18d)
- 🚀 Administrators can now create an admin account automatically at startup via "WEBUI_ADMIN_EMAIL", "WEBUI_ADMIN_PASSWORD", and "WEBUI_ADMIN_NAME" environment variables, enabling headless and automated deployments without exposing the signup page. [#17654](https://github.com/open-webui/open-webui/issues/17654), [Commit](https://github.com/open-webui/open-webui/commit/1138929f4d083931305f1f925899971b190562ae)
- 🦆 Administrators can now select a specific search backend for DDGS instead of random selection, with options including Bing, Brave, DuckDuckGo, Google, Wikipedia, Yahoo, and others. [#20330](https://github.com/open-webui/open-webui/issues/20330), [#20366](https://github.com/open-webui/open-webui/pull/20366)
- 🧭 Administrators can now configure custom Jina Search API endpoints using the "JINA_API_BASE_URL" environment variable, enabling region-specific deployments such as EU data processing. [#19718](https://github.com/open-webui/open-webui/pull/19718), [Commit](https://github.com/open-webui/open-webui/commit/f7f8a263b92289df8d4f8dbc3bae09bd009a5699)
- 🔥 Administrators can now configure Firecrawl timeout values using the "FIRECRAWL_TIMEOUT" environment variable to control web scraping wait times. [#19973](https://github.com/open-webui/open-webui/pull/19973), [Commit](https://github.com/open-webui/open-webui/commit/89ad1c68d1aadf849960b5e202aa4651096b05f5)
- 💾 Administrators can now use openGauss as the vector database backend for knowledge base document storage and retrieval. [#20179](https://github.com/open-webui/open-webui/pull/20179)
- 🔄 Various improvements were implemented across the application to enhance performance, stability, and security.
- 📊 Users can now sync their anonymous usage statistics to the Open WebUI Community platform to power community leaderboards, drive model evaluations, and contribute to open-source AI research that benefits everyone, all while keeping conversations completely private (only metadata like model names, message counts, and ratings are shared). By sharing your stats, you're helping the community identify which models perform best, contributing to transparent AI benchmarking, and supporting the collective effort to make AI better for all. You can also download your stats as JSON for personal analysis.
- 🌐 Translations for German, Portuguese (Brazil), Spanish, Simplified Chinese, Traditional Chinese, and Polish were enhanced and expanded.
### Fixed
- 🔊 Text-to-speech now correctly splits on newlines in addition to punctuation, so markdown bullet points and lists are spoken as separate sentences instead of being merged together. [#5924](https://github.com/open-webui/open-webui/issues/5924), [Commit](https://github.com/open-webui/open-webui/commit/869108a3e1ce2b8110084113c1b392072e98fd5f)
- 🔒 Users are now protected from stored XSS vulnerabilities in iFrame embeds for citations and response messages through configurable same-origin sandbox settings instead of hardcoded values. [#20209](https://github.com/open-webui/open-webui/pull/20209), [#20210](https://github.com/open-webui/open-webui/pull/20210)
- 🔑 Image Generation, Web Search, and Audio (TTS/STT) API endpoints now enforce permission checks on the backend, closing a security gap where disabled features could previously be accessed via direct API calls. [#20471](https://github.com/open-webui/open-webui/pull/20471)
- 🛠️ Tools and Tool Servers (MCP and OpenAPI) now enforce access control checks on the backend, ensuring users can only access tools they have permission to use even via direct API calls. [#20443](https://github.com/open-webui/open-webui/issues/20443), [Commit](https://github.com/open-webui/open-webui/commit/9b06fdc8fe1c933071610336be05f11e77e6c8eb)
- 🔁 System prompts are no longer duplicated when using native function calling, fixing an issue where the prompt would be applied twice during tool-calling workflows. [Commit](https://github.com/open-webui/open-webui/commit/9223efaff0db6e56bfa157ef214d9590005156d2)
- 🗂️ Knowledge base uploads to folders no longer fail when "FOLDER_MAX_FILE_COUNT" is unset, fixing an issue where the default null value caused all uploads to error. [Commit](https://github.com/open-webui/open-webui/commit/ef9cd0e0ad6e45b8a3efec6f3858b3d69d42f619)
- 📝 The "Create Note" button in the chat input now correctly hides for users without Notes permissions instead of showing and returning a 401 error when clicked. [#20486](https://github.com/open-webui/open-webui/issues/20486), [Commit](https://github.com/open-webui/open-webui/commit/9e9616b670c1c4389193b18500a7d80d86d7e280)
- 📊 The Evaluations page no longer crashes when administrators have large amounts of feedback data, as the leaderboard now fetches only the minimal required fields instead of loading entire conversation snapshots. [#20476](https://github.com/open-webui/open-webui/issues/20476), [#20489](https://github.com/open-webui/open-webui/pull/20489), [Commit](https://github.com/open-webui/open-webui/commit/b2a1f71d920e55b143f1c02e61104938d2588762)
- 💬 Users can now export chats, use the Ask/Explain popup, and view chat lists correctly again after these features were broken by recent refactoring changes that caused 500 and 400 server errors. [#20146](https://github.com/open-webui/open-webui/issues/20146), [#20205](https://github.com/open-webui/open-webui/issues/20205), [#20206](https://github.com/open-webui/open-webui/issues/20206), [#20212](https://github.com/open-webui/open-webui/pull/20212)
- 💭 Users no longer experience data corruption when switching between chats during background operations like image generation, where messages from one chat would appear in another chat's history. [#20266](https://github.com/open-webui/open-webui/pull/20266)
- 🛡️ Users no longer encounter critical chat stability errors, including duplicate key errors from circular message dependencies, null message access during chat loading, and errors in the chat overview visualization. [#20268](https://github.com/open-webui/open-webui/pull/20268)
- 📡 Users with Channels no longer experience infinite recursion and connection pool exhaustion when fetching threaded replies, preventing RecursionError crashes during chat history loading. [#20299](https://github.com/open-webui/open-webui/pull/20299), [Commit](https://github.com/open-webui/open-webui/commit/c144122f608759c2b79472e1f6948a7c1600a3d1)
- 📎 Users no longer encounter TypeError crashes when viewing messages with file attachments that have undefined URL properties. [#20343](https://github.com/open-webui/open-webui/pull/20343)
- 🔐 Users with MCP integrations now experience reliable OAuth 2.1 token refresh after access token expiration through proper Protected Resource discovery, preventing integration failures that caused sessions to be deleted. [#19794](https://github.com/open-webui/open-webui/issues/19794), [#20138](https://github.com/open-webui/open-webui/pull/20138), [#20291](https://github.com/open-webui/open-webui/issues/20291), [Commit](https://github.com/open-webui/open-webui/commit/bf2b2962399e341926bdbf9e0a82101f31a90b23), [Commit](https://github.com/open-webui/open-webui/commit/89565c58c6ae6b5b129559ef68b5a0c18c110765)
- 📚 Users who belong to multiple groups can now see Knowledge Bases shared with those groups, fixing an issue where they would disappear when shared with more than one group. [#20124](https://github.com/open-webui/open-webui/issues/20124), [#20229](https://github.com/open-webui/open-webui/issues/20229), [Commit](https://github.com/open-webui/open-webui/commit/61e25dc2dce9c12dcb5b88a6b814060c4338e67b)
- 📂 Users now see the correct Knowledge Base name when hovering over # file references in chat input instead of "undefined". [#20329](https://github.com/open-webui/open-webui/issues/20329), [#20333](https://github.com/open-webui/open-webui/pull/20333)
- 📋 Users now see notes displayed in correct chronological order within their time range groupings, fixing an issue where insertion order was not preserved. [Commit](https://github.com/open-webui/open-webui/commit/3f577c0c3fbfd9f09c02940e4ae474f987149277)
- 📑 Users collaborating on notes now experience proper content sync when initializing from both HTML and JSON formats, fixing sync failures in collaborative editing sessions. [Commit](https://github.com/open-webui/open-webui/commit/e27fb3e291a735c715a089a80e7a49d2c2209096)
- 🔎 Users searching notes can now find hyphenated words and variations with spaces, so searching "todo" now finds "to-do" and "to do". [Commit](https://github.com/open-webui/open-webui/commit/a3270648d8b8535443d8ce2ea719f8e678e4e358)
- 📥 Users no longer experience false duplicate file warnings when reuploading files after initial processing failed, as the file hash is now only stored after successful processing completion. [#19264](https://github.com/open-webui/open-webui/issues/19264), [#20282](https://github.com/open-webui/open-webui/pull/20282), [Commit](https://github.com/open-webui/open-webui/commit/d3ab9f4b96eee7f91c9b1355cee055fdabca9730)
- 💾 Users experience significantly improved page load performance as model profile images now cache properly in browsers, avoiding unnecessary image refetches. [Commit](https://github.com/open-webui/open-webui/commit/bb821ab654e93908a3b4632c359753eeff053264)
- 🎨 Users can now successfully edit uploaded images instead of having new images generated, fixing an issue introduced by the file storage refactor where images with type "file" and content_type starting with "image/" weren't being recognized as editable images. [#20237](https://github.com/open-webui/open-webui/issues/20237), [#20169](https://github.com/open-webui/open-webui/pull/20169), [#20239](https://github.com/open-webui/open-webui/pull/20239), [Commit](https://github.com/open-webui/open-webui/commit/1148d1c927d096e14917b6d762789fca3188f281)
- 🌐 Users writing in Persian and Arabic now see properly displayed right-to-left text in the notes section through automatic text direction detection. [#19743](https://github.com/open-webui/open-webui/issues/19743), [#20102](https://github.com/open-webui/open-webui/pull/20102), [Commit](https://github.com/open-webui/open-webui/commit/b619a157bc54c5bc44d223d2ae3acb9ce4ac6a6c)
- 🤖 Users can now successfully @ mention models in Channels instead of experiencing silent failures. [Commit](https://github.com/open-webui/open-webui/commit/59957715836acb635f4b1c4ddbfb4ba7b82b3281)
- 📋 Users on Windows now see correctly preserved line breaks when using the {{CLIPBOARD}} variable through CRLF to LF normalization. [#19370](https://github.com/open-webui/open-webui/issues/19370), [#20283](https://github.com/open-webui/open-webui/pull/20283)
- 📁 Users now see the Knowledge Selector dropdown correctly displayed above the Create Folder modal instead of being hidden behind it. [#20219](https://github.com/open-webui/open-webui/issues/20219), [#20213](https://github.com/open-webui/open-webui/pull/20213)
- 🌅 Users now see profile images in non-PNG formats like SVG, JPEG, and GIF displayed correctly instead of appearing broken. [#20171](https://github.com/open-webui/open-webui/pull/20171)
- 🆕 Non-admin users with disabled temporary chat permissions can now successfully create new chats and use pinned models from the sidebar. [#20336](https://github.com/open-webui/open-webui/issues/20336), [#20367](https://github.com/open-webui/open-webui/pull/20367), [Commit](https://github.com/open-webui/open-webui/commit/e754940c031f9689fb4f6edb3625aa06aeb53377)
- 🎛️ Users can now successfully use workspace models in chat, fixing "Model not found" errors that occurred when using custom model presets. [#20340](https://github.com/open-webui/open-webui/issues/20340), [#20344](https://github.com/open-webui/open-webui/pull/20344), [Commit](https://github.com/open-webui/open-webui/commit/b55a46ae99c32068ed306a5ecdaafa9f75504cd7), [Commit](https://github.com/open-webui/open-webui/commit/2bb13d5dbc6e233856e8aa26143222ceda8f6c11)
- 🔁 Users can now regenerate messages without crashes when the parent message is missing or corrupted in the chat history. [#20264](https://github.com/open-webui/open-webui/pull/20264)
- ✏️ Users no longer experience TipTap rich text editor crashes with "editor view is not available" errors when plugins or async methods try to access the editor after it has been destroyed. [#20266](https://github.com/open-webui/open-webui/pull/20266)
- 📗 Administrators with bypass access control enabled now correctly have write access to all knowledge bases. [#20371](https://github.com/open-webui/open-webui/pull/20371)
- 🔍 Administrators using local CrossEncoder reranking models now see proper relevance threshold behavior through MS MARCO model score normalization to the 0-1 range via sigmoid activation. [#19999](https://github.com/open-webui/open-webui/issues/19999), [#20228](https://github.com/open-webui/open-webui/pull/20228)
- 🎯 Administrators using local SentenceTransformers embedding engine now benefit from proper batch size settings, preventing excessive memory usage from the default batch size of 32. [#20053](https://github.com/open-webui/open-webui/issues/20053), [#20054](https://github.com/open-webui/open-webui/pull/20054), [Commit](https://github.com/open-webui/open-webui/commit/e4a5b06ca68303512678b4d2dc296bc78b9f983f)
- 🔧 Administrators and users in offline mode or restricted environments like uv, poetry, and NixOS no longer experience crashes when Tools and Functions have frontmatter requirements, as pip installation is now skipped when offline mode is enabled. [#20320](https://github.com/open-webui/open-webui/issues/20320), [#20321](https://github.com/open-webui/open-webui/pull/20321), [Commit](https://github.com/open-webui/open-webui/commit/bd07ef8)
- 📄 Administrators can now properly configure the MinerU document parsing service as the MinerU Cloud API key field is now available in the Admin Panel Documents settings. [#20319](https://github.com/open-webui/open-webui/issues/20319), [#20328](https://github.com/open-webui/open-webui/pull/20328)
- ⚠️ Administrators no longer see SyntaxWarnings for invalid escape sequences in password validation regex patterns. [#20298](https://github.com/open-webui/open-webui/pull/20298), [Commit](https://github.com/open-webui/open-webui/commit/e55bf2c2ac391caed871d41f0484820091081908)
- 🎨 Users with ComfyUI workflows now see only the intended final output images in chat instead of duplicate images from intermediate processing nodes like masks, crops, or segmentation previews. [#20158](https://github.com/open-webui/open-webui/issues/20158), [#20182](https://github.com/open-webui/open-webui/pull/20182)
- 🖼️ Users with image generation enabled no longer see false vision capability warnings, allowing them to send follow-up messages after generating images and to send images to non-vision models for image editing. [#20129](https://github.com/open-webui/open-webui/issues/20129), [#20256](https://github.com/open-webui/open-webui/pull/20256)
- 🔌 Administrators no longer experience infinite loading screens when invalid or MCP-style configurations are used with OpenAPI connection types for external tools. [#20207](https://github.com/open-webui/open-webui/issues/20207), [#20257](https://github.com/open-webui/open-webui/pull/20257)
- 📥 Administrators no longer encounter TypeError crashes during SHA256 verification when uploading GGUF models via URL, fixing 500 Internal Server Error crashes. [#20263](https://github.com/open-webui/open-webui/issues/20263)
- 🚦 Users with Brave Search now experience automatic retry with a 1-second delay when hitting rate limits, preventing failures when sequential requests exceed the 1 request per second limit, though this only works reliably when web search concurrency is set to a maximum of 1. [#15134](https://github.com/open-webui/open-webui/issues/15134), [#20255](https://github.com/open-webui/open-webui/pull/20255)
- 🗄️ Administrators with Redis Sentinel deployments no longer experience crashes during websocket disconnections due to improper async-generator handling in the YDocManager. [#20142](https://github.com/open-webui/open-webui/issues/20142), [#20145](https://github.com/open-webui/open-webui/pull/20145)
- 🔐 Administrators using SCIM group management no longer encounter 500 errors when working with groups that have no members. [#20187](https://github.com/open-webui/open-webui/pull/20187)
- 🔗 Users now experience more reliable citations from AI models, especially when using smaller or weaker models that may not format citation references perfectly. [Commit](https://github.com/open-webui/open-webui/commit/c0ec04935b4eea3d334bfdec2fc41278f1085a49)
- 🕸️ Administrators can now successfully save WebSearch settings without encountering validation errors for domain filter lists, YouTube language settings, or timeout values. [#20422](https://github.com/open-webui/open-webui/pull/20422)
- 📦 Administrators installing with the uv package manager now experience successful installation after deprecated dependencies that were causing conflicts were removed. [#20177](https://github.com/open-webui/open-webui/issues/20177), [#20192](https://github.com/open-webui/open-webui/pull/20192)
- ⏱️ Administrators using custom "AIOHTTP_CLIENT_TIMEOUT" settings now see the configured timeout correctly applied to embedding generation, OAuth discovery, webhook calls, and tool/function loading instead of falling back to the default 300-second timeout. [Commit](https://github.com/open-webui/open-webui/commit/e67891a374625d9888ec391da561f0b4ed79ed5d)
### Changed
- ⚠️ This release includes a major overhaul of database connection handling in the backend that requires all instances in multi-worker, multi-server, or load-balanced deployments to be updated simultaneously; running mixed versions will cause failures due to incompatible database connection management between old and new instances.
- 📝 Administrators who previously used the standalone "Markdown (Header)" text splitter must now switch to "character" or "token" mode with the new "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER" toggle enabled, as document chunking now applies markdown header splitting as a preprocessing step before character or token splitting. [Commit](https://github.com/open-webui/open-webui/commit/d3a682759f375c6cb0bc8c170f62863a070f712e), [Commit](https://github.com/open-webui/open-webui/commit/18a33a079bf07487edffc421a721c86194cc90c9), [Commit](https://github.com/open-webui/open-webui/commit/08bf4670ec862018f9dc57296cb19fd5eab14ef6)
- 🖼️ Users no longer see the "Generate Image" action button in chat message interfaces; custom function should be used. [Commit](https://github.com/open-webui/open-webui/commit/f0829ba6e6fd200702fb76efc43dd785cf87fec9)
- 🔗 Administrators will find the Admin Evaluations page at the new URL "/admin/evaluations/feedback" instead of "/admin/evaluations/feedbacks" to use the correct uncountable form of the word. [#20296](https://github.com/open-webui/open-webui/pull/20296)
- 🔐 Scripts or integrations that directly called Image Generation, Web Search, or Audio APIs while those features were disabled in the Admin UI will now receive 403 Forbidden errors, as backend permission enforcement has been added to match frontend restrictions. [#20471](https://github.com/open-webui/open-webui/pull/20471)
- 👥 The default group sharing permission changed from "Members" to "Anyone", meaning users can now share content to any group configured with "Anyone" permission regardless of their membership in that group. [Commit](https://github.com/open-webui/open-webui/commit/ca514cd3eda2524b8da472ef17c0ccb216bac2e8)
## [0.6.43] - 2025-12-22 ## [0.6.43] - 2025-12-22
### Fixed ### Fixed
+8 -2
View File
@@ -13,6 +13,7 @@ ARG USE_CUDA_VER=cu128
# IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them. # IMPORTANT: If you change the embedding model (sentence-transformers/all-MiniLM-L6-v2) and vice versa, you aren't able to use RAG Chat with your previous documents loaded in the WebUI! You need to re-embed them.
ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 ARG USE_EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
ARG USE_RERANKING_MODEL="" ARG USE_RERANKING_MODEL=""
ARG USE_AUXILIARY_EMBEDDING_MODEL=TaylorAI/bge-micro-v2
# Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken # Tiktoken encoding name; models to use can be found at https://huggingface.co/models?library=tiktoken
ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base" ARG USE_TIKTOKEN_ENCODING_NAME="cl100k_base"
@@ -42,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
RUN npm run build RUN npm run build
######## WebUI backend ######## ######## WebUI backend ########
FROM python:3.11-slim-bookworm AS base FROM python:3.11.14-slim-bookworm AS base
# Use args # Use args
ARG USE_CUDA ARG USE_CUDA
@@ -52,6 +53,7 @@ ARG USE_SLIM
ARG USE_PERMISSION_HARDENING ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL ARG USE_RERANKING_MODEL
ARG USE_AUXILIARY_EMBEDDING_MODEL
ARG UID ARG UID
ARG GID ARG GID
@@ -67,7 +69,8 @@ ENV ENV=prod \
USE_SLIM_DOCKER=${USE_SLIM} \ USE_SLIM_DOCKER=${USE_SLIM} \
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \ USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \ USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL} \
USE_AUXILIARY_EMBEDDING_MODEL_DOCKER=${USE_AUXILIARY_EMBEDDING_MODEL}
## Basis URL Config ## ## Basis URL Config ##
ENV OLLAMA_BASE_URL="/ollama" \ ENV OLLAMA_BASE_URL="/ollama" \
@@ -88,6 +91,7 @@ ENV WHISPER_MODEL="base" \
## RAG Embedding model settings ## ## RAG Embedding model settings ##
ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \ ENV RAG_EMBEDDING_MODEL="$USE_EMBEDDING_MODEL_DOCKER" \
RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \ RAG_RERANKING_MODEL="$USE_RERANKING_MODEL_DOCKER" \
AUXILIARY_EMBEDDING_MODEL="$USE_AUXILIARY_EMBEDDING_MODEL_DOCKER" \
SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models" SENTENCE_TRANSFORMERS_HOME="/app/backend/data/cache/embedding/models"
## Tiktoken model settings ## ## Tiktoken model settings ##
@@ -136,6 +140,7 @@ RUN pip3 install --no-cache-dir uv && \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ pip3 install torch 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 && \ 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['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; 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 os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
else \ else \
@@ -143,6 +148,7 @@ RUN pip3 install --no-cache-dir uv && \
uv pip install --system -r requirements.txt --no-cache-dir && \ uv pip install --system -r requirements.txt --no-cache-dir && \
if [ "$USE_SLIM" != "true" ]; then \ 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['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; 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 os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
fi; \ fi; \
+114 -6
View File
@@ -19,6 +19,7 @@ from authlib.integrations.starlette_client import OAuth
from open_webui.env import ( from open_webui.env import (
DATA_DIR, DATA_DIR,
DATABASE_URL, DATABASE_URL,
ENABLE_DB_MIGRATIONS,
ENV, ENV,
REDIS_URL, REDIS_URL,
REDIS_KEY_PREFIX, REDIS_KEY_PREFIX,
@@ -67,7 +68,8 @@ def run_migrations():
log.exception(f"Error running migrations: {e}") log.exception(f"Error running migrations: {e}")
run_migrations() if ENABLE_DB_MIGRATIONS:
run_migrations()
class Config(Base): class Config(Base):
@@ -1468,6 +1470,15 @@ USER_PERMISSIONS_FEATURES_API_KEYS = (
os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true" os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true"
) )
USER_PERMISSIONS_FEATURES_MEMORIES = (
os.environ.get("USER_PERMISSIONS_FEATURES_MEMORIES", "True").lower() == "true"
)
USER_PERMISSIONS_SETTINGS_INTERFACE = (
os.environ.get("USER_PERMISSIONS_SETTINGS_INTERFACE", "True").lower() == "true"
)
DEFAULT_USER_PERMISSIONS = { DEFAULT_USER_PERMISSIONS = {
"workspace": { "workspace": {
@@ -1526,6 +1537,10 @@ DEFAULT_USER_PERMISSIONS = {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
"memories": USER_PERMISSIONS_FEATURES_MEMORIES,
},
"settings": {
"interface": USER_PERMISSIONS_SETTINGS_INTERFACE,
}, },
} }
@@ -1541,6 +1556,12 @@ ENABLE_FOLDERS = PersistentConfig(
os.environ.get("ENABLE_FOLDERS", "True").lower() == "true", os.environ.get("ENABLE_FOLDERS", "True").lower() == "true",
) )
FOLDER_MAX_FILE_COUNT = PersistentConfig(
"FOLDER_MAX_FILE_COUNT",
"folders.max_file_count",
os.environ.get("FOLDER_MAX_FILE_COUNT", ""),
)
ENABLE_CHANNELS = PersistentConfig( ENABLE_CHANNELS = PersistentConfig(
"ENABLE_CHANNELS", "ENABLE_CHANNELS",
"channels.enable", "channels.enable",
@@ -1553,6 +1574,12 @@ ENABLE_NOTES = PersistentConfig(
os.environ.get("ENABLE_NOTES", "True").lower() == "true", os.environ.get("ENABLE_NOTES", "True").lower() == "true",
) )
ENABLE_USER_STATUS = PersistentConfig(
"ENABLE_USER_STATUS",
"users.enable_status",
os.environ.get("ENABLE_USER_STATUS", "True").lower() == "true",
)
ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig( ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
"ENABLE_EVALUATION_ARENA_MODELS", "ENABLE_EVALUATION_ARENA_MODELS",
"evaluation.arena.enable", "evaluation.arena.enable",
@@ -2067,6 +2094,12 @@ ENABLE_CODE_INTERPRETER = PersistentConfig(
os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true", os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true",
) )
ENABLE_MEMORIES = PersistentConfig(
"ENABLE_MEMORIES",
"memories.enable",
os.environ.get("ENABLE_MEMORIES", "True").lower() == "true",
)
CODE_INTERPRETER_ENGINE = PersistentConfig( CODE_INTERPRETER_ENGINE = PersistentConfig(
"CODE_INTERPRETER_ENGINE", "CODE_INTERPRETER_ENGINE",
"code_interpreter.engine", "code_interpreter.engine",
@@ -2340,6 +2373,51 @@ else:
except Exception: except Exception:
PGVECTOR_IVFFLAT_LISTS = 100 PGVECTOR_IVFFLAT_LISTS = 100
# openGauss
OPENGAUSS_DB_URL = os.environ.get("OPENGAUSS_DB_URL", DATABASE_URL)
OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
OPENGAUSS_POOL_SIZE = os.environ.get("OPENGAUSS_POOL_SIZE", None)
if OPENGAUSS_POOL_SIZE != None:
try:
OPENGAUSS_POOL_SIZE = int(OPENGAUSS_POOL_SIZE)
except Exception:
OPENGAUSS_POOL_SIZE = None
OPENGAUSS_POOL_MAX_OVERFLOW = os.environ.get("OPENGAUSS_POOL_MAX_OVERFLOW", 0)
if OPENGAUSS_POOL_MAX_OVERFLOW == "":
OPENGAUSS_POOL_MAX_OVERFLOW = 0
else:
try:
OPENGAUSS_POOL_MAX_OVERFLOW = int(OPENGAUSS_POOL_MAX_OVERFLOW)
except Exception:
OPENGAUSS_POOL_MAX_OVERFLOW = 0
OPENGAUSS_POOL_TIMEOUT = os.environ.get("OPENGAUSS_POOL_TIMEOUT", 30)
if OPENGAUSS_POOL_TIMEOUT == "":
OPENGAUSS_POOL_TIMEOUT = 30
else:
try:
OPENGAUSS_POOL_TIMEOUT = int(OPENGAUSS_POOL_TIMEOUT)
except Exception:
OPENGAUSS_POOL_TIMEOUT = 30
OPENGAUSS_POOL_RECYCLE = os.environ.get("OPENGAUSS_POOL_RECYCLE", 3600)
if OPENGAUSS_POOL_RECYCLE == "":
OPENGAUSS_POOL_RECYCLE = 3600
else:
try:
OPENGAUSS_POOL_RECYCLE = int(OPENGAUSS_POOL_RECYCLE)
except Exception:
OPENGAUSS_POOL_RECYCLE = 3600
# Pinecone # Pinecone
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None) PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None) PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
@@ -2815,6 +2893,12 @@ RAG_TEXT_SPLITTER = PersistentConfig(
os.environ.get("RAG_TEXT_SPLITTER", ""), os.environ.get("RAG_TEXT_SPLITTER", ""),
) )
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = PersistentConfig(
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER",
"rag.enable_markdown_header_text_splitter",
os.environ.get("ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", "True").lower() == "true",
)
TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken") TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken")
TIKTOKEN_ENCODING_NAME = PersistentConfig( TIKTOKEN_ENCODING_NAME = PersistentConfig(
@@ -2827,6 +2911,13 @@ TIKTOKEN_ENCODING_NAME = PersistentConfig(
CHUNK_SIZE = PersistentConfig( CHUNK_SIZE = PersistentConfig(
"CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000")) "CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000"))
) )
CHUNK_MIN_SIZE_TARGET = PersistentConfig(
"CHUNK_MIN_SIZE_TARGET",
"rag.chunk_min_size_target",
int(os.environ.get("CHUNK_MIN_SIZE_TARGET", "0")),
)
CHUNK_OVERLAP = PersistentConfig( CHUNK_OVERLAP = PersistentConfig(
"CHUNK_OVERLAP", "CHUNK_OVERLAP",
"rag.chunk_overlap", "rag.chunk_overlap",
@@ -3127,12 +3218,24 @@ SERPLY_API_KEY = PersistentConfig(
os.getenv("SERPLY_API_KEY", ""), os.getenv("SERPLY_API_KEY", ""),
) )
DDGS_BACKEND = PersistentConfig(
"DDGS_BACKEND",
"rag.web.search.ddgs_backend",
os.getenv("DDGS_BACKEND", "auto"),
)
JINA_API_KEY = PersistentConfig( JINA_API_KEY = PersistentConfig(
"JINA_API_KEY", "JINA_API_KEY",
"rag.web.search.jina_api_key", "rag.web.search.jina_api_key",
os.getenv("JINA_API_KEY", ""), os.getenv("JINA_API_KEY", ""),
) )
JINA_API_BASE_URL = PersistentConfig(
"JINA_API_BASE_URL",
"rag.web.search.jina_api_base_url",
os.getenv("JINA_API_BASE_URL", ""),
)
SEARCHAPI_API_KEY = PersistentConfig( SEARCHAPI_API_KEY = PersistentConfig(
"SEARCHAPI_API_KEY", "SEARCHAPI_API_KEY",
"rag.web.search.searchapi_api_key", "rag.web.search.searchapi_api_key",
@@ -3267,6 +3370,12 @@ FIRECRAWL_API_BASE_URL = PersistentConfig(
os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"), os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"),
) )
FIRECRAWL_TIMEOUT = PersistentConfig(
"FIRECRAWL_TIMEOUT",
"rag.web.loader.firecrawl_timeout",
os.environ.get("FIRECRAWL_TIMEOUT", ""),
)
EXTERNAL_WEB_SEARCH_URL = PersistentConfig( EXTERNAL_WEB_SEARCH_URL = PersistentConfig(
"EXTERNAL_WEB_SEARCH_URL", "EXTERNAL_WEB_SEARCH_URL",
"rag.web.search.external_web_search_url", "rag.web.search.external_web_search_url",
@@ -3628,17 +3737,16 @@ WHISPER_MODEL = PersistentConfig(
os.getenv("WHISPER_MODEL", "base"), os.getenv("WHISPER_MODEL", "base"),
) )
WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models") WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
WHISPER_MODEL_AUTO_UPDATE = ( WHISPER_MODEL_AUTO_UPDATE = (
not OFFLINE_MODE not OFFLINE_MODE
and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true" and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
) )
WHISPER_VAD_FILTER = PersistentConfig( WHISPER_VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true"
"WHISPER_VAD_FILTER",
"audio.stt.whisper_vad_filter", WHISPER_MULTILINGUAL = os.getenv("WHISPER_MULTILINGUAL", "False").lower() == "true"
os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true",
)
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None
+39 -1
View File
@@ -117,6 +117,8 @@ VERSION = PACKAGE_DATA["version"]
DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "") DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "")
INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4())) INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4()))
ENABLE_DB_MIGRATIONS = os.environ.get("ENABLE_DB_MIGRATIONS", "True").lower() == "true"
# Function to parse each section # Function to parse each section
def parse_section(section): def parse_section(section):
@@ -339,6 +341,11 @@ if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None:
except Exception: except Exception:
DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0 DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0
# When enabled, get_db_context reuses existing sessions; set to False to always create new sessions
DATABASE_ENABLE_SESSION_SHARING = (
os.environ.get("DATABASE_ENABLE_SESSION_SHARING", "False").lower() == "true"
)
# Enable public visibility of active user count (when disabled, only admins can see it) # Enable public visibility of active user count (when disabled, only admins can see it)
ENABLE_PUBLIC_ACTIVE_USERS_COUNT = ( ENABLE_PUBLIC_ACTIVE_USERS_COUNT = (
os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true" os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true"
@@ -354,6 +361,8 @@ ENABLE_REALTIME_CHAT_SAVE = (
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true" ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
RAG_SYSTEM_CONTEXT = os.environ.get("RAG_SYSTEM_CONTEXT", "False").lower() == "true"
#################################### ####################################
# REDIS # REDIS
#################################### ####################################
@@ -409,6 +418,16 @@ ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true" os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
) )
####################################
# Admin Account Runtime Creation
####################################
# Optional env vars for creating an admin account on startup
# Useful for headless/automated deployments
WEBUI_ADMIN_EMAIL = os.environ.get("WEBUI_ADMIN_EMAIL", "")
WEBUI_ADMIN_PASSWORD = os.environ.get("WEBUI_ADMIN_PASSWORD", "")
WEBUI_ADMIN_NAME = os.environ.get("WEBUI_ADMIN_NAME", "Admin")
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
) )
@@ -426,12 +445,14 @@ PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$",
) )
try: try:
PASSWORD_VALIDATION_REGEX_PATTERN = rf"{PASSWORD_VALIDATION_REGEX_PATTERN}"
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN) PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN)
except Exception as e: except Exception as e:
log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}") log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}")
PASSWORD_VALIDATION_REGEX_PATTERN = re.compile( PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$" r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$"
) )
@@ -667,6 +688,8 @@ except ValueError:
WEBSOCKET_SERVER_PING_INTERVAL = 25 WEBSOCKET_SERVER_PING_INTERVAL = 25
REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true"
AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "")
if AIOHTTP_CLIENT_TIMEOUT == "": if AIOHTTP_CLIENT_TIMEOUT == "":
@@ -760,6 +783,16 @@ else:
except Exception: except Exception:
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
# Whether to apply sigmoid normalization to CrossEncoder reranking scores.
# When enabled (default), scores are normalized to 0-1 range for proper
# relevance threshold behavior with MS MARCO models.
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
os.environ.get(
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION", "True"
).lower()
== "true"
)
#################################### ####################################
# OFFLINE_MODE # OFFLINE_MODE
#################################### ####################################
@@ -776,6 +809,11 @@ if OFFLINE_MODE:
#################################### ####################################
# AUDIT LOGGING # AUDIT LOGGING
#################################### ####################################
ENABLE_AUDIT_STDOUT = os.getenv("ENABLE_AUDIT_STDOUT", "False").lower() == "true"
ENABLE_AUDIT_LOGS_FILE = os.getenv("ENABLE_AUDIT_LOGS_FILE", "True").lower() == "true"
# Where to store log file # Where to store log file
# Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to # Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to
# provide the whole path, like: /app/audit.log # provide the whole path, like: /app/audit.log
+15 -3
View File
@@ -14,11 +14,13 @@ from open_webui.env import (
DATABASE_POOL_SIZE, DATABASE_POOL_SIZE,
DATABASE_POOL_TIMEOUT, DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL, DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
ENABLE_DB_MIGRATIONS,
) )
from peewee_migrate import Router from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, MetaData, event, types from sqlalchemy import Dialect, create_engine, MetaData, event, types
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.orm import scoped_session, sessionmaker, Session
from sqlalchemy.pool import QueuePool, NullPool from sqlalchemy.pool import QueuePool, NullPool
from sqlalchemy.sql.type_api import _T from sqlalchemy.sql.type_api import _T
from typing_extensions import Self from typing_extensions import Self
@@ -75,7 +77,8 @@ def handle_peewee_migration(DATABASE_URL):
assert db.is_closed(), "Database connection is still open." assert db.is_closed(), "Database connection is still open."
handle_peewee_migration(DATABASE_URL) if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL SQLALCHEMY_DATABASE_URL = DATABASE_URL
@@ -146,7 +149,7 @@ SessionLocal = sessionmaker(
) )
metadata_obj = MetaData(schema=DATABASE_SCHEMA) metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj) Base = declarative_base(metadata=metadata_obj)
Session = scoped_session(SessionLocal) ScopedSession = scoped_session(SessionLocal)
def get_session(): def get_session():
@@ -158,3 +161,12 @@ def get_session():
get_db = contextmanager(get_session) get_db = contextmanager(get_session)
@contextmanager
def get_db_context(db: Optional[Session] = None):
if isinstance(db, Session) and DATABASE_ENABLE_SESSION_SHARING:
yield db
else:
with get_db() as session:
yield session
+47 -7
View File
@@ -102,7 +102,9 @@ from open_webui.routers.retrieval import (
get_rf, get_rf,
) )
from open_webui.internal.db import Session, engine
from sqlalchemy.orm import Session
from open_webui.internal.db import ScopedSession, engine, get_session
from open_webui.models.functions import Functions from open_webui.models.functions import Functions
from open_webui.models.models import Models from open_webui.models.models import Models
@@ -143,6 +145,7 @@ from open_webui.config import (
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
CODE_INTERPRETER_JUPYTER_TIMEOUT, CODE_INTERPRETER_JUPYTER_TIMEOUT,
ENABLE_MEMORIES,
# Image # Image
AUTOMATIC1111_API_AUTH, AUTOMATIC1111_API_AUTH,
AUTOMATIC1111_BASE_URL, AUTOMATIC1111_BASE_URL,
@@ -206,6 +209,7 @@ from open_webui.config import (
PLAYWRIGHT_TIMEOUT, PLAYWRIGHT_TIMEOUT,
FIRECRAWL_API_BASE_URL, FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY, FIRECRAWL_API_KEY,
FIRECRAWL_TIMEOUT,
WEB_LOADER_ENGINE, WEB_LOADER_ENGINE,
WEB_LOADER_CONCURRENT_REQUESTS, WEB_LOADER_CONCURRENT_REQUESTS,
WEB_LOADER_TIMEOUT, WEB_LOADER_TIMEOUT,
@@ -250,6 +254,7 @@ from open_webui.config import (
RAG_OLLAMA_BASE_URL, RAG_OLLAMA_BASE_URL,
RAG_OLLAMA_API_KEY, RAG_OLLAMA_API_KEY,
CHUNK_OVERLAP, CHUNK_OVERLAP,
CHUNK_MIN_SIZE_TARGET,
CHUNK_SIZE, CHUNK_SIZE,
CONTENT_EXTRACTION_ENGINE, CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY, DATALAB_MARKER_API_KEY,
@@ -280,6 +285,7 @@ from open_webui.config import (
MISTRAL_OCR_API_BASE_URL, MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY, MISTRAL_OCR_API_KEY,
RAG_TEXT_SPLITTER, RAG_TEXT_SPLITTER,
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
TIKTOKEN_ENCODING_NAME, TIKTOKEN_ENCODING_NAME,
PDF_EXTRACT_IMAGES, PDF_EXTRACT_IMAGES,
YOUTUBE_LOADER_LANGUAGE, YOUTUBE_LOADER_LANGUAGE,
@@ -295,6 +301,7 @@ from open_webui.config import (
WEB_SEARCH_DOMAIN_FILTER_LIST, WEB_SEARCH_DOMAIN_FILTER_LIST,
OLLAMA_CLOUD_WEB_SEARCH_API_KEY, OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY, JINA_API_KEY,
JINA_API_BASE_URL,
SEARCHAPI_API_KEY, SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE, SEARCHAPI_ENGINE,
SERPAPI_API_KEY, SERPAPI_API_KEY,
@@ -306,6 +313,7 @@ from open_webui.config import (
YACY_PASSWORD, YACY_PASSWORD,
SERPER_API_KEY, SERPER_API_KEY,
SERPLY_API_KEY, SERPLY_API_KEY,
DDGS_BACKEND,
SERPSTACK_API_KEY, SERPSTACK_API_KEY,
SERPSTACK_HTTPS, SERPSTACK_HTTPS,
TAVILY_API_KEY, TAVILY_API_KEY,
@@ -358,8 +366,10 @@ from open_webui.config import (
ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS,
API_KEYS_ALLOWED_ENDPOINTS, API_KEYS_ALLOWED_ENDPOINTS,
ENABLE_FOLDERS, ENABLE_FOLDERS,
FOLDER_MAX_FILE_COUNT,
ENABLE_CHANNELS, ENABLE_CHANNELS,
ENABLE_NOTES, ENABLE_NOTES,
ENABLE_USER_STATUS,
ENABLE_COMMUNITY_SHARING, ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING, ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS, ENABLE_USER_WEBHOOKS,
@@ -476,6 +486,10 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_STAR_SESSIONS_MIDDLEWARE, ENABLE_STAR_SESSIONS_MIDDLEWARE,
ENABLE_PUBLIC_ACTIVE_USERS_COUNT, ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
# Admin Account Runtime Creation
WEBUI_ADMIN_EMAIL,
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
) )
@@ -500,6 +514,7 @@ from open_webui.utils.auth import (
decode_token, decode_token,
get_admin_user, get_admin_user,
get_verified_user, get_verified_user,
create_admin_user,
) )
from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import ( from open_webui.utils.oauth import (
@@ -578,6 +593,12 @@ async def lifespan(app: FastAPI):
if LICENSE_KEY: if LICENSE_KEY:
get_license_data(app, LICENSE_KEY) get_license_data(app, LICENSE_KEY)
# Create admin account from env vars if specified and no users exist
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
if create_admin_user(WEBUI_ADMIN_EMAIL, WEBUI_ADMIN_PASSWORD, WEBUI_ADMIN_NAME):
# Disable signup since we now have an admin
app.state.config.ENABLE_SIGNUP = False
# This should be blocking (sync) so functions are not deactivated on first /get_models calls # This should be blocking (sync) so functions are not deactivated on first /get_models calls
# when the first user lands on the / route. # when the first user lands on the / route.
log.info("Installing external dependencies of functions and tools...") log.info("Installing external dependencies of functions and tools...")
@@ -774,11 +795,13 @@ app.state.config.BANNERS = WEBUI_BANNERS
app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS
app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_NOTES = ENABLE_NOTES app.state.config.ENABLE_NOTES = ENABLE_NOTES
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
app.state.config.ENABLE_USER_STATUS = ENABLE_USER_STATUS
app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS app.state.config.ENABLE_EVALUATION_ARENA_MODELS = ENABLE_EVALUATION_ARENA_MODELS
app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS app.state.config.EVALUATION_ARENA_MODELS = EVALUATION_ARENA_MODELS
@@ -886,11 +909,17 @@ app.state.config.MINERU_API_TIMEOUT = MINERU_API_TIMEOUT
app.state.config.MINERU_PARAMS = MINERU_PARAMS app.state.config.MINERU_PARAMS = MINERU_PARAMS
app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER
app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = (
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER
)
app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME
app.state.config.CHUNK_SIZE = CHUNK_SIZE app.state.config.CHUNK_SIZE = CHUNK_SIZE
app.state.config.CHUNK_MIN_SIZE_TARGET = CHUNK_MIN_SIZE_TARGET
app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP
app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
@@ -955,12 +984,14 @@ app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY
app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS
app.state.config.SERPER_API_KEY = SERPER_API_KEY app.state.config.SERPER_API_KEY = SERPER_API_KEY
app.state.config.SERPLY_API_KEY = SERPLY_API_KEY app.state.config.SERPLY_API_KEY = SERPLY_API_KEY
app.state.config.DDGS_BACKEND = DDGS_BACKEND
app.state.config.TAVILY_API_KEY = TAVILY_API_KEY app.state.config.TAVILY_API_KEY = TAVILY_API_KEY
app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY
app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE
app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY
app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE
app.state.config.JINA_API_KEY = JINA_API_KEY app.state.config.JINA_API_KEY = JINA_API_KEY
app.state.config.JINA_API_BASE_URL = JINA_API_BASE_URL
app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
app.state.config.EXA_API_KEY = EXA_API_KEY app.state.config.EXA_API_KEY = EXA_API_KEY
@@ -980,6 +1011,7 @@ app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT app.state.config.PLAYWRIGHT_TIMEOUT = PLAYWRIGHT_TIMEOUT
app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL app.state.config.FIRECRAWL_API_BASE_URL = FIRECRAWL_API_BASE_URL
app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
app.state.config.FIRECRAWL_TIMEOUT = FIRECRAWL_TIMEOUT
app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
app.state.EMBEDDING_FUNCTION = None app.state.EMBEDDING_FUNCTION = None
@@ -1088,6 +1120,7 @@ app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIM
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.ENABLE_MEMORIES = ENABLE_MEMORIES
app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL app.state.config.IMAGE_GENERATION_MODEL = IMAGE_GENERATION_MODEL
app.state.config.IMAGE_SIZE = IMAGE_SIZE app.state.config.IMAGE_SIZE = IMAGE_SIZE
@@ -1141,7 +1174,6 @@ app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL
app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY
app.state.config.WHISPER_MODEL = WHISPER_MODEL app.state.config.WHISPER_MODEL = WHISPER_MODEL
app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER
app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY
app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY
@@ -1324,7 +1356,7 @@ app.add_middleware(APIKeyRestrictionMiddleware)
async def commit_session_after_request(request: Request, call_next): async def commit_session_after_request(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
# log.debug("Commit session after request") # log.debug("Commit session after request")
Session.commit() ScopedSession.commit()
return response return response
@@ -1645,7 +1677,7 @@ async def chat_completion(
) )
# Insert chat files from parent message if any # Insert chat files from parent message if any
parent_message = metadata.get("parent_message", {}) parent_message = metadata.get("parent_message") or {}
parent_message_files = parent_message.get("files", []) parent_message_files = parent_message.get("files", [])
if parent_message_files: if parent_message_files:
try: try:
@@ -1902,6 +1934,7 @@ async def get_app_config(request: Request):
{ {
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_folders": app.state.config.ENABLE_FOLDERS, "enable_folders": app.state.config.ENABLE_FOLDERS,
"folder_max_file_count": app.state.config.FOLDER_MAX_FILE_COUNT,
"enable_channels": app.state.config.ENABLE_CHANNELS, "enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_notes": app.state.config.ENABLE_NOTES, "enable_notes": app.state.config.ENABLE_NOTES,
"enable_web_search": app.state.config.ENABLE_WEB_SEARCH, "enable_web_search": app.state.config.ENABLE_WEB_SEARCH,
@@ -1912,10 +1945,12 @@ async def get_app_config(request: Request):
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING, "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING, "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS, "enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS,
"enable_user_status": app.state.config.ENABLE_USER_STATUS,
"enable_admin_export": ENABLE_ADMIN_EXPORT, "enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"enable_memories": app.state.config.ENABLE_MEMORIES,
**( **(
{ {
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL, "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
@@ -2280,8 +2315,13 @@ async def oauth_login(provider: str, request: Request):
# - Email addresses are considered unique, so we fail registration if the email address is already taken # - Email addresses are considered unique, so we fail registration if the email address is already taken
@app.get("/oauth/{provider}/login/callback") @app.get("/oauth/{provider}/login/callback")
@app.get("/oauth/{provider}/callback") # Legacy endpoint @app.get("/oauth/{provider}/callback") # Legacy endpoint
async def oauth_login_callback(provider: str, request: Request, response: Response): async def oauth_login_callback(
return await oauth_manager.handle_callback(request, provider, response) provider: str,
request: Request,
response: Response,
db: Session = Depends(get_session),
):
return await oauth_manager.handle_callback(request, provider, response, db=db)
@app.get("/manifest.json") @app.get("/manifest.json")
@@ -2340,7 +2380,7 @@ async def healthcheck():
@app.get("/health/db") @app.get("/health/db")
async def healthcheck_with_db(): async def healthcheck_with_db():
Session.execute(text("SELECT 1;")).all() ScopedSession.execute(text("SELECT 1;")).all()
return {"status": True} return {"status": True}
+28 -18
View File
@@ -2,7 +2,8 @@ import logging
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import UserModel, UserProfileImageResponse, Users from open_webui.models.users import UserModel, UserProfileImageResponse, Users
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import Boolean, Column, String, Text from sqlalchemy import Boolean, Column, String, Text
@@ -87,8 +88,9 @@ class AuthsTable:
profile_image_url: str = "/user.png", profile_image_url: str = "/user.png",
role: str = "pending", role: str = "pending",
oauth: Optional[dict] = None, oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]: ) -> Optional[UserModel]:
with get_db() as db: with get_db_context(db) as db:
log.info("insert_new_auth") log.info("insert_new_auth")
id = str(uuid.uuid4()) id = str(uuid.uuid4())
@@ -100,7 +102,7 @@ class AuthsTable:
db.add(result) db.add(result)
user = Users.insert_new_user( user = Users.insert_new_user(
id, name, email, profile_image_url, role, oauth=oauth id, name, email, profile_image_url, role, oauth=oauth, db=db
) )
db.commit() db.commit()
@@ -112,16 +114,16 @@ class AuthsTable:
return None return None
def authenticate_user( def authenticate_user(
self, email: str, verify_password: callable self, email: str, verify_password: callable, db: Optional[Session] = None
) -> Optional[UserModel]: ) -> Optional[UserModel]:
log.info(f"authenticate_user: {email}") log.info(f"authenticate_user: {email}")
user = Users.get_user_by_email(email) user = Users.get_user_by_email(email, db=db)
if not user: if not user:
return None return None
try: try:
with get_db() as db: with get_db_context(db) as db:
auth = db.query(Auth).filter_by(id=user.id, active=True).first() auth = db.query(Auth).filter_by(id=user.id, active=True).first()
if auth: if auth:
if verify_password(auth.password): if verify_password(auth.password):
@@ -133,32 +135,38 @@ class AuthsTable:
except Exception: except Exception:
return None return None
def authenticate_user_by_api_key(self, api_key: str) -> Optional[UserModel]: def authenticate_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_api_key: {api_key}") log.info(f"authenticate_user_by_api_key: {api_key}")
# if no api_key, return None # if no api_key, return None
if not api_key: if not api_key:
return None return None
try: try:
user = Users.get_user_by_api_key(api_key) user = Users.get_user_by_api_key(api_key, db=db)
return user if user else None return user if user else None
except Exception: except Exception:
return False return False
def authenticate_user_by_email(self, email: str) -> Optional[UserModel]: def authenticate_user_by_email(
self, email: str, db: Optional[Session] = None
) -> Optional[UserModel]:
log.info(f"authenticate_user_by_email: {email}") log.info(f"authenticate_user_by_email: {email}")
try: try:
with get_db() as db: with get_db_context(db) as db:
auth = db.query(Auth).filter_by(email=email, active=True).first() auth = db.query(Auth).filter_by(email=email, active=True).first()
if auth: if auth:
user = Users.get_user_by_id(auth.id) user = Users.get_user_by_id(auth.id, db=db)
return user return user
except Exception: except Exception:
return None return None
def update_user_password_by_id(self, id: str, new_password: str) -> bool: def update_user_password_by_id(
self, id: str, new_password: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
result = ( result = (
db.query(Auth).filter_by(id=id).update({"password": new_password}) db.query(Auth).filter_by(id=id).update({"password": new_password})
) )
@@ -167,20 +175,22 @@ class AuthsTable:
except Exception: except Exception:
return False return False
def update_email_by_id(self, id: str, email: str) -> bool: def update_email_by_id(
self, id: str, email: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
result = db.query(Auth).filter_by(id=id).update({"email": email}) result = db.query(Auth).filter_by(id=id).update({"email": email})
db.commit() db.commit()
return True if result == 1 else False return True if result == 1 else False
except Exception: except Exception:
return False return False
def delete_auth_by_id(self, id: str) -> bool: def delete_auth_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
# Delete User # Delete User
result = Users.delete_user_by_id(id) result = Users.delete_user_by_id(id, db=db)
if result: if result:
db.query(Auth).filter_by(id=id).delete() db.query(Auth).filter_by(id=id).delete()
+204 -52
View File
@@ -1,9 +1,11 @@
import json import json
import secrets
import time import time
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -244,6 +246,11 @@ class CreateChannelForm(ChannelForm):
type: Optional[str] = None type: Optional[str] = None
class ChannelWebhookForm(BaseModel):
name: str
profile_image_url: Optional[str] = None
class ChannelTable: class ChannelTable:
def _collect_unique_user_ids( def _collect_unique_user_ids(
@@ -304,9 +311,9 @@ class ChannelTable:
return memberships return memberships
def insert_new_channel( def insert_new_channel(
self, form_data: CreateChannelForm, user_id: str self, form_data: CreateChannelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]: ) -> Optional[ChannelModel]:
with get_db() as db: with get_db_context(db) as db:
channel = ChannelModel( channel = ChannelModel(
**{ **{
**form_data.model_dump(), **form_data.model_dump(),
@@ -337,8 +344,8 @@ class ChannelTable:
db.commit() db.commit()
return channel return channel
def get_channels(self) -> list[ChannelModel]: def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db() as db: with get_db_context(db) as db:
channels = db.query(Channel).all() channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels] return [ChannelModel.model_validate(channel) for channel in channels]
@@ -384,10 +391,12 @@ class ChannelTable:
return query return query
def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]: def get_channels_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db_context(db) as db:
user_group_ids = [ user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id) group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
] ]
membership_channels = ( membership_channels = (
@@ -421,8 +430,10 @@ class ChannelTable:
all_channels = membership_channels + standard_channels all_channels = membership_channels + standard_channels
return [ChannelModel.model_validate(c) for c in all_channels] return [ChannelModel.model_validate(c) for c in all_channels]
def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]: def get_dm_channel_by_user_ids(
with get_db() as db: self, user_ids: list[str], db: Optional[Session] = None
) -> Optional[ChannelModel]:
with get_db_context(db) as db:
# Ensure uniqueness in case a list with duplicates is passed # Ensure uniqueness in case a list with duplicates is passed
unique_user_ids = list(set(user_ids)) unique_user_ids = list(set(user_ids))
@@ -460,8 +471,9 @@ class ChannelTable:
invited_by: str, invited_by: str,
user_ids: Optional[list[str]] = None, user_ids: Optional[list[str]] = None,
group_ids: Optional[list[str]] = None, group_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> list[ChannelMemberModel]: ) -> list[ChannelMemberModel]:
with get_db() as db: with get_db_context(db) as db:
# 1. Collect all user_ids including groups + inviter # 1. Collect all user_ids including groups + inviter
requested_users = self._collect_unique_user_ids( requested_users = self._collect_unique_user_ids(
invited_by, user_ids, group_ids invited_by, user_ids, group_ids
@@ -494,8 +506,9 @@ class ChannelTable:
self, self,
channel_id: str, channel_id: str,
user_ids: list[str], user_ids: list[str],
db: Optional[Session] = None,
) -> int: ) -> int:
with get_db() as db: with get_db_context(db) as db:
result = ( result = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -507,8 +520,10 @@ class ChannelTable:
db.commit() db.commit()
return result # number of rows deleted return result # number of rows deleted
def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool: def is_user_channel_manager(
with get_db() as db: self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
# Check if the user is the creator of the channel # Check if the user is the creator of the channel
# or has a 'manager' role in ChannelMember # or has a 'manager' role in ChannelMember
channel = db.query(Channel).filter(Channel.id == channel_id).first() channel = db.query(Channel).filter(Channel.id == channel_id).first()
@@ -527,9 +542,9 @@ class ChannelTable:
return membership is not None return membership is not None
def join_channel( def join_channel(
self, channel_id: str, user_id: str self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelMemberModel]: ) -> Optional[ChannelMemberModel]:
with get_db() as db: with get_db_context(db) as db:
# Check if the membership already exists # Check if the membership already exists
existing_membership = ( existing_membership = (
db.query(ChannelMember) db.query(ChannelMember)
@@ -565,8 +580,10 @@ class ChannelTable:
db.commit() db.commit()
return channel_member return channel_member
def leave_channel(self, channel_id: str, user_id: str) -> bool: def leave_channel(
with get_db() as db: self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = ( membership = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -587,9 +604,9 @@ class ChannelTable:
return True return True
def get_member_by_channel_and_user_id( def get_member_by_channel_and_user_id(
self, channel_id: str, user_id: str self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelMemberModel]: ) -> Optional[ChannelMemberModel]:
with get_db() as db: with get_db_context(db) as db:
membership = ( membership = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -600,8 +617,10 @@ class ChannelTable:
) )
return ChannelMemberModel.model_validate(membership) if membership else None return ChannelMemberModel.model_validate(membership) if membership else None
def get_members_by_channel_id(self, channel_id: str) -> list[ChannelMemberModel]: def get_members_by_channel_id(
with get_db() as db: self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelMemberModel]:
with get_db_context(db) as db:
memberships = ( memberships = (
db.query(ChannelMember) db.query(ChannelMember)
.filter(ChannelMember.channel_id == channel_id) .filter(ChannelMember.channel_id == channel_id)
@@ -612,8 +631,14 @@ class ChannelTable:
for membership in memberships for membership in memberships
] ]
def pin_channel(self, channel_id: str, user_id: str, is_pinned: bool) -> bool: def pin_channel(
with get_db() as db: self,
channel_id: str,
user_id: str,
is_pinned: bool,
db: Optional[Session] = None,
) -> bool:
with get_db_context(db) as db:
membership = ( membership = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -631,8 +656,10 @@ class ChannelTable:
db.commit() db.commit()
return True return True
def update_member_last_read_at(self, channel_id: str, user_id: str) -> bool: def update_member_last_read_at(
with get_db() as db: self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = ( membership = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -651,9 +678,13 @@ class ChannelTable:
return True return True
def update_member_active_status( def update_member_active_status(
self, channel_id: str, user_id: str, is_active: bool self,
channel_id: str,
user_id: str,
is_active: bool,
db: Optional[Session] = None,
) -> bool: ) -> bool:
with get_db() as db: with get_db_context(db) as db:
membership = ( membership = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -671,8 +702,10 @@ class ChannelTable:
db.commit() db.commit()
return True return True
def is_user_channel_member(self, channel_id: str, user_id: str) -> bool: def is_user_channel_member(
with get_db() as db: self, channel_id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
membership = ( membership = (
db.query(ChannelMember) db.query(ChannelMember)
.filter( .filter(
@@ -683,13 +716,20 @@ class ChannelTable:
) )
return membership is not None return membership is not None
def get_channel_by_id(self, id: str) -> Optional[ChannelModel]: def get_channel_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
channel = db.query(Channel).filter(Channel.id == id).first() ) -> Optional[ChannelModel]:
return ChannelModel.model_validate(channel) if channel else None try:
with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None
except Exception:
return None
def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]: def get_channels_by_file_id(
with get_db() as db: self, file_id: str, db: Optional[Session] = None
) -> list[ChannelModel]:
with get_db_context(db) as db:
channel_files = ( channel_files = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
) )
@@ -698,9 +738,9 @@ class ChannelTable:
return [ChannelModel.model_validate(channel) for channel in channels] return [ChannelModel.model_validate(channel) for channel in channels]
def get_channels_by_file_id_and_user_id( def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str self, file_id: str, user_id: str, db: Optional[Session] = None
) -> list[ChannelModel]: ) -> list[ChannelModel]:
with get_db() as db: with get_db_context(db) as db:
# 1. Determine which channels have this file # 1. Determine which channels have this file
channel_file_rows = ( channel_file_rows = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
@@ -724,7 +764,9 @@ class ChannelTable:
return [] return []
# Preload user's group membership # Preload user's group membership
user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)] user_group_ids = [
g.id for g in Groups.get_groups_by_member_id(user_id, db=db)
]
allowed_channels = [] allowed_channels = []
@@ -761,9 +803,9 @@ class ChannelTable:
return allowed_channels return allowed_channels
def get_channel_by_id_and_user_id( def get_channel_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelModel]: ) -> Optional[ChannelModel]:
with get_db() as db: with get_db_context(db) as db:
# Fetch the channel # Fetch the channel
channel: Channel = ( channel: Channel = (
db.query(Channel) db.query(Channel)
@@ -799,7 +841,7 @@ class ChannelTable:
# Determine user groups # Determine user groups
user_group_ids = [ user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id) group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
] ]
# Apply ACL rules # Apply ACL rules
@@ -818,9 +860,9 @@ class ChannelTable:
) )
def update_channel_by_id( def update_channel_by_id(
self, id: str, form_data: ChannelForm self, id: str, form_data: ChannelForm, db: Optional[Session] = None
) -> Optional[ChannelModel]: ) -> Optional[ChannelModel]:
with get_db() as db: with get_db_context(db) as db:
channel = db.query(Channel).filter(Channel.id == id).first() channel = db.query(Channel).filter(Channel.id == id).first()
if not channel: if not channel:
return None return None
@@ -839,9 +881,9 @@ class ChannelTable:
return ChannelModel.model_validate(channel) if channel else None return ChannelModel.model_validate(channel) if channel else None
def add_file_to_channel_by_id( def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str self, channel_id: str, file_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChannelFileModel]: ) -> Optional[ChannelFileModel]:
with get_db() as db: with get_db_context(db) as db:
channel_file = ChannelFileModel( channel_file = ChannelFileModel(
**{ **{
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
@@ -866,10 +908,14 @@ class ChannelTable:
return None return None
def set_file_message_id_in_channel_by_id( def set_file_message_id_in_channel_by_id(
self, channel_id: str, file_id: str, message_id: str self,
channel_id: str,
file_id: str,
message_id: str,
db: Optional[Session] = None,
) -> bool: ) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
channel_file = ( channel_file = (
db.query(ChannelFile) db.query(ChannelFile)
.filter_by(channel_id=channel_id, file_id=file_id) .filter_by(channel_id=channel_id, file_id=file_id)
@@ -886,9 +932,11 @@ class ChannelTable:
except Exception: except Exception:
return False return False
def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool: def remove_file_from_channel_by_id(
self, channel_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(ChannelFile).filter_by( db.query(ChannelFile).filter_by(
channel_id=channel_id, file_id=file_id channel_id=channel_id, file_id=file_id
).delete() ).delete()
@@ -897,11 +945,115 @@ class ChannelTable:
except Exception: except Exception:
return False return False
def delete_channel_by_id(self, id: str): def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
db.query(Channel).filter(Channel.id == id).delete() db.query(Channel).filter(Channel.id == id).delete()
db.commit() db.commit()
return True return True
####################
# Webhook Methods
####################
def insert_webhook(
self,
channel_id: str,
user_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = ChannelWebhookModel(
id=str(uuid.uuid4()),
channel_id=channel_id,
user_id=user_id,
name=form_data.name,
profile_image_url=form_data.profile_image_url,
token=secrets.token_urlsafe(32),
last_used_at=None,
created_at=int(time.time_ns()),
updated_at=int(time.time_ns()),
)
db.add(ChannelWebhook(**webhook.model_dump()))
db.commit()
return webhook
def get_webhooks_by_channel_id(
self, channel_id: str, db: Optional[Session] = None
) -> list[ChannelWebhookModel]:
with get_db_context(db) as db:
webhooks = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.channel_id == channel_id)
.all()
)
return [ChannelWebhookModel.model_validate(w) for w in webhooks]
def get_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def get_webhook_by_id_and_token(
self, webhook_id: str, token: str, db: Optional[Session] = None
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook)
.filter(
ChannelWebhook.id == webhook_id,
ChannelWebhook.token == token,
)
.first()
)
return ChannelWebhookModel.model_validate(webhook) if webhook else None
def update_webhook_by_id(
self,
webhook_id: str,
form_data: ChannelWebhookForm,
db: Optional[Session] = None,
) -> Optional[ChannelWebhookModel]:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
if not webhook:
return None
webhook.name = form_data.name
webhook.profile_image_url = form_data.profile_image_url
webhook.updated_at = int(time.time_ns())
db.commit()
return ChannelWebhookModel.model_validate(webhook)
def update_webhook_last_used_at(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
webhook = (
db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first()
)
if not webhook:
return False
webhook.last_used_at = int(time.time_ns())
db.commit()
return True
def delete_webhook_by_id(
self, webhook_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
result = (
db.query(ChannelWebhook)
.filter(ChannelWebhook.id == webhook_id)
.delete()
)
db.commit()
return result > 0
Channels = ChannelTable() Channels = ChannelTable()
+254 -109
View File
@@ -4,7 +4,8 @@ import time
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.folders import Folders from open_webui.models.folders import Folders
from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db from open_webui.utils.misc import sanitize_data_for_db, sanitize_text_for_db
@@ -210,6 +211,48 @@ class ChatUsageStatsListResponse(BaseModel):
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
class MessageStats(BaseModel):
id: str
role: str
model: Optional[str] = None
content_length: int
token_count: Optional[int] = None
timestamp: Optional[int] = None
rating: Optional[int] = None # Derived from message.annotation.rating
tags: Optional[list[str]] = None # Derived from message.annotation.tags
class ChatHistoryStats(BaseModel):
messages: dict[str, MessageStats]
currentId: Optional[str] = None
class ChatBody(BaseModel):
history: ChatHistoryStats
class AggregateChatStats(BaseModel):
average_response_time: float
average_user_message_content_length: float
average_assistant_message_content_length: float
models: dict[str, int]
message_count: int
history_models: dict[str, int]
history_message_count: int
history_user_message_count: int
history_assistant_message_count: int
class ChatStatsExport(BaseModel):
id: str
user_id: str
created_at: int
updated_at: int
tags: list[str] = []
stats: AggregateChatStats
chat: ChatBody
class ChatTable: class ChatTable:
def _clean_null_bytes(self, obj): def _clean_null_bytes(self, obj):
"""Recursively remove null bytes from strings in dict/list structures.""" """Recursively remove null bytes from strings in dict/list structures."""
@@ -238,8 +281,10 @@ class ChatTable:
return changed return changed
def insert_new_chat(self, user_id: str, form_data: ChatForm) -> Optional[ChatModel]: def insert_new_chat(
with get_db() as db: self, user_id: str, form_data: ChatForm, db: Optional[Session] = None
) -> Optional[ChatModel]:
with get_db_context(db) as db:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
chat = ChatModel( chat = ChatModel(
**{ **{
@@ -289,9 +334,12 @@ class ChatTable:
return chat return chat
def import_chats( def import_chats(
self, user_id: str, chat_import_forms: list[ChatImportForm] self,
user_id: str,
chat_import_forms: list[ChatImportForm],
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
chats = [] chats = []
for form_data in chat_import_forms: for form_data in chat_import_forms:
@@ -302,9 +350,11 @@ class ChatTable:
db.commit() db.commit()
return [ChatModel.model_validate(chat) for chat in chats] return [ChatModel.model_validate(chat) for chat in chats]
def update_chat_by_id(self, id: str, chat: dict) -> Optional[ChatModel]: def update_chat_by_id(
self, id: str, chat: dict, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat_item = db.get(Chat, id) chat_item = db.get(Chat, id)
chat_item.chat = self._clean_null_bytes(chat) chat_item.chat = self._clean_null_bytes(chat)
chat_item.title = ( chat_item.title = (
@@ -423,31 +473,37 @@ class ChatTable:
def add_message_files_by_id_and_message_id( def add_message_files_by_id_and_message_id(
self, id: str, message_id: str, files: list[dict] self, id: str, message_id: str, files: list[dict]
) -> list[dict]: ) -> list[dict]:
chat = self.get_chat_by_id(id) with get_db_context() as db:
if chat is None: chat = self.get_chat_by_id(id, db=db)
return None if chat is None:
return None
chat = chat.chat chat = chat.chat
history = chat.get("history", {}) history = chat.get("history", {})
message_files = [] message_files = []
if message_id in history.get("messages", {}): if message_id in history.get("messages", {}):
message_files = history["messages"][message_id].get("files", []) message_files = history["messages"][message_id].get("files", [])
message_files = message_files + files message_files = message_files + files
history["messages"][message_id]["files"] = message_files history["messages"][message_id]["files"] = message_files
chat["history"] = history chat["history"] = history
self.update_chat_by_id(id, chat) self.update_chat_by_id(id, chat, db=db)
return message_files return message_files
def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]: def insert_shared_chat_by_chat_id(
with get_db() as db: self, chat_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
with get_db_context(db) as db:
# Get the existing chat to share # Get the existing chat to share
chat = db.get(Chat, chat_id) chat = db.get(Chat, chat_id)
# Check if chat exists
if not chat:
return None
# Check if the chat is already shared # Check if the chat is already shared
if chat.share_id: if chat.share_id:
return self.get_chat_by_id_and_user_id(chat.share_id, "shared") return self.get_chat_by_id_and_user_id(chat.share_id, "shared", db=db)
# Create a new chat with the same data, but with a new ID # Create a new chat with the same data, but with a new ID
shared_chat = ChatModel( shared_chat = ChatModel(
**{ **{
@@ -476,16 +532,18 @@ class ChatTable:
db.commit() db.commit()
return shared_chat if (shared_result and result) else None return shared_chat if (shared_result and result) else None
def update_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]: def update_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, chat_id) chat = db.get(Chat, chat_id)
shared_chat = ( shared_chat = (
db.query(Chat).filter_by(user_id=f"shared-{chat_id}").first() db.query(Chat).filter_by(user_id=f"shared-{chat_id}").first()
) )
if shared_chat is None: if shared_chat is None:
return self.insert_shared_chat_by_chat_id(chat_id) return self.insert_shared_chat_by_chat_id(chat_id, db=db)
shared_chat.title = chat.title shared_chat.title = chat.title
shared_chat.chat = chat.chat shared_chat.chat = chat.chat
@@ -500,9 +558,11 @@ class ChatTable:
except Exception: except Exception:
return None return None
def delete_shared_chat_by_chat_id(self, chat_id: str) -> bool: def delete_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete() db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete()
db.commit() db.commit()
@@ -510,9 +570,11 @@ class ChatTable:
except Exception: except Exception:
return False return False
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool: def unarchive_all_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": False}) db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
db.commit() db.commit()
return True return True
@@ -520,10 +582,10 @@ class ChatTable:
return False return False
def update_chat_share_id_by_id( def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str] self, id: str, share_id: Optional[str], db: Optional[Session] = None
) -> Optional[ChatModel]: ) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
chat.share_id = share_id chat.share_id = share_id
db.commit() db.commit()
@@ -532,9 +594,11 @@ class ChatTable:
except Exception: except Exception:
return None return None
def toggle_chat_pinned_by_id(self, id: str) -> Optional[ChatModel]: def toggle_chat_pinned_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
chat.pinned = not chat.pinned chat.pinned = not chat.pinned
chat.updated_at = int(time.time()) chat.updated_at = int(time.time())
@@ -544,9 +608,11 @@ class ChatTable:
except Exception: except Exception:
return None return None
def toggle_chat_archive_by_id(self, id: str) -> Optional[ChatModel]: def toggle_chat_archive_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
chat.archived = not chat.archived chat.archived = not chat.archived
chat.folder_id = None chat.folder_id = None
@@ -557,9 +623,11 @@ class ChatTable:
except Exception: except Exception:
return None return None
def archive_all_chats_by_user_id(self, user_id: str) -> bool: def archive_all_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": True}) db.query(Chat).filter_by(user_id=user_id).update({"archived": True})
db.commit() db.commit()
return True return True
@@ -572,9 +640,10 @@ class ChatTable:
filter: Optional[dict] = None, filter: Optional[dict] = None,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id, archived=True) query = db.query(Chat).filter_by(user_id=user_id, archived=True)
if filter: if filter:
@@ -613,8 +682,9 @@ class ChatTable:
filter: Optional[dict] = None, filter: Optional[dict] = None,
skip: int = 0, skip: int = 0,
limit: int = 50, limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id) query = db.query(Chat).filter_by(user_id=user_id)
if not include_archived: if not include_archived:
query = query.filter_by(archived=False) query = query.filter_by(archived=False)
@@ -653,8 +723,9 @@ class ChatTable:
include_pinned: bool = False, include_pinned: bool = False,
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
db: Optional[Session] = None,
) -> list[ChatTitleIdResponse]: ) -> list[ChatTitleIdResponse]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id) query = db.query(Chat).filter_by(user_id=user_id)
if not include_folders: if not include_folders:
@@ -691,9 +762,13 @@ class ChatTable:
] ]
def get_chat_list_by_chat_ids( def get_chat_list_by_chat_ids(
self, chat_ids: list[str], skip: int = 0, limit: int = 50 self,
chat_ids: list[str],
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
all_chats = ( all_chats = (
db.query(Chat) db.query(Chat)
.filter(Chat.id.in_(chat_ids)) .filter(Chat.id.in_(chat_ids))
@@ -703,9 +778,11 @@ class ChatTable:
) )
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_by_id(self, id: str) -> Optional[ChatModel]: def get_chat_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat_item = db.get(Chat, id) chat_item = db.get(Chat, id)
if chat_item is None: if chat_item is None:
return None return None
@@ -718,30 +795,36 @@ class ChatTable:
except Exception: except Exception:
return None return None
def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]: def get_chat_by_share_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
# it is possible that the shared link was deleted. hence, # it is possible that the shared link was deleted. hence,
# we check if the chat is still shared by checking if a chat with the share_id exists # we check if the chat is still shared by checking if a chat with the share_id exists
chat = db.query(Chat).filter_by(share_id=id).first() chat = db.query(Chat).filter_by(share_id=id).first()
if chat: if chat:
return self.get_chat_by_id(id) return self.get_chat_by_id(id, db=db)
else: else:
return None return None
except Exception: except Exception:
return None return None
def get_chat_by_id_and_user_id(self, id: str, user_id: str) -> Optional[ChatModel]: def get_chat_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.query(Chat).filter_by(id=id, user_id=user_id).first() chat = db.query(Chat).filter_by(id=id, user_id=user_id).first()
return ChatModel.model_validate(chat) return ChatModel.model_validate(chat)
except Exception: except Exception:
return None return None
def get_chats(self, skip: int = 0, limit: int = 50) -> list[ChatModel]: def get_chats(
with get_db() as db: self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
all_chats = ( all_chats = (
db.query(Chat) db.query(Chat)
# .limit(limit).offset(skip) # .limit(limit).offset(skip)
@@ -750,14 +833,34 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_user_id( def get_chats_by_user_id(
self, user_id: str, skip: Optional[int] = None, limit: Optional[int] = None self,
user_id: str,
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
db: Optional[Session] = None,
) -> ChatListResponse: ) -> ChatListResponse:
with get_db() as db: with get_db_context(db) as db:
query = ( query = db.query(Chat).filter_by(user_id=user_id)
db.query(Chat)
.filter_by(user_id=user_id) if filter:
.order_by(Chat.updated_at.desc()) if filter.get("updated_at"):
) query = query.filter(Chat.updated_at > filter.get("updated_at"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction:
if hasattr(Chat, order_by):
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
query = query.order_by(Chat.updated_at.desc())
else:
query = query.order_by(Chat.updated_at.desc())
total = query.count() total = query.count()
@@ -775,8 +878,10 @@ class ChatTable:
} }
) )
def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]: def get_pinned_chats_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
all_chats = ( all_chats = (
db.query(Chat) db.query(Chat)
.filter_by(user_id=user_id, pinned=True, archived=False) .filter_by(user_id=user_id, pinned=True, archived=False)
@@ -784,8 +889,10 @@ class ChatTable:
) )
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_archived_chats_by_user_id(self, user_id: str) -> list[ChatModel]: def get_archived_chats_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
all_chats = ( all_chats = (
db.query(Chat) db.query(Chat)
.filter_by(user_id=user_id, archived=True) .filter_by(user_id=user_id, archived=True)
@@ -800,6 +907,7 @@ class ChatTable:
include_archived: bool = False, include_archived: bool = False,
skip: int = 0, skip: int = 0,
limit: int = 60, limit: int = 60,
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
""" """
Filters chats based on a search query using Python, allowing pagination using skip and limit. Filters chats based on a search query using Python, allowing pagination using skip and limit.
@@ -808,7 +916,7 @@ class ChatTable:
if not search_text: if not search_text:
return self.get_chat_list_by_user_id( return self.get_chat_list_by_user_id(
user_id, include_archived, filter={}, skip=skip, limit=limit user_id, include_archived, filter={}, skip=skip, limit=limit, db=db
) )
search_text_words = search_text.split(" ") search_text_words = search_text.split(" ")
@@ -863,7 +971,7 @@ class ChatTable:
search_text = " ".join(search_text_words) search_text = " ".join(search_text_words)
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter(Chat.user_id == user_id) query = db.query(Chat).filter(Chat.user_id == user_id)
if is_archived is not None: if is_archived is not None:
@@ -1004,9 +1112,14 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id( def get_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60 self,
folder_id: str,
user_id: str,
skip: int = 0,
limit: int = 60,
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id) query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
query = query.filter_by(archived=False) query = query.filter_by(archived=False)
@@ -1022,9 +1135,9 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_ids_and_user_id( def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str self, folder_ids: list[str], user_id: str, db: Optional[Session] = None
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter( query = db.query(Chat).filter(
Chat.folder_id.in_(folder_ids), Chat.user_id == user_id Chat.folder_id.in_(folder_ids), Chat.user_id == user_id
) )
@@ -1037,10 +1150,10 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def update_chat_folder_id_by_id_and_user_id( def update_chat_folder_id_by_id_and_user_id(
self, id: str, user_id: str, folder_id: str self, id: str, user_id: str, folder_id: str, db: Optional[Session] = None
) -> Optional[ChatModel]: ) -> Optional[ChatModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
chat.folder_id = folder_id chat.folder_id = folder_id
chat.updated_at = int(time.time()) chat.updated_at = int(time.time())
@@ -1051,16 +1164,23 @@ class ChatTable:
except Exception: except Exception:
return None return None
def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str) -> list[TagModel]: def get_chat_tags_by_id_and_user_id(
with get_db() as db: self, id: str, user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
tags = chat.meta.get("tags", []) tags = chat.meta.get("tags", [])
return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags] return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags]
def get_chat_list_by_user_id_and_tag_name( def get_chat_list_by_user_id_and_tag_name(
self, user_id: str, tag_name: str, skip: int = 0, limit: int = 50 self,
user_id: str,
tag_name: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id) query = db.query(Chat).filter_by(user_id=user_id)
tag_id = tag_name.replace(" ", "_").lower() tag_id = tag_name.replace(" ", "_").lower()
@@ -1089,13 +1209,13 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def add_chat_tag_by_id_and_user_id_and_tag_name( def add_chat_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
) -> Optional[ChatModel]: ) -> Optional[ChatModel]:
tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id) tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id)
if tag is None: if tag is None:
tag = Tags.insert_new_tag(tag_name, user_id) tag = Tags.insert_new_tag(tag_name, user_id)
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
tag_id = tag.id tag_id = tag.id
@@ -1111,8 +1231,10 @@ class ChatTable:
except Exception: except Exception:
return None return None
def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str) -> int: def count_chats_by_tag_name_and_user_id(
with get_db() as db: # Assuming `get_db()` returns a session object self, tag_name: str, user_id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db: # Assuming `get_db()` returns a session object
query = db.query(Chat).filter_by(user_id=user_id, archived=False) query = db.query(Chat).filter_by(user_id=user_id, archived=False)
# Normalize the tag_name for consistency # Normalize the tag_name for consistency
@@ -1147,8 +1269,10 @@ class ChatTable:
return count return count
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int: def count_chats_by_folder_id_and_user_id(
with get_db() as db: self, folder_id: str, user_id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id) query = db.query(Chat).filter_by(user_id=user_id)
query = query.filter_by(folder_id=folder_id) query = query.filter_by(folder_id=folder_id)
@@ -1158,10 +1282,10 @@ class ChatTable:
return count return count
def delete_tag_by_id_and_user_id_and_tag_name( def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
) -> bool: ) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
tags = chat.meta.get("tags", []) tags = chat.meta.get("tags", [])
tag_id = tag_name.replace(" ", "_").lower() tag_id = tag_name.replace(" ", "_").lower()
@@ -1176,9 +1300,11 @@ class ChatTable:
except Exception: except Exception:
return False return False
def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str) -> bool: def delete_all_tags_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
chat = db.get(Chat, id) chat = db.get(Chat, id)
chat.meta = { chat.meta = {
**chat.meta, **chat.meta,
@@ -1190,30 +1316,34 @@ class ChatTable:
except Exception: except Exception:
return False return False
def delete_chat_by_id(self, id: str) -> bool: def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(id=id).delete() db.query(Chat).filter_by(id=id).delete()
db.commit() db.commit()
return True and self.delete_shared_chat_by_chat_id(id) return True and self.delete_shared_chat_by_chat_id(id, db=db)
except Exception: except Exception:
return False return False
def delete_chat_by_id_and_user_id(self, id: str, user_id: str) -> bool: def delete_chat_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(id=id, user_id=user_id).delete() db.query(Chat).filter_by(id=id, user_id=user_id).delete()
db.commit() db.commit()
return True and self.delete_shared_chat_by_chat_id(id) return True and self.delete_shared_chat_by_chat_id(id, db=db)
except Exception: except Exception:
return False return False
def delete_chats_by_user_id(self, user_id: str) -> bool: def delete_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
self.delete_shared_chats_by_user_id(user_id) self.delete_shared_chats_by_user_id(user_id, db=db)
db.query(Chat).filter_by(user_id=user_id).delete() db.query(Chat).filter_by(user_id=user_id).delete()
db.commit() db.commit()
@@ -1223,10 +1353,10 @@ class ChatTable:
return False return False
def delete_chats_by_user_id_and_folder_id( def delete_chats_by_user_id_and_folder_id(
self, user_id: str, folder_id: str self, user_id: str, folder_id: str, db: Optional[Session] = None
) -> bool: ) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete() db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete()
db.commit() db.commit()
@@ -1235,10 +1365,14 @@ class ChatTable:
return False return False
def move_chats_by_user_id_and_folder_id( def move_chats_by_user_id_and_folder_id(
self, user_id: str, folder_id: str, new_folder_id: Optional[str] self,
user_id: str,
folder_id: str,
new_folder_id: Optional[str],
db: Optional[Session] = None,
) -> bool: ) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update( db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update(
{"folder_id": new_folder_id} {"folder_id": new_folder_id}
) )
@@ -1248,9 +1382,11 @@ class ChatTable:
except Exception: except Exception:
return False return False
def delete_shared_chats_by_user_id(self, user_id: str) -> bool: def delete_shared_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user] shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user]
@@ -1262,7 +1398,12 @@ class ChatTable:
return False return False
def insert_chat_files( def insert_chat_files(
self, chat_id: str, message_id: str, file_ids: list[str], user_id: str self,
chat_id: str,
message_id: str,
file_ids: list[str],
user_id: str,
db: Optional[Session] = None,
) -> Optional[list[ChatFileModel]]: ) -> Optional[list[ChatFileModel]]:
if not file_ids: if not file_ids:
return None return None
@@ -1270,7 +1411,7 @@ class ChatTable:
chat_message_file_ids = [ chat_message_file_ids = [
item.id item.id
for item in self.get_chat_files_by_chat_id_and_message_id( for item in self.get_chat_files_by_chat_id_and_message_id(
chat_id, message_id chat_id, message_id, db=db
) )
] ]
# Remove duplicates and existing file_ids # Remove duplicates and existing file_ids
@@ -1287,7 +1428,7 @@ class ChatTable:
return None return None
try: try:
with get_db() as db: with get_db_context(db) as db:
now = int(time.time()) now = int(time.time())
chat_files = [ chat_files = [
@@ -1315,9 +1456,9 @@ class ChatTable:
return None return None
def get_chat_files_by_chat_id_and_message_id( def get_chat_files_by_chat_id_and_message_id(
self, chat_id: str, message_id: str self, chat_id: str, message_id: str, db: Optional[Session] = None
) -> list[ChatFileModel]: ) -> list[ChatFileModel]:
with get_db() as db: with get_db_context(db) as db:
all_chat_files = ( all_chat_files = (
db.query(ChatFile) db.query(ChatFile)
.filter_by(chat_id=chat_id, message_id=message_id) .filter_by(chat_id=chat_id, message_id=message_id)
@@ -1328,17 +1469,21 @@ class ChatTable:
ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files
] ]
def delete_chat_file(self, chat_id: str, file_id: str) -> bool: def delete_chat_file(
self, chat_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete() db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete()
db.commit() db.commit()
return True return True
except Exception: except Exception:
return False return False
def get_shared_chats_by_file_id(self, file_id: str) -> list[ChatModel]: def get_shared_chats_by_file_id(
with get_db() as db: self, file_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
with get_db_context(db) as db:
# Join Chat and ChatFile tables to get shared chats associated with the file_id # Join Chat and ChatFile tables to get shared chats associated with the file_id
all_chats = ( all_chats = (
db.query(Chat) db.query(Chat)
+164 -27
View File
@@ -3,7 +3,8 @@ import time
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import User from open_webui.models.users import User
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -67,6 +68,13 @@ class FeedbackIdResponse(BaseModel):
updated_at: int updated_at: int
class LeaderboardFeedbackData(BaseModel):
"""Minimal feedback data for leaderboard computation (excludes snapshot/meta)."""
id: str
data: Optional[dict] = None
class RatingData(BaseModel): class RatingData(BaseModel):
rating: Optional[str | int] = None rating: Optional[str | int] = None
model_id: Optional[str] = None model_id: Optional[str] = None
@@ -119,11 +127,22 @@ class FeedbackListResponse(BaseModel):
total: int total: int
class ModelHistoryEntry(BaseModel):
date: str
won: int
lost: int
class ModelHistoryResponse(BaseModel):
model_id: str
history: list[ModelHistoryEntry]
class FeedbackTable: class FeedbackTable:
def insert_new_feedback( def insert_new_feedback(
self, user_id: str, form_data: FeedbackForm self, user_id: str, form_data: FeedbackForm, db: Optional[Session] = None
) -> Optional[FeedbackModel]: ) -> Optional[FeedbackModel]:
with get_db() as db: with get_db_context(db) as db:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
feedback = FeedbackModel( feedback = FeedbackModel(
**{ **{
@@ -148,9 +167,11 @@ class FeedbackTable:
log.exception(f"Error creating a new feedback: {e}") log.exception(f"Error creating a new feedback: {e}")
return None return None
def get_feedback_by_id(self, id: str) -> Optional[FeedbackModel]: def get_feedback_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first() feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback: if not feedback:
return None return None
@@ -159,10 +180,10 @@ class FeedbackTable:
return None return None
def get_feedback_by_id_and_user_id( def get_feedback_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FeedbackModel]: ) -> Optional[FeedbackModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback: if not feedback:
return None return None
@@ -171,9 +192,13 @@ class FeedbackTable:
return None return None
def get_feedback_items( def get_feedback_items(
self, filter: dict = {}, skip: int = 0, limit: int = 30 self,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> FeedbackListResponse: ) -> FeedbackListResponse:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id) query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
if filter: if filter:
@@ -234,8 +259,8 @@ class FeedbackTable:
return FeedbackListResponse(items=feedbacks, total=total) return FeedbackListResponse(items=feedbacks, total=total)
def get_all_feedbacks(self) -> list[FeedbackModel]: def get_all_feedbacks(self, db: Optional[Session] = None) -> list[FeedbackModel]:
with get_db() as db: with get_db_context(db) as db:
return [ return [
FeedbackModel.model_validate(feedback) FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback) for feedback in db.query(Feedback)
@@ -243,8 +268,110 @@ class FeedbackTable:
.all() .all()
] ]
def get_feedbacks_by_type(self, type: str) -> list[FeedbackModel]: def get_all_feedback_ids(
with get_db() as db: self, db: Optional[Session] = None
) -> list[FeedbackIdResponse]:
with get_db_context(db) as db:
return [
FeedbackIdResponse(
id=row.id,
user_id=row.user_id,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in db.query(
Feedback.id,
Feedback.user_id,
Feedback.created_at,
Feedback.updated_at,
)
.order_by(Feedback.updated_at.desc())
.all()
]
def get_feedbacks_for_leaderboard(
self, db: Optional[Session] = None
) -> list[LeaderboardFeedbackData]:
"""Fetch only id and data for leaderboard computation (excludes snapshot/meta)."""
with get_db_context(db) as db:
return [
LeaderboardFeedbackData(id=row.id, data=row.data)
for row in db.query(Feedback.id, Feedback.data).all()
]
def get_model_evaluation_history(
self, model_id: str, days: int = 30, db: Optional[Session] = None
) -> list[ModelHistoryEntry]:
"""
Get daily wins/losses for a specific model over the past N days.
If days=0, returns all time data starting from first feedback.
Returns: [{"date": "2026-01-08", "won": 5, "lost": 2}, ...]
"""
from datetime import datetime, timedelta
from collections import defaultdict
with get_db_context(db) as db:
if days == 0:
# All time - no cutoff
rows = db.query(Feedback.created_at, Feedback.data).all()
else:
cutoff = int(time.time()) - (days * 86400)
rows = (
db.query(Feedback.created_at, Feedback.data)
.filter(Feedback.created_at >= cutoff)
.all()
)
daily_counts = defaultdict(lambda: {"won": 0, "lost": 0})
first_date = None
for created_at, data in rows:
if not data:
continue
if data.get("model_id") != model_id:
continue
rating_str = str(data.get("rating", ""))
if rating_str not in ("1", "-1"):
continue
date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d")
if rating_str == "1":
daily_counts[date_str]["won"] += 1
else:
daily_counts[date_str]["lost"] += 1
# Track first date for this model
if first_date is None or date_str < first_date:
first_date = date_str
# Generate date range
result = []
today = datetime.now().date()
if days == 0 and first_date:
# All time: start from first feedback date
start_date = datetime.strptime(first_date, "%Y-%m-%d").date()
num_days = (today - start_date).days + 1
else:
# Fixed range
num_days = days
start_date = today - timedelta(days=days - 1)
for i in range(num_days):
d = start_date + timedelta(days=i)
date_str = d.strftime("%Y-%m-%d")
counts = daily_counts.get(date_str, {"won": 0, "lost": 0})
result.append(
ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"])
)
return result
def get_feedbacks_by_type(
self, type: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [ return [
FeedbackModel.model_validate(feedback) FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback) for feedback in db.query(Feedback)
@@ -253,8 +380,10 @@ class FeedbackTable:
.all() .all()
] ]
def get_feedbacks_by_user_id(self, user_id: str) -> list[FeedbackModel]: def get_feedbacks_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
with get_db_context(db) as db:
return [ return [
FeedbackModel.model_validate(feedback) FeedbackModel.model_validate(feedback)
for feedback in db.query(Feedback) for feedback in db.query(Feedback)
@@ -264,9 +393,9 @@ class FeedbackTable:
] ]
def update_feedback_by_id( def update_feedback_by_id(
self, id: str, form_data: FeedbackForm self, id: str, form_data: FeedbackForm, db: Optional[Session] = None
) -> Optional[FeedbackModel]: ) -> Optional[FeedbackModel]:
with get_db() as db: with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first() feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback: if not feedback:
return None return None
@@ -284,9 +413,13 @@ class FeedbackTable:
return FeedbackModel.model_validate(feedback) return FeedbackModel.model_validate(feedback)
def update_feedback_by_id_and_user_id( def update_feedback_by_id_and_user_id(
self, id: str, user_id: str, form_data: FeedbackForm self,
id: str,
user_id: str,
form_data: FeedbackForm,
db: Optional[Session] = None,
) -> Optional[FeedbackModel]: ) -> Optional[FeedbackModel]:
with get_db() as db: with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback: if not feedback:
return None return None
@@ -303,8 +436,8 @@ class FeedbackTable:
db.commit() db.commit()
return FeedbackModel.model_validate(feedback) return FeedbackModel.model_validate(feedback)
def delete_feedback_by_id(self, id: str) -> bool: def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
feedback = db.query(Feedback).filter_by(id=id).first() feedback = db.query(Feedback).filter_by(id=id).first()
if not feedback: if not feedback:
return False return False
@@ -312,8 +445,10 @@ class FeedbackTable:
db.commit() db.commit()
return True return True
def delete_feedback_by_id_and_user_id(self, id: str, user_id: str) -> bool: def delete_feedback_by_id_and_user_id(
with get_db() as db: 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() feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first()
if not feedback: if not feedback:
return False return False
@@ -321,8 +456,10 @@ class FeedbackTable:
db.commit() db.commit()
return True return True
def delete_feedbacks_by_user_id(self, user_id: str) -> bool: def delete_feedbacks_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
feedbacks = db.query(Feedback).filter_by(user_id=user_id).all() feedbacks = db.query(Feedback).filter_by(user_id=user_id).all()
if not feedbacks: if not feedbacks:
return False return False
@@ -331,8 +468,8 @@ class FeedbackTable:
db.commit() db.commit()
return True return True
def delete_all_feedbacks(self) -> bool: def delete_all_feedbacks(self, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
feedbacks = db.query(Feedback).all() feedbacks = db.query(Feedback).all()
if not feedbacks: if not feedbacks:
return False return False
+126 -36
View File
@@ -2,7 +2,8 @@ import logging
import time import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON from sqlalchemy import BigInteger, Column, String, Text, JSON
@@ -108,8 +109,10 @@ class FileListResponse(BaseModel):
class FilesTable: class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]: def insert_new_file(
with get_db() as db: self, user_id: str, form_data: FileForm, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
file = FileModel( file = FileModel(
**{ **{
**form_data.model_dump(), **form_data.model_dump(),
@@ -132,16 +135,23 @@ class FilesTable:
log.exception(f"Error inserting a new file: {e}") log.exception(f"Error inserting a new file: {e}")
return None return None
def get_file_by_id(self, id: str) -> Optional[FileModel]: def get_file_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
try: ) -> Optional[FileModel]:
file = db.get(File, id) try:
return FileModel.model_validate(file) with get_db_context(db) as db:
except Exception: try:
return None file = db.get(File, id)
return FileModel.model_validate(file)
except Exception:
return None
except Exception:
return None
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]: def get_file_by_id_and_user_id(
with get_db() as db: self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try: try:
file = db.query(File).filter_by(id=id, user_id=user_id).first() file = db.query(File).filter_by(id=id, user_id=user_id).first()
if file: if file:
@@ -151,8 +161,10 @@ class FilesTable:
except Exception: except Exception:
return None return None
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]: def get_file_metadata_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> Optional[FileMetadataResponse]:
with get_db_context(db) as db:
try: try:
file = db.get(File, id) file = db.get(File, id)
return FileMetadataResponse( return FileMetadataResponse(
@@ -165,12 +177,14 @@ class FilesTable:
except Exception: except Exception:
return None return None
def get_files(self) -> list[FileModel]: def get_files(self, db: Optional[Session] = None) -> list[FileModel]:
with get_db() as db: with get_db_context(db) as db:
return [FileModel.model_validate(file) for file in db.query(File).all()] return [FileModel.model_validate(file) for file in db.query(File).all()]
def check_access_by_user_id(self, id, user_id, permission="write") -> bool: def check_access_by_user_id(
file = self.get_file_by_id(id) self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
file = self.get_file_by_id(id, db=db)
if not file: if not file:
return False return False
if file.user_id == user_id: if file.user_id == user_id:
@@ -178,8 +192,10 @@ class FilesTable:
# Implement additional access control logic here as needed # Implement additional access control logic here as needed
return False return False
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]: def get_files_by_ids(
with get_db() as db: self, ids: list[str], db: Optional[Session] = None
) -> list[FileModel]:
with get_db_context(db) as db:
return [ return [
FileModel.model_validate(file) FileModel.model_validate(file)
for file in db.query(File) for file in db.query(File)
@@ -188,8 +204,10 @@ class FilesTable:
.all() .all()
] ]
def get_file_metadatas_by_ids(self, ids: list[str]) -> list[FileMetadataResponse]: def get_file_metadatas_by_ids(
with get_db() as db: self, ids: list[str], db: Optional[Session] = None
) -> list[FileMetadataResponse]:
with get_db_context(db) as db:
return [ return [
FileMetadataResponse( FileMetadataResponse(
id=file.id, id=file.id,
@@ -206,17 +224,81 @@ class FilesTable:
.all() .all()
] ]
def get_files_by_user_id(self, user_id: str) -> list[FileModel]: def get_files_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[FileModel]:
with get_db_context(db) as db:
return [ return [
FileModel.model_validate(file) FileModel.model_validate(file)
for file in db.query(File).filter_by(user_id=user_id).all() for file in db.query(File).filter_by(user_id=user_id).all()
] ]
@staticmethod
def _glob_to_like_pattern(glob: str) -> str:
"""
Convert a glob/fnmatch pattern to a SQL LIKE pattern.
Escapes SQL special characters and converts glob wildcards:
- `*` becomes `%` (match any sequence of characters)
- `?` becomes `_` (match exactly one character)
Args:
glob: A glob pattern (e.g., "*.txt", "file?.doc")
Returns:
A SQL LIKE compatible pattern with proper escaping.
"""
# Escape SQL special characters first, then convert glob wildcards
pattern = glob.replace("\\", "\\\\")
pattern = pattern.replace("%", "\\%")
pattern = pattern.replace("_", "\\_")
pattern = pattern.replace("*", "%")
pattern = pattern.replace("?", "_")
return pattern
def search_files(
self,
user_id: Optional[str] = None,
filename: str = "*",
skip: int = 0,
limit: int = 100,
db: Optional[Session] = None,
) -> list[FileModel]:
"""
Search files with glob pattern matching, optional user filter, and pagination.
Args:
user_id: Filter by user ID. If None, returns files for all users.
filename: Glob pattern to match filenames (e.g., "*.txt"). Default "*" matches all.
skip: Number of results to skip for pagination.
limit: Maximum number of results to return.
db: Optional database session.
Returns:
List of matching FileModel objects, ordered by updated_at descending.
"""
with get_db_context(db) as db:
query = db.query(File)
if user_id:
query = query.filter_by(user_id=user_id)
pattern = self._glob_to_like_pattern(filename)
if pattern != "%":
query = query.filter(File.filename.ilike(pattern, escape="\\"))
return [
FileModel.model_validate(file)
for file in query.order_by(File.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
]
def update_file_by_id( def update_file_by_id(
self, id: str, form_data: FileUpdateForm self, id: str, form_data: FileUpdateForm, db: Optional[Session] = None
) -> Optional[FileModel]: ) -> Optional[FileModel]:
with get_db() as db: with get_db_context(db) as db:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
@@ -236,8 +318,10 @@ class FilesTable:
log.exception(f"Error updating file completely by id: {e}") log.exception(f"Error updating file completely by id: {e}")
return None return None
def update_file_hash_by_id(self, id: str, hash: str) -> Optional[FileModel]: def update_file_hash_by_id(
with get_db() as db: self, id: str, hash: Optional[str], db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.hash = hash file.hash = hash
@@ -248,8 +332,10 @@ class FilesTable:
except Exception: except Exception:
return None return None
def update_file_data_by_id(self, id: str, data: dict) -> Optional[FileModel]: def update_file_data_by_id(
with get_db() as db: self, id: str, data: dict, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data} file.data = {**(file.data if file.data else {}), **data}
@@ -260,8 +346,10 @@ class FilesTable:
return None return None
def update_file_metadata_by_id(self, id: str, meta: dict) -> Optional[FileModel]: def update_file_metadata_by_id(
with get_db() as db: self, id: str, meta: dict, db: Optional[Session] = None
) -> Optional[FileModel]:
with get_db_context(db) as db:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta} file.meta = {**(file.meta if file.meta else {}), **meta}
@@ -271,8 +359,10 @@ class FilesTable:
except Exception: except Exception:
return None return None
def delete_file_by_id(self, id: str) -> bool: return False
with get_db() as db:
def delete_file_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
try: try:
db.query(File).filter_by(id=id).delete() db.query(File).filter_by(id=id).delete()
db.commit() db.commit()
@@ -281,8 +371,8 @@ class FilesTable:
except Exception: except Exception:
return False return False
def delete_all_files(self) -> bool: def delete_all_files(self, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
try: try:
db.query(File).delete() db.query(File).delete()
db.commit() db.commit()
+45 -27
View File
@@ -7,8 +7,9 @@ import re
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func from sqlalchemy import BigInteger, Column, Text, JSON, Boolean, func
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, JSONField, get_db, get_db_context
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -83,9 +84,13 @@ class FolderUpdateForm(BaseModel):
class FolderTable: class FolderTable:
def insert_new_folder( def insert_new_folder(
self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None self,
user_id: str,
form_data: FolderForm,
parent_id: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
with get_db() as db: with get_db_context(db) as db:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
folder = FolderModel( folder = FolderModel(
**{ **{
@@ -111,10 +116,10 @@ class FolderTable:
return None return None
def get_folder_by_id_and_user_id( def get_folder_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder: if not folder:
@@ -125,15 +130,15 @@ class FolderTable:
return None return None
def get_children_folders_by_id_and_user_id( def get_children_folders_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[list[FolderModel]]: ) -> Optional[list[FolderModel]]:
try: try:
with get_db() as db: with get_db_context(db) as db:
folders = [] folders = []
def get_children(folder): def get_children(folder):
children = self.get_folders_by_parent_id_and_user_id( children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id folder.id, user_id, db=db
) )
for child in children: for child in children:
get_children(child) get_children(child)
@@ -148,18 +153,24 @@ class FolderTable:
except Exception: except Exception:
return None return None
def get_folders_by_user_id(self, user_id: str) -> list[FolderModel]: def get_folders_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[FolderModel]:
with get_db_context(db) as db:
return [ return [
FolderModel.model_validate(folder) FolderModel.model_validate(folder)
for folder in db.query(Folder).filter_by(user_id=user_id).all() for folder in db.query(Folder).filter_by(user_id=user_id).all()
] ]
def get_folder_by_parent_id_and_user_id_and_name( def get_folder_by_parent_id_and_user_id_and_name(
self, parent_id: Optional[str], user_id: str, name: str self,
parent_id: Optional[str],
user_id: str,
name: str,
db: Optional[Session] = None,
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
# Check if folder exists # Check if folder exists
folder = ( folder = (
db.query(Folder) db.query(Folder)
@@ -177,9 +188,9 @@ class FolderTable:
return None return None
def get_folders_by_parent_id_and_user_id( def get_folders_by_parent_id_and_user_id(
self, parent_id: Optional[str], user_id: str self, parent_id: Optional[str], user_id: str, db: Optional[Session] = None
) -> list[FolderModel]: ) -> list[FolderModel]:
with get_db() as db: with get_db_context(db) as db:
return [ return [
FolderModel.model_validate(folder) FolderModel.model_validate(folder)
for folder in db.query(Folder) for folder in db.query(Folder)
@@ -192,9 +203,10 @@ class FolderTable:
id: str, id: str,
user_id: str, user_id: str,
parent_id: str, parent_id: str,
db: Optional[Session] = None,
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder: if not folder:
@@ -211,10 +223,14 @@ class FolderTable:
return return
def update_folder_by_id_and_user_id( def update_folder_by_id_and_user_id(
self, id: str, user_id: str, form_data: FolderUpdateForm self,
id: str,
user_id: str,
form_data: FolderUpdateForm,
db: Optional[Session] = None,
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder: if not folder:
@@ -257,10 +273,10 @@ class FolderTable:
return return
def update_folder_is_expanded_by_id_and_user_id( def update_folder_is_expanded_by_id_and_user_id(
self, id: str, user_id: str, is_expanded: bool self, id: str, user_id: str, is_expanded: bool, db: Optional[Session] = None
) -> Optional[FolderModel]: ) -> Optional[FolderModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder: if not folder:
@@ -276,10 +292,12 @@ class FolderTable:
log.error(f"update_folder: {e}") log.error(f"update_folder: {e}")
return return
def delete_folder_by_id_and_user_id(self, id: str, user_id: str) -> list[str]: def delete_folder_by_id_and_user_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> list[str]:
try: try:
folder_ids = [] folder_ids = []
with get_db() as db: with get_db_context(db) as db:
folder = db.query(Folder).filter_by(id=id, user_id=user_id).first() folder = db.query(Folder).filter_by(id=id, user_id=user_id).first()
if not folder: if not folder:
return folder_ids return folder_ids
@@ -289,7 +307,7 @@ class FolderTable:
# Delete all children folders # Delete all children folders
def delete_children(folder): def delete_children(folder):
folder_children = self.get_folders_by_parent_id_and_user_id( folder_children = self.get_folders_by_parent_id_and_user_id(
folder.id, user_id folder.id, user_id, db=db
) )
for folder_child in folder_children: for folder_child in folder_children:
@@ -314,7 +332,7 @@ class FolderTable:
return name.strip().lower() return name.strip().lower()
def search_folders_by_names( def search_folders_by_names(
self, user_id: str, queries: list[str] self, user_id: str, queries: list[str], db: Optional[Session] = None
) -> list[FolderModel]: ) -> list[FolderModel]:
""" """
Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive. Search for folders for a user where the name matches any of the queries, treating _ and space as equivalent, case-insensitive.
@@ -324,7 +342,7 @@ class FolderTable:
return [] return []
results = {} results = {}
with get_db() as db: with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all() folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders: for folder in folders:
if self.normalize_folder_name(folder.name) in normalized_queries: if self.normalize_folder_name(folder.name) in normalized_queries:
@@ -332,7 +350,7 @@ class FolderTable:
# get children folders # get children folders
children = self.get_children_folders_by_id_and_user_id( children = self.get_children_folders_by_id_and_user_id(
folder.id, user_id folder.id, user_id, db=db
) )
for child in children: for child in children:
results[child.id] = child results[child.id] = child
@@ -345,14 +363,14 @@ class FolderTable:
return results return results
def search_folders_by_name_contains( def search_folders_by_name_contains(
self, user_id: str, query: str self, user_id: str, query: str, db: Optional[Session] = None
) -> list[FolderModel]: ) -> list[FolderModel]:
""" """
Partial match: normalized name contains (as substring) the normalized query. Partial match: normalized name contains (as substring) the normalized query.
""" """
normalized_query = self.normalize_folder_name(query) normalized_query = self.normalize_folder_name(query)
results = [] results = []
with get_db() as db: with get_db_context(db) as db:
folders = db.query(Folder).filter_by(user_id=user_id).all() folders = db.query(Folder).filter_by(user_id=user_id).all()
for folder in folders: for folder in folders:
norm_name = self.normalize_folder_name(folder.name) norm_name = self.normalize_folder_name(folder.name)
+58 -38
View File
@@ -2,7 +2,8 @@ import logging
import time import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserModel from open_webui.models.users import Users, UserModel
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@@ -103,7 +104,11 @@ class FunctionValves(BaseModel):
class FunctionsTable: class FunctionsTable:
def insert_new_function( def insert_new_function(
self, user_id: str, type: str, form_data: FunctionForm self,
user_id: str,
type: str,
form_data: FunctionForm,
db: Optional[Session] = None,
) -> Optional[FunctionModel]: ) -> Optional[FunctionModel]:
function = FunctionModel( function = FunctionModel(
**{ **{
@@ -116,7 +121,7 @@ class FunctionsTable:
) )
try: try:
with get_db() as db: with get_db_context(db) as db:
result = Function(**function.model_dump()) result = Function(**function.model_dump())
db.add(result) db.add(result)
db.commit() db.commit()
@@ -130,11 +135,14 @@ class FunctionsTable:
return None return None
def sync_functions( def sync_functions(
self, user_id: str, functions: list[FunctionWithValvesModel] self,
user_id: str,
functions: list[FunctionWithValvesModel],
db: Optional[Session] = None,
) -> list[FunctionWithValvesModel]: ) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present. # Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try: try:
with get_db() as db: with get_db_context(db) as db:
# Get existing functions # Get existing functions
existing_functions = db.query(Function).all() existing_functions = db.query(Function).all()
existing_ids = {func.id for func in existing_functions} existing_ids = {func.id for func in existing_functions}
@@ -177,18 +185,20 @@ class FunctionsTable:
log.exception(f"Error syncing functions for user {user_id}: {e}") log.exception(f"Error syncing functions for user {user_id}: {e}")
return [] return []
def get_function_by_id(self, id: str) -> Optional[FunctionModel]: def get_function_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[FunctionModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
function = db.get(Function, id) function = db.get(Function, id)
return FunctionModel.model_validate(function) return FunctionModel.model_validate(function)
except Exception: except Exception:
return None return None
def get_functions( def get_functions(
self, active_only=False, include_valves=False self, active_only=False, include_valves=False, db: Optional[Session] = None
) -> list[FunctionModel | FunctionWithValvesModel]: ) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db() as db: with get_db_context(db) as db:
if active_only: if active_only:
functions = db.query(Function).filter_by(is_active=True).all() functions = db.query(Function).filter_by(is_active=True).all()
@@ -205,12 +215,14 @@ class FunctionsTable:
FunctionModel.model_validate(function) for function in functions FunctionModel.model_validate(function) for function in functions
] ]
def get_function_list(self) -> list[FunctionUserResponse]: def get_function_list(
with get_db() as db: 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() functions = db.query(Function).order_by(Function.updated_at.desc()).all()
user_ids = list(set(func.user_id for func in functions)) user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users} users_dict = {user.id: user for user in users}
return [ return [
@@ -228,9 +240,9 @@ class FunctionsTable:
] ]
def get_functions_by_type( def get_functions_by_type(
self, type: str, active_only=False self, type: str, active_only=False, db: Optional[Session] = None
) -> list[FunctionModel]: ) -> list[FunctionModel]:
with get_db() as db: with get_db_context(db) as db:
if active_only: if active_only:
return [ return [
FunctionModel.model_validate(function) FunctionModel.model_validate(function)
@@ -244,8 +256,10 @@ class FunctionsTable:
for function in db.query(Function).filter_by(type=type).all() for function in db.query(Function).filter_by(type=type).all()
] ]
def get_global_filter_functions(self) -> list[FunctionModel]: def get_global_filter_functions(
with get_db() as db: self, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
return [ return [
FunctionModel.model_validate(function) FunctionModel.model_validate(function)
for function in db.query(Function) for function in db.query(Function)
@@ -253,8 +267,10 @@ class FunctionsTable:
.all() .all()
] ]
def get_global_action_functions(self) -> list[FunctionModel]: def get_global_action_functions(
with get_db() as db: self, db: Optional[Session] = None
) -> list[FunctionModel]:
with get_db_context(db) as db:
return [ return [
FunctionModel.model_validate(function) FunctionModel.model_validate(function)
for function in db.query(Function) for function in db.query(Function)
@@ -262,8 +278,10 @@ class FunctionsTable:
.all() .all()
] ]
def get_function_valves_by_id(self, id: str) -> Optional[dict]: def get_function_valves_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
with get_db_context(db) as db:
try: try:
function = db.get(Function, id) function = db.get(Function, id)
return function.valves if function.valves else {} return function.valves if function.valves else {}
@@ -272,23 +290,23 @@ class FunctionsTable:
return None return None
def update_function_valves_by_id( def update_function_valves_by_id(
self, id: str, valves: dict self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[FunctionValves]: ) -> Optional[FunctionValves]:
with get_db() as db: with get_db_context(db) as db:
try: try:
function = db.get(Function, id) function = db.get(Function, id)
function.valves = valves function.valves = valves
function.updated_at = int(time.time()) function.updated_at = int(time.time())
db.commit() db.commit()
db.refresh(function) db.refresh(function)
return self.get_function_by_id(id) return self.get_function_by_id(id, db=db)
except Exception: except Exception:
return None return None
def update_function_metadata_by_id( def update_function_metadata_by_id(
self, id: str, metadata: dict self, id: str, metadata: dict, db: Optional[Session] = None
) -> Optional[FunctionModel]: ) -> Optional[FunctionModel]:
with get_db() as db: with get_db_context(db) as db:
try: try:
function = db.get(Function, id) function = db.get(Function, id)
@@ -301,7 +319,7 @@ class FunctionsTable:
function.updated_at = int(time.time()) function.updated_at = int(time.time())
db.commit() db.commit()
db.refresh(function) db.refresh(function)
return self.get_function_by_id(id) return self.get_function_by_id(id, db=db)
else: else:
return None return None
except Exception as e: except Exception as e:
@@ -309,10 +327,10 @@ class FunctionsTable:
return None return None
def get_user_valves_by_id_and_user_id( def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[dict]: ) -> Optional[dict]:
try: try:
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {} user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings # Check if user has "functions" and "valves" settings
@@ -327,10 +345,10 @@ class FunctionsTable:
return None return None
def update_user_valves_by_id_and_user_id( def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
) -> Optional[dict]: ) -> Optional[dict]:
try: try:
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {} user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "functions" and "valves" settings # Check if user has "functions" and "valves" settings
@@ -342,7 +360,7 @@ class FunctionsTable:
user_settings["functions"]["valves"][id] = valves user_settings["functions"]["valves"][id] = valves
# Update the user settings in the database # Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings}) Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
return user_settings["functions"]["valves"][id] return user_settings["functions"]["valves"][id]
except Exception as e: except Exception as e:
@@ -351,8 +369,10 @@ class FunctionsTable:
) )
return None return None
def update_function_by_id(self, id: str, updated: dict) -> Optional[FunctionModel]: def update_function_by_id(
with get_db() as db: self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[FunctionModel]:
with get_db_context(db) as db:
try: try:
db.query(Function).filter_by(id=id).update( db.query(Function).filter_by(id=id).update(
{ {
@@ -361,12 +381,12 @@ class FunctionsTable:
} }
) )
db.commit() db.commit()
return self.get_function_by_id(id) return self.get_function_by_id(id, db=db)
except Exception: except Exception:
return None return None
def deactivate_all_functions(self) -> Optional[bool]: def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[bool]:
with get_db() as db: with get_db_context(db) as db:
try: try:
db.query(Function).update( db.query(Function).update(
{ {
@@ -379,8 +399,8 @@ class FunctionsTable:
except Exception: except Exception:
return None return None
def delete_function_by_id(self, id: str) -> bool: def delete_function_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
try: try:
db.query(Function).filter_by(id=id).delete() db.query(Function).filter_by(id=id).delete()
db.commit() db.commit()
+131 -53
View File
@@ -4,7 +4,8 @@ import time
from typing import Optional from typing import Optional
import uuid import uuid
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.files import FileMetadataResponse from open_webui.models.files import FileMetadataResponse
@@ -120,9 +121,9 @@ class GroupListResponse(BaseModel):
class GroupTable: class GroupTable:
def insert_new_group( def insert_new_group(
self, user_id: str, form_data: GroupForm self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
) -> Optional[GroupModel]: ) -> Optional[GroupModel]:
with get_db() as db: with get_db_context(db) as db:
group = GroupModel( group = GroupModel(
**{ **{
**form_data.model_dump(exclude_none=True), **form_data.model_dump(exclude_none=True),
@@ -146,54 +147,84 @@ class GroupTable:
except Exception: except Exception:
return None return None
def get_all_groups(self) -> list[GroupModel]: def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]:
with get_db() as db: with get_db_context(db) as db:
groups = db.query(Group).order_by(Group.updated_at.desc()).all() groups = db.query(Group).order_by(Group.updated_at.desc()).all()
return [GroupModel.model_validate(group) for group in groups] return [GroupModel.model_validate(group) for group in groups]
def get_groups(self, filter) -> list[GroupResponse]: def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Group) query = db.query(Group)
if filter: if filter:
if "query" in filter: if "query" in filter:
query = query.filter(Group.name.ilike(f"%{filter['query']}%")) query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
# When share filter is present, member check is handled in the share logic
if "share" in filter: if "share" in filter:
share_value = filter["share"] share_value = filter["share"]
json_share = Group.data["config"]["share"].as_boolean() member_id = filter.get("member_id")
json_share = Group.data["config"]["share"]
json_share_bool = json_share.as_boolean()
json_share_str = json_share.as_string()
if share_value: if share_value:
query = query.filter( # Groups open to anyone: data is null, share is null, or share is true
or_( anyone_can_share = or_(
Group.data.is_(None), Group.data.is_(None),
json_share.is_(None), json_share_bool.is_(None),
json_share == True, json_share_bool == True,
)
) )
if member_id:
# Also include member-only groups where user is a member
member_groups_subq = (
db.query(GroupMember.group_id)
.filter(GroupMember.user_id == member_id)
.subquery()
)
members_only_and_is_member = and_(
json_share_str == "members",
Group.id.in_(member_groups_subq),
)
query = query.filter(
or_(anyone_can_share, members_only_and_is_member)
)
else:
query = query.filter(anyone_can_share)
else: else:
query = query.filter( query = query.filter(
and_(Group.data.isnot(None), json_share == False) and_(Group.data.isnot(None), json_share_bool == False)
) )
else:
# Only apply member_id filter when share filter is NOT present
if "member_id" in filter:
query = query.join(
GroupMember, GroupMember.group_id == Group.id
).filter(GroupMember.user_id == filter["member_id"])
groups = query.order_by(Group.updated_at.desc()).all() groups = query.order_by(Group.updated_at.desc()).all()
return [ return [
GroupResponse.model_validate( GroupResponse.model_validate(
{ {
**GroupModel.model_validate(group).model_dump(), **GroupModel.model_validate(group).model_dump(),
"member_count": self.get_group_member_count_by_id(group.id), "member_count": self.get_group_member_count_by_id(
group.id, db=db
),
} }
) )
for group in groups for group in groups
] ]
def search_groups( def search_groups(
self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30 self,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> GroupListResponse: ) -> GroupListResponse:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Group) query = db.query(Group)
if filter: if filter:
@@ -220,15 +251,17 @@ class GroupTable:
"items": [ "items": [
GroupResponse.model_validate( GroupResponse.model_validate(
**GroupModel.model_validate(group).model_dump(), **GroupModel.model_validate(group).model_dump(),
member_count=self.get_group_member_count_by_id(group.id), member_count=self.get_group_member_count_by_id(group.id, db=db),
) )
for group in groups for group in groups
], ],
"total": total, "total": total,
} }
def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]: def get_groups_by_member_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[GroupModel]:
with get_db_context(db) as db:
return [ return [
GroupModel.model_validate(group) GroupModel.model_validate(group)
for group in db.query(Group) for group in db.query(Group)
@@ -238,16 +271,41 @@ class GroupTable:
.all() .all()
] ]
def get_group_by_id(self, id: str) -> Optional[GroupModel]: def get_groups_by_member_ids(
self, user_ids: list[str], db: Optional[Session] = None
) -> dict[str, list[GroupModel]]:
"""Fetch groups for multiple users in a single query to avoid N+1."""
with get_db_context(db) as db:
# Query GroupMember joined with Group, filtering by user_ids
results = (
db.query(GroupMember.user_id, Group)
.join(Group, Group.id == GroupMember.group_id)
.filter(GroupMember.user_id.in_(user_ids))
.order_by(Group.updated_at.desc())
.all()
)
# Group groups by user_id
user_groups: dict[str, list[GroupModel]] = {uid: [] for uid in user_ids}
for user_id, group in results:
user_groups[user_id].append(GroupModel.model_validate(group))
return user_groups
def get_group_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[GroupModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first() group = db.query(Group).filter_by(id=id).first()
return GroupModel.model_validate(group) if group else None return GroupModel.model_validate(group) if group else None
except Exception: except Exception:
return None return None
def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]: def get_group_user_ids_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> Optional[list[str]]:
with get_db_context(db) as db:
members = ( members = (
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all() db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
) )
@@ -257,8 +315,10 @@ class GroupTable:
return [m[0] for m in members] return [m[0] for m in members]
def get_group_user_ids_by_ids(self, group_ids: list[str]) -> dict[str, list[str]]: def get_group_user_ids_by_ids(
with get_db() as db: self, group_ids: list[str], db: Optional[Session] = None
) -> dict[str, list[str]]:
with get_db_context(db) as db:
members = ( members = (
db.query(GroupMember.group_id, GroupMember.user_id) db.query(GroupMember.group_id, GroupMember.user_id)
.filter(GroupMember.group_id.in_(group_ids)) .filter(GroupMember.group_id.in_(group_ids))
@@ -274,8 +334,10 @@ class GroupTable:
return group_user_ids return group_user_ids
def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None: def set_group_user_ids_by_id(
with get_db() as db: self, group_id: str, user_ids: list[str], db: Optional[Session] = None
) -> None:
with get_db_context(db) as db:
# Delete existing members # Delete existing members
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete() db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
@@ -295,8 +357,10 @@ class GroupTable:
db.add_all(new_members) db.add_all(new_members)
db.commit() db.commit()
def get_group_member_count_by_id(self, id: str) -> int: def get_group_member_count_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> int:
with get_db_context(db) as db:
count = ( count = (
db.query(func.count(GroupMember.user_id)) db.query(func.count(GroupMember.user_id))
.filter(GroupMember.group_id == id) .filter(GroupMember.group_id == id)
@@ -305,10 +369,14 @@ class GroupTable:
return count if count else 0 return count if count else 0
def update_group_by_id( def update_group_by_id(
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False self,
id: str,
form_data: GroupUpdateForm,
overwrite: bool = False,
db: Optional[Session] = None,
) -> Optional[GroupModel]: ) -> Optional[GroupModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Group).filter_by(id=id).update( db.query(Group).filter_by(id=id).update(
{ {
**form_data.model_dump(exclude_none=True), **form_data.model_dump(exclude_none=True),
@@ -316,22 +384,22 @@ class GroupTable:
} }
) )
db.commit() db.commit()
return self.get_group_by_id(id=id) return self.get_group_by_id(id=id, db=db)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return None return None
def delete_group_by_id(self, id: str) -> bool: def delete_group_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Group).filter_by(id=id).delete() db.query(Group).filter_by(id=id).delete()
db.commit() db.commit()
return True return True
except Exception: except Exception:
return False return False
def delete_all_groups(self) -> bool: def delete_all_groups(self, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
try: try:
db.query(Group).delete() db.query(Group).delete()
db.commit() db.commit()
@@ -340,8 +408,10 @@ class GroupTable:
except Exception: except Exception:
return False return False
def remove_user_from_all_groups(self, user_id: str) -> bool: def remove_user_from_all_groups(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try: try:
# Find all groups the user belongs to # Find all groups the user belongs to
groups = ( groups = (
@@ -369,16 +439,16 @@ class GroupTable:
return False return False
def create_groups_by_group_names( def create_groups_by_group_names(
self, user_id: str, group_names: list[str] self, user_id: str, group_names: list[str], db: Optional[Session] = None
) -> list[GroupModel]: ) -> list[GroupModel]:
# check for existing groups # check for existing groups
existing_groups = self.get_all_groups() existing_groups = self.get_all_groups(db=db)
existing_group_names = {group.name for group in existing_groups} existing_group_names = {group.name for group in existing_groups}
new_groups = [] new_groups = []
with get_db() as db: with get_db_context(db) as db:
for group_name in group_names: for group_name in group_names:
if group_name not in existing_group_names: if group_name not in existing_group_names:
new_group = GroupModel( new_group = GroupModel(
@@ -400,8 +470,10 @@ class GroupTable:
continue continue
return new_groups return new_groups
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool: def sync_groups_by_group_names(
with get_db() as db: self, user_id: str, group_names: list[str], db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try: try:
now = int(time.time()) now = int(time.time())
@@ -461,10 +533,13 @@ class GroupTable:
return False return False
def add_users_to_group( def add_users_to_group(
self, id: str, user_ids: Optional[list[str]] = None self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[GroupModel]: ) -> Optional[GroupModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first() group = db.query(Group).filter_by(id=id).first()
if not group: if not group:
return None return None
@@ -499,10 +574,13 @@ class GroupTable:
return None return None
def remove_users_from_group( def remove_users_from_group(
self, id: str, user_ids: Optional[list[str]] = None self,
id: str,
user_ids: Optional[list[str]] = None,
db: Optional[Session] = None,
) -> Optional[GroupModel]: ) -> Optional[GroupModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
group = db.query(Group).filter_by(id=id).first() group = db.query(Group).filter_by(id=id).first()
if not group: if not group:
return None return None
+88 -50
View File
@@ -4,7 +4,8 @@ import time
from typing import Optional from typing import Optional
import uuid import uuid
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.files import ( from open_webui.models.files import (
File, File,
@@ -157,9 +158,9 @@ class KnowledgeFileListResponse(BaseModel):
class KnowledgeTable: class KnowledgeTable:
def insert_new_knowledge( def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm self, user_id: str, form_data: KnowledgeForm, db: Optional[Session] = None
) -> Optional[KnowledgeModel]: ) -> Optional[KnowledgeModel]:
with get_db() as db: with get_db_context(db) as db:
knowledge = KnowledgeModel( knowledge = KnowledgeModel(
**{ **{
**form_data.model_dump(), **form_data.model_dump(),
@@ -183,15 +184,15 @@ class KnowledgeTable:
return None return None
def get_knowledge_bases( def get_knowledge_bases(
self, skip: int = 0, limit: int = 30 self, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> list[KnowledgeUserModel]: ) -> list[KnowledgeUserModel]:
with get_db() as db: with get_db_context(db) as db:
all_knowledge = ( all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
) )
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users} users_dict = {user.id: user for user in users}
knowledge_bases = [] knowledge_bases = []
@@ -208,10 +209,15 @@ class KnowledgeTable:
return knowledge_bases return knowledge_bases
def search_knowledge_bases( def search_knowledge_bases(
self, user_id: str, filter: dict, skip: int = 0, limit: int = 30 self,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeListResponse: ) -> KnowledgeListResponse:
try: try:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Knowledge, User).outerjoin( query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id User, User.id == Knowledge.user_id
) )
@@ -267,17 +273,17 @@ class KnowledgeTable:
return KnowledgeListResponse(items=[], total=0) return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files( def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30 self, filter: dict, skip: int = 0, limit: int = 30, db: Optional[Session] = None
) -> KnowledgeFileListResponse: ) -> KnowledgeFileListResponse:
""" """
Scalable version: search files across all knowledge bases the user has Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists. READ access to, without loading all KBs or using large IN() lists.
""" """
try: try:
with get_db() as db: with get_db_context(db) as db:
# Base query: join Knowledge → KnowledgeFile → File # Base query: join Knowledge → KnowledgeFile → File
query = ( query = (
db.query(File, User) db.query(File, User, Knowledge)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id) .join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id) .join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id) .outerjoin(User, User.id == KnowledgeFile.user_id)
@@ -307,7 +313,7 @@ class KnowledgeTable:
rows = query.all() rows = query.all()
items = [] items = []
for file, user in rows: for file, user, knowledge in rows:
items.append( items.append(
FileUserResponse( FileUserResponse(
**FileModel.model_validate(file).model_dump(), **FileModel.model_validate(file).model_dump(),
@@ -318,6 +324,9 @@ class KnowledgeTable:
if user if user
else None else None
), ),
collection=KnowledgeModel.model_validate(
knowledge
).model_dump(),
) )
) )
@@ -327,20 +336,26 @@ class KnowledgeTable:
print("search_knowledge_files error:", e) print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0) return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(self, id, user_id, permission="write") -> bool: def check_access_by_user_id(
knowledge = self.get_knowledge_by_id(id) self, id, user_id, permission="write", db: Optional[Session] = None
) -> bool:
knowledge = self.get_knowledge_by_id(id, db=db)
if not knowledge: if not knowledge:
return False return False
if knowledge.user_id == user_id: if knowledge.user_id == user_id:
return True return True
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return has_access(user_id, permission, knowledge.access_control, user_group_ids) return has_access(user_id, permission, knowledge.access_control, user_group_ids)
def get_knowledge_bases_by_user_id( def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[KnowledgeUserModel]: ) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases() knowledge_bases = self.get_knowledge_bases(db=db)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [ return [
knowledge_base knowledge_base
for knowledge_base in knowledge_bases for knowledge_base in knowledge_bases
@@ -350,32 +365,38 @@ class KnowledgeTable:
) )
] ]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: def get_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
knowledge = db.query(Knowledge).filter_by(id=id).first() knowledge = db.query(Knowledge).filter_by(id=id).first()
return KnowledgeModel.model_validate(knowledge) if knowledge else None return KnowledgeModel.model_validate(knowledge) if knowledge else None
except Exception: except Exception:
return None return None
def get_knowledge_by_id_and_user_id( def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]: ) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id) knowledge = self.get_knowledge_by_id(id, db=db)
if not knowledge: if not knowledge:
return None return None
if knowledge.user_id == user_id: if knowledge.user_id == user_id:
return knowledge return knowledge
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
if has_access(user_id, "write", knowledge.access_control, user_group_ids): if has_access(user_id, "write", knowledge.access_control, user_group_ids):
return knowledge return knowledge
return None return None
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: def get_knowledges_by_file_id(
self, file_id: str, db: Optional[Session] = None
) -> list[KnowledgeModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
knowledges = ( knowledges = (
db.query(Knowledge) db.query(Knowledge)
.join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id) .join(KnowledgeFile, Knowledge.id == KnowledgeFile.knowledge_id)
@@ -395,9 +416,10 @@ class KnowledgeTable:
filter: dict, filter: dict,
skip: int = 0, skip: int = 0,
limit: int = 30, limit: int = 30,
db: Optional[Session] = None,
) -> KnowledgeFileListResponse: ) -> KnowledgeFileListResponse:
try: try:
with get_db() as db: with get_db_context(db) as db:
query = ( query = (
db.query(File, User) db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id) .join(KnowledgeFile, File.id == KnowledgeFile.file_id)
@@ -470,9 +492,11 @@ class KnowledgeTable:
print(e) print(e)
return KnowledgeFileListResponse(items=[], total=0) return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: def get_files_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
files = ( files = (
db.query(File) db.query(File)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id) .join(KnowledgeFile, File.id == KnowledgeFile.file_id)
@@ -483,18 +507,24 @@ class KnowledgeTable:
except Exception: except Exception:
return [] return []
def get_file_metadatas_by_id(self, knowledge_id: str) -> list[FileMetadataResponse]: def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[Session] = None
) -> list[FileMetadataResponse]:
try: try:
with get_db() as db: with get_db_context(db) as db:
files = self.get_files_by_id(knowledge_id) files = self.get_files_by_id(knowledge_id, db=db)
return [FileMetadataResponse(**file.model_dump()) for file in files] return [FileMetadataResponse(**file.model_dump()) for file in files]
except Exception: except Exception:
return [] return []
def add_file_to_knowledge_by_id( def add_file_to_knowledge_by_id(
self, knowledge_id: str, file_id: str, user_id: str self,
knowledge_id: str,
file_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[KnowledgeFileModel]: ) -> Optional[KnowledgeFileModel]:
with get_db() as db: with get_db_context(db) as db:
knowledge_file = KnowledgeFileModel( knowledge_file = KnowledgeFileModel(
**{ **{
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
@@ -518,9 +548,11 @@ class KnowledgeTable:
except Exception: except Exception:
return None return None
def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str) -> bool: def remove_file_from_knowledge_by_id(
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(KnowledgeFile).filter_by( db.query(KnowledgeFile).filter_by(
knowledge_id=knowledge_id, file_id=file_id knowledge_id=knowledge_id, file_id=file_id
).delete() ).delete()
@@ -529,9 +561,11 @@ class KnowledgeTable:
except Exception: except Exception:
return False return False
def reset_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]: def reset_knowledge_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[KnowledgeModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
# Delete all knowledge_file entries for this knowledge_id # Delete all knowledge_file entries for this knowledge_id
db.query(KnowledgeFile).filter_by(knowledge_id=id).delete() db.query(KnowledgeFile).filter_by(knowledge_id=id).delete()
db.commit() db.commit()
@@ -544,17 +578,21 @@ class KnowledgeTable:
) )
db.commit() db.commit()
return self.get_knowledge_by_id(id=id) return self.get_knowledge_by_id(id=id, db=db)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return None return None
def update_knowledge_by_id( def update_knowledge_by_id(
self, id: str, form_data: KnowledgeForm, overwrite: bool = False self,
id: str,
form_data: KnowledgeForm,
overwrite: bool = False,
db: Optional[Session] = None,
) -> Optional[KnowledgeModel]: ) -> Optional[KnowledgeModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id) knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update( db.query(Knowledge).filter_by(id=id).update(
{ {
**form_data.model_dump(), **form_data.model_dump(),
@@ -562,17 +600,17 @@ class KnowledgeTable:
} }
) )
db.commit() db.commit()
return self.get_knowledge_by_id(id=id) return self.get_knowledge_by_id(id=id, db=db)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return None return None
def update_knowledge_data_by_id( def update_knowledge_data_by_id(
self, id: str, data: dict self, id: str, data: dict, db: Optional[Session] = None
) -> Optional[KnowledgeModel]: ) -> Optional[KnowledgeModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
knowledge = self.get_knowledge_by_id(id=id) knowledge = self.get_knowledge_by_id(id=id, db=db)
db.query(Knowledge).filter_by(id=id).update( db.query(Knowledge).filter_by(id=id).update(
{ {
"data": data, "data": data,
@@ -580,22 +618,22 @@ class KnowledgeTable:
} }
) )
db.commit() db.commit()
return self.get_knowledge_by_id(id=id) return self.get_knowledge_by_id(id=id, db=db)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return None return None
def delete_knowledge_by_id(self, id: str) -> bool: def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Knowledge).filter_by(id=id).delete() db.query(Knowledge).filter_by(id=id).delete()
db.commit() db.commit()
return True return True
except Exception: except Exception:
return False return False
def delete_all_knowledge(self) -> bool: def delete_all_knowledge(self, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
try: try:
db.query(Knowledge).delete() db.query(Knowledge).delete()
db.commit() db.commit()
+26 -15
View File
@@ -2,7 +2,8 @@ import time
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text from sqlalchemy import BigInteger, Column, String, Text
@@ -41,8 +42,9 @@ class MemoriesTable:
self, self,
user_id: str, user_id: str,
content: str, content: str,
db: Optional[Session] = None,
) -> Optional[MemoryModel]: ) -> Optional[MemoryModel]:
with get_db() as db: with get_db_context(db) as db:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
memory = MemoryModel( memory = MemoryModel(
@@ -68,8 +70,9 @@ class MemoriesTable:
id: str, id: str,
user_id: str, user_id: str,
content: str, content: str,
db: Optional[Session] = None,
) -> Optional[MemoryModel]: ) -> Optional[MemoryModel]:
with get_db() as db: with get_db_context(db) as db:
try: try:
memory = db.get(Memory, id) memory = db.get(Memory, id)
if not memory or memory.user_id != user_id: if not memory or memory.user_id != user_id:
@@ -83,32 +86,36 @@ class MemoriesTable:
except Exception: except Exception:
return None return None
def get_memories(self) -> list[MemoryModel]: def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]:
with get_db() as db: with get_db_context(db) as db:
try: try:
memories = db.query(Memory).all() memories = db.query(Memory).all()
return [MemoryModel.model_validate(memory) for memory in memories] return [MemoryModel.model_validate(memory) for memory in memories]
except Exception: except Exception:
return None return None
def get_memories_by_user_id(self, user_id: str) -> list[MemoryModel]: def get_memories_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[MemoryModel]:
with get_db_context(db) as db:
try: try:
memories = db.query(Memory).filter_by(user_id=user_id).all() memories = db.query(Memory).filter_by(user_id=user_id).all()
return [MemoryModel.model_validate(memory) for memory in memories] return [MemoryModel.model_validate(memory) for memory in memories]
except Exception: except Exception:
return None return None
def get_memory_by_id(self, id: str) -> Optional[MemoryModel]: def get_memory_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> Optional[MemoryModel]:
with get_db_context(db) as db:
try: try:
memory = db.get(Memory, id) memory = db.get(Memory, id)
return MemoryModel.model_validate(memory) return MemoryModel.model_validate(memory)
except Exception: except Exception:
return None return None
def delete_memory_by_id(self, id: str) -> bool: def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
try: try:
db.query(Memory).filter_by(id=id).delete() db.query(Memory).filter_by(id=id).delete()
db.commit() db.commit()
@@ -118,8 +125,10 @@ class MemoriesTable:
except Exception: except Exception:
return False return False
def delete_memories_by_user_id(self, user_id: str) -> bool: def delete_memories_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try: try:
db.query(Memory).filter_by(user_id=user_id).delete() db.query(Memory).filter_by(user_id=user_id).delete()
db.commit() db.commit()
@@ -128,8 +137,10 @@ class MemoriesTable:
except Exception: except Exception:
return False return False
def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool: def delete_memory_by_id_and_user_id(
with get_db() as db: self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
with get_db_context(db) as db:
try: try:
memory = db.get(Memory, id) memory = db.get(Memory, id)
if not memory or memory.user_id != user_id: if not memory or memory.user_id != user_id:
+201 -43
View File
@@ -3,7 +3,8 @@ import time
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, User, UserNameResponse from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember from open_webui.models.channels import Channels, ChannelMember
@@ -137,9 +138,13 @@ class MessageResponse(MessageReplyToResponse):
class MessageTable: class MessageTable:
def insert_new_message( def insert_new_message(
self, form_data: MessageForm, channel_id: str, user_id: str self,
form_data: MessageForm,
channel_id: str,
user_id: str,
db: Optional[Session] = None,
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
with get_db() as db: with get_db_context(db) as db:
channel_member = Channels.join_channel(channel_id, user_id) channel_member = Channels.join_channel(channel_id, user_id)
id = str(uuid.uuid4()) id = str(uuid.uuid4())
@@ -169,26 +174,57 @@ class MessageTable:
db.refresh(result) db.refresh(result)
return MessageModel.model_validate(result) if result else None return MessageModel.model_validate(result) if result else None
def get_message_by_id(self, id: str) -> Optional[MessageResponse]: def get_message_by_id(
with get_db() as db: self,
id: str,
include_thread_replies: Optional[bool] = True,
db: Optional[Session] = None,
) -> Optional[MessageResponse]:
with get_db_context(db) as db:
message = db.get(Message, id) message = db.get(Message, id)
if not message: if not message:
return None return None
reply_to_message = ( reply_to_message = (
self.get_message_by_id(message.reply_to_id) self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id if message.reply_to_id
else None else None
) )
reactions = self.get_reactions_by_message_id(id) reactions = self.get_reactions_by_message_id(id, db=db)
thread_replies = self.get_thread_replies_by_message_id(id)
thread_replies = []
if include_thread_replies:
thread_replies = self.get_thread_replies_by_message_id(id, db=db)
# Check if message was sent by webhook (webhook info in meta takes precedence)
webhook_info = message.meta.get("webhook") if message.meta else None
if webhook_info and webhook_info.get("id"):
# Look up webhook by ID to get current name
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
# Webhook was deleted, use placeholder
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
else:
user = Users.get_user_by_id(message.user_id, db=db)
user_info = user.model_dump() if user else None
user = Users.get_user_by_id(message.user_id)
return MessageResponse.model_validate( return MessageResponse.model_validate(
{ {
**MessageModel.model_validate(message).model_dump(), **MessageModel.model_validate(message).model_dump(),
"user": user.model_dump() if user else None, "user": user_info,
"reply_to_message": ( "reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None reply_to_message.model_dump() if reply_to_message else None
), ),
@@ -200,8 +236,10 @@ class MessageTable:
} }
) )
def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]: def get_thread_replies_by_message_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> list[MessageReplyToResponse]:
with get_db_context(db) as db:
all_messages = ( all_messages = (
db.query(Message) db.query(Message)
.filter_by(parent_id=id) .filter_by(parent_id=id)
@@ -212,14 +250,35 @@ class MessageTable:
messages = [] messages = []
for message in all_messages: for message in all_messages:
reply_to_message = ( reply_to_message = (
self.get_message_by_id(message.reply_to_id) self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id if message.reply_to_id
else None else None
) )
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append( messages.append(
MessageReplyToResponse.model_validate( MessageReplyToResponse.model_validate(
{ {
**MessageModel.model_validate(message).model_dump(), **MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": ( "reply_to_message": (
reply_to_message.model_dump() reply_to_message.model_dump()
if reply_to_message if reply_to_message
@@ -230,17 +289,23 @@ class MessageTable:
) )
return messages return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]: def get_reply_user_ids_by_message_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
return [ return [
message.user_id message.user_id
for message in db.query(Message).filter_by(parent_id=id).all() for message in db.query(Message).filter_by(parent_id=id).all()
] ]
def get_messages_by_channel_id( def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50 self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageReplyToResponse]: ) -> list[MessageReplyToResponse]:
with get_db() as db: with get_db_context(db) as db:
all_messages = ( all_messages = (
db.query(Message) db.query(Message)
.filter_by(channel_id=channel_id, parent_id=None) .filter_by(channel_id=channel_id, parent_id=None)
@@ -253,14 +318,35 @@ class MessageTable:
messages = [] messages = []
for message in all_messages: for message in all_messages:
reply_to_message = ( reply_to_message = (
self.get_message_by_id(message.reply_to_id) self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id if message.reply_to_id
else None else None
) )
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append( messages.append(
MessageReplyToResponse.model_validate( MessageReplyToResponse.model_validate(
{ {
**MessageModel.model_validate(message).model_dump(), **MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": ( "reply_to_message": (
reply_to_message.model_dump() reply_to_message.model_dump()
if reply_to_message if reply_to_message
@@ -272,9 +358,14 @@ class MessageTable:
return messages return messages
def get_messages_by_parent_id( def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50 self,
channel_id: str,
parent_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageReplyToResponse]: ) -> list[MessageReplyToResponse]:
with get_db() as db: with get_db_context(db) as db:
message = db.get(Message, parent_id) message = db.get(Message, parent_id)
if not message: if not message:
@@ -296,14 +387,35 @@ class MessageTable:
messages = [] messages = []
for message in all_messages: for message in all_messages:
reply_to_message = ( reply_to_message = (
self.get_message_by_id(message.reply_to_id) self.get_message_by_id(
message.reply_to_id, include_thread_replies=False, db=db
)
if message.reply_to_id if message.reply_to_id
else None else None
) )
webhook_info = message.meta.get("webhook") if message.meta else None
user_info = None
if webhook_info and webhook_info.get("id"):
webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db)
if webhook:
user_info = {
"id": webhook.id,
"name": webhook.name,
"role": "webhook",
}
else:
user_info = {
"id": webhook_info.get("id"),
"name": "Deleted Webhook",
"role": "webhook",
}
messages.append( messages.append(
MessageReplyToResponse.model_validate( MessageReplyToResponse.model_validate(
{ {
**MessageModel.model_validate(message).model_dump(), **MessageModel.model_validate(message).model_dump(),
"user": user_info,
"reply_to_message": ( "reply_to_message": (
reply_to_message.model_dump() reply_to_message.model_dump()
if reply_to_message if reply_to_message
@@ -314,8 +426,10 @@ class MessageTable:
) )
return messages return messages
def get_last_message_by_channel_id(self, channel_id: str) -> Optional[MessageModel]: def get_last_message_by_channel_id(
with get_db() as db: self, channel_id: str, db: Optional[Session] = None
) -> Optional[MessageModel]:
with get_db_context(db) as db:
message = ( message = (
db.query(Message) db.query(Message)
.filter_by(channel_id=channel_id) .filter_by(channel_id=channel_id)
@@ -325,9 +439,13 @@ class MessageTable:
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None
def get_pinned_messages_by_channel_id( def get_pinned_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50 self,
channel_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[MessageModel]: ) -> list[MessageModel]:
with get_db() as db: with get_db_context(db) as db:
all_messages = ( all_messages = (
db.query(Message) db.query(Message)
.filter_by(channel_id=channel_id, is_pinned=True) .filter_by(channel_id=channel_id, is_pinned=True)
@@ -339,9 +457,9 @@ class MessageTable:
return [MessageModel.model_validate(message) for message in all_messages] return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id( def update_message_by_id(
self, id: str, form_data: MessageForm self, id: str, form_data: MessageForm, db: Optional[Session] = None
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
with get_db() as db: with get_db_context(db) as db:
message = db.get(Message, id) message = db.get(Message, id)
message.content = form_data.content message.content = form_data.content
message.data = { message.data = {
@@ -358,9 +476,13 @@ class MessageTable:
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None
def update_is_pinned_by_id( def update_is_pinned_by_id(
self, id: str, is_pinned: bool, pinned_by: Optional[str] = None self,
id: str,
is_pinned: bool,
pinned_by: Optional[str] = None,
db: Optional[Session] = None,
) -> Optional[MessageModel]: ) -> Optional[MessageModel]:
with get_db() as db: with get_db_context(db) as db:
message = db.get(Message, id) message = db.get(Message, id)
message.is_pinned = is_pinned message.is_pinned = is_pinned
message.pinned_at = int(time.time_ns()) if is_pinned else None message.pinned_at = int(time.time_ns()) if is_pinned else None
@@ -370,9 +492,13 @@ class MessageTable:
return MessageModel.model_validate(message) if message else None return MessageModel.model_validate(message) if message else None
def get_unread_message_count( def get_unread_message_count(
self, channel_id: str, user_id: str, last_read_at: Optional[int] = None self,
channel_id: str,
user_id: str,
last_read_at: Optional[int] = None,
db: Optional[Session] = None,
) -> int: ) -> int:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Message).filter( query = db.query(Message).filter(
Message.channel_id == channel_id, Message.channel_id == channel_id,
Message.parent_id == None, # only count top-level messages Message.parent_id == None, # only count top-level messages
@@ -383,9 +509,9 @@ class MessageTable:
return query.count() return query.count()
def add_reaction_to_message( def add_reaction_to_message(
self, id: str, user_id: str, name: str self, id: str, user_id: str, name: str, db: Optional[Session] = None
) -> Optional[MessageReactionModel]: ) -> Optional[MessageReactionModel]:
with get_db() as db: with get_db_context(db) as db:
# check for existing reaction # check for existing reaction
existing_reaction = ( existing_reaction = (
db.query(MessageReaction) db.query(MessageReaction)
@@ -409,8 +535,10 @@ class MessageTable:
db.refresh(result) db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(self, id: str) -> list[Reactions]: def get_reactions_by_message_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> list[Reactions]:
with get_db_context(db) as db:
# JOIN User so all user info is fetched in one query # JOIN User so all user info is fetched in one query
results = ( results = (
db.query(MessageReaction, User) db.query(MessageReaction, User)
@@ -440,29 +568,29 @@ class MessageTable:
return [Reactions(**reaction) for reaction in reactions.values()] return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name( def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str self, id: str, user_id: str, name: str, db: Optional[Session] = None
) -> bool: ) -> bool:
with get_db() as db: with get_db_context(db) as db:
db.query(MessageReaction).filter_by( db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name message_id=id, user_id=user_id, name=name
).delete() ).delete()
db.commit() db.commit()
return True return True
def delete_reactions_by_id(self, id: str) -> bool: def delete_reactions_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
db.query(MessageReaction).filter_by(message_id=id).delete() db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit() db.commit()
return True return True
def delete_replies_by_id(self, id: str) -> bool: def delete_replies_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
db.query(Message).filter_by(parent_id=id).delete() db.query(Message).filter_by(parent_id=id).delete()
db.commit() db.commit()
return True return True
def delete_message_by_id(self, id: str) -> bool: def delete_message_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
db.query(Message).filter_by(id=id).delete() db.query(Message).filter_by(id=id).delete()
# Delete all reactions to this message # Delete all reactions to this message
@@ -471,5 +599,35 @@ class MessageTable:
db.commit() db.commit()
return True return True
def search_messages_by_channel_ids(
self,
channel_ids: list[str],
query: str,
start_timestamp: Optional[int] = None,
end_timestamp: Optional[int] = None,
limit: int = 10,
db: Optional[Session] = None,
) -> list[MessageModel]:
"""Search messages in specified channels by content."""
with get_db_context(db) as db:
query_builder = db.query(Message).filter(
Message.channel_id.in_(channel_ids),
Message.content.ilike(f"%{query}%"),
)
if start_timestamp:
query_builder = query_builder.filter(
Message.created_at >= start_timestamp
)
if end_timestamp:
query_builder = query_builder.filter(
Message.created_at <= end_timestamp
)
messages = (
query_builder.order_by(Message.created_at.desc()).limit(limit).all()
)
return [MessageModel.model_validate(msg) for msg in messages]
Messages = MessageTable() Messages = MessageTable()
+58 -31
View File
@@ -2,7 +2,8 @@ import logging
import time import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
@@ -129,6 +130,10 @@ class ModelUserResponse(ModelModel):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
class ModelAccessResponse(ModelUserResponse):
write_access: Optional[bool] = False
class ModelResponse(ModelModel): class ModelResponse(ModelModel):
pass pass
@@ -138,6 +143,11 @@ class ModelListResponse(BaseModel):
total: int total: int
class ModelAccessListResponse(BaseModel):
items: list[ModelAccessResponse]
total: int
class ModelForm(BaseModel): class ModelForm(BaseModel):
id: str id: str
base_model_id: Optional[str] = None base_model_id: Optional[str] = None
@@ -150,7 +160,7 @@ class ModelForm(BaseModel):
class ModelsTable: class ModelsTable:
def insert_new_model( def insert_new_model(
self, form_data: ModelForm, user_id: str self, form_data: ModelForm, user_id: str, db: Optional[Session] = None
) -> Optional[ModelModel]: ) -> Optional[ModelModel]:
model = ModelModel( model = ModelModel(
**{ **{
@@ -161,7 +171,7 @@ class ModelsTable:
} }
) )
try: try:
with get_db() as db: with get_db_context(db) as db:
result = Model(**model.model_dump()) result = Model(**model.model_dump())
db.add(result) db.add(result)
db.commit() db.commit()
@@ -175,17 +185,17 @@ class ModelsTable:
log.exception(f"Failed to insert a new model: {e}") log.exception(f"Failed to insert a new model: {e}")
return None return None
def get_all_models(self) -> list[ModelModel]: def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db() as db: with get_db_context(db) as db:
return [ModelModel.model_validate(model) for model in db.query(Model).all()] return [ModelModel.model_validate(model) for model in db.query(Model).all()]
def get_models(self) -> list[ModelUserResponse]: def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
with get_db() as db: with get_db_context(db) as db:
all_models = db.query(Model).filter(Model.base_model_id != None).all() all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models)) user_ids = list(set(model.user_id for model in all_models))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users} users_dict = {user.id: user for user in users}
models = [] models = []
@@ -201,18 +211,20 @@ class ModelsTable:
) )
return models return models
def get_base_models(self) -> list[ModelModel]: def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db() as db: with get_db_context(db) as db:
return [ return [
ModelModel.model_validate(model) ModelModel.model_validate(model)
for model in db.query(Model).filter(Model.base_model_id == None).all() for model in db.query(Model).filter(Model.base_model_id == None).all()
] ]
def get_models_by_user_id( def get_models_by_user_id(
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[ModelUserResponse]: ) -> list[ModelUserResponse]:
models = self.get_models() models = self.get_models(db=db)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [ return [
model model
for model in models for model in models
@@ -263,9 +275,14 @@ class ModelsTable:
return query return query
def search_models( def search_models(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> ModelListResponse: ) -> ModelListResponse:
with get_db() as db: with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested # Join GroupMember so we can order by group_id when requested
query = db.query(Model, User).outerjoin(User, User.id == Model.user_id) query = db.query(Model, User).outerjoin(User, User.id == Model.user_id)
query = query.filter(Model.base_model_id != None) query = query.filter(Model.base_model_id != None)
@@ -291,7 +308,7 @@ class ModelsTable:
db, db,
query, query,
filter, filter,
permission="write", permission="read",
) )
tag = filter.get("tag") tag = filter.get("tag")
@@ -349,24 +366,30 @@ class ModelsTable:
return ModelListResponse(items=models, total=total) return ModelListResponse(items=models, total=total)
def get_model_by_id(self, id: str) -> Optional[ModelModel]: def get_model_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
model = db.get(Model, id) model = db.get(Model, id)
return ModelModel.model_validate(model) return ModelModel.model_validate(model)
except Exception: except Exception:
return None return None
def get_models_by_ids(self, ids: list[str]) -> list[ModelModel]: def get_models_by_ids(
self, ids: list[str], db: Optional[Session] = None
) -> list[ModelModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all() models = db.query(Model).filter(Model.id.in_(ids)).all()
return [ModelModel.model_validate(model) for model in models] return [ModelModel.model_validate(model) for model in models]
except Exception: except Exception:
return [] return []
def toggle_model_by_id(self, id: str) -> Optional[ModelModel]: def toggle_model_by_id(
with get_db() as db: self, id: str, db: Optional[Session] = None
) -> Optional[ModelModel]:
with get_db_context(db) as db:
try: try:
is_active = db.query(Model).filter_by(id=id).first().is_active is_active = db.query(Model).filter_by(id=id).first().is_active
@@ -378,13 +401,15 @@ class ModelsTable:
) )
db.commit() db.commit()
return self.get_model_by_id(id) return self.get_model_by_id(id, db=db)
except Exception: except Exception:
return None return None
def update_model_by_id(self, id: str, model: ModelForm) -> Optional[ModelModel]: def update_model_by_id(
self, id: str, model: ModelForm, db: Optional[Session] = None
) -> Optional[ModelModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
# update only the fields that are present in the model # update only the fields that are present in the model
data = model.model_dump(exclude={"id"}) data = model.model_dump(exclude={"id"})
result = db.query(Model).filter_by(id=id).update(data) result = db.query(Model).filter_by(id=id).update(data)
@@ -398,9 +423,9 @@ class ModelsTable:
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 return None
def delete_model_by_id(self, id: str) -> bool: def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Model).filter_by(id=id).delete() db.query(Model).filter_by(id=id).delete()
db.commit() db.commit()
@@ -408,9 +433,9 @@ class ModelsTable:
except Exception: except Exception:
return False return False
def delete_all_models(self) -> bool: def delete_all_models(self, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Model).delete() db.query(Model).delete()
db.commit() db.commit()
@@ -418,9 +443,11 @@ class ModelsTable:
except Exception: except Exception:
return False return False
def sync_models(self, user_id: str, models: list[ModelModel]) -> list[ModelModel]: def sync_models(
self, user_id: str, models: list[ModelModel], db: Optional[Session] = None
) -> list[ModelModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
# Get existing models # Get existing models
existing_models = db.query(Model).all() existing_models = db.query(Model).all()
existing_ids = {model.id for model in existing_models} existing_ids = {model.id for model in existing_models}
+44 -26
View File
@@ -4,7 +4,8 @@ import uuid
from typing import Optional from typing import Optional
from functools import lru_cache from functools import lru_cache
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.models.users import User, UserModel, Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
@@ -211,11 +212,9 @@ class NoteTable:
return query return query
def insert_new_note( def insert_new_note(
self, self, user_id: str, form_data: NoteForm, db: Optional[Session] = None
form_data: NoteForm,
user_id: str,
) -> Optional[NoteModel]: ) -> Optional[NoteModel]:
with get_db() as db: with get_db_context(db) as db:
note = NoteModel( note = NoteModel(
**{ **{
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
@@ -233,9 +232,9 @@ class NoteTable:
return note return note
def get_notes( def get_notes(
self, skip: Optional[int] = None, limit: Optional[int] = None self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[NoteModel]: ) -> list[NoteModel]:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Note).order_by(Note.updated_at.desc()) query = db.query(Note).order_by(Note.updated_at.desc())
if skip is not None: if skip is not None:
query = query.offset(skip) query = query.offset(skip)
@@ -245,19 +244,32 @@ class NoteTable:
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def search_notes( def search_notes(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30 self,
user_id: str,
filter: dict = {},
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> NoteListResponse: ) -> NoteListResponse:
with get_db() as db: with get_db_context(db) as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id) query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter: if filter:
query_key = filter.get("query") query_key = filter.get("query")
if query_key: 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( query = query.filter(
or_( or_(
Note.title.ilike(f"%{query_key}%"), func.replace(
cast(Note.data["content"]["md"], Text).ilike( func.replace(Note.title, "-", ""), " ", ""
f"%{query_key}%" ).ilike(f"%{normalized_query}%"),
), func.replace(
func.replace(
cast(Note.data["content"]["md"], Text), "-", ""
),
" ",
"",
).ilike(f"%{normalized_query}%"),
) )
) )
@@ -333,12 +345,13 @@ class NoteTable:
self, self,
user_id: str, user_id: str,
permission: str = "read", permission: str = "read",
skip: Optional[int] = None, skip: int = 0,
limit: Optional[int] = None, limit: int = 50,
db: Optional[Session] = None,
) -> list[NoteModel]: ) -> list[NoteModel]:
with get_db() as db: with get_db_context(db) as db:
user_group_ids = [ user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id) group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
] ]
query = db.query(Note).order_by(Note.updated_at.desc()) query = db.query(Note).order_by(Note.updated_at.desc())
@@ -354,15 +367,17 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def get_note_by_id(self, id: str) -> Optional[NoteModel]: def get_note_by_id(
with get_db() as db: 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() note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None return NoteModel.model_validate(note) if note else None
def update_note_by_id( def update_note_by_id(
self, id: str, form_data: NoteUpdateForm self, id: str, form_data: NoteUpdateForm, db: Optional[Session] = None
) -> Optional[NoteModel]: ) -> Optional[NoteModel]:
with get_db() as db: with get_db_context(db) as db:
note = db.query(Note).filter(Note.id == id).first() note = db.query(Note).filter(Note.id == id).first()
if not note: if not note:
return None return None
@@ -384,11 +399,14 @@ class NoteTable:
db.commit() db.commit()
return NoteModel.model_validate(note) if note else None return NoteModel.model_validate(note) if note else None
def delete_note_by_id(self, id: str): def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: try:
db.query(Note).filter(Note.id == id).delete() with get_db_context(db) as db:
db.commit() db.query(Note).filter(Note.id == id).delete()
return True db.commit()
return True
except Exception:
return False
Notes = NoteTable() Notes = NoteTable()
+30 -18
View File
@@ -8,7 +8,8 @@ import json
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY from open_webui.env import OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -109,10 +110,11 @@ class OAuthSessionTable:
user_id: str, user_id: str,
provider: str, provider: str,
token: dict, token: dict,
db: Optional[Session] = None,
) -> Optional[OAuthSessionModel]: ) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session""" """Create a new OAuth session"""
try: try:
with get_db() as db: with get_db_context(db) as db:
current_time = int(time.time()) current_time = int(time.time())
id = str(uuid.uuid4()) id = str(uuid.uuid4())
@@ -141,10 +143,12 @@ class OAuthSessionTable:
log.error(f"Error creating OAuth session: {e}") log.error(f"Error creating OAuth session: {e}")
return None return None
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]: def get_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID""" """Get OAuth session by ID"""
try: try:
with get_db() as db: with get_db_context(db) as db:
session = db.query(OAuthSession).filter_by(id=session_id).first() session = db.query(OAuthSession).filter_by(id=session_id).first()
if session: if session:
session.token = self._decrypt_token(session.token) session.token = self._decrypt_token(session.token)
@@ -156,11 +160,11 @@ class OAuthSessionTable:
return None return None
def get_session_by_id_and_user_id( def get_session_by_id_and_user_id(
self, session_id: str, user_id: str self, session_id: str, user_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]: ) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID""" """Get OAuth session by ID and user ID"""
try: try:
with get_db() as db: with get_db_context(db) as db:
session = ( session = (
db.query(OAuthSession) db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id) .filter_by(id=session_id, user_id=user_id)
@@ -176,11 +180,11 @@ class OAuthSessionTable:
return None return None
def get_session_by_provider_and_user_id( def get_session_by_provider_and_user_id(
self, provider: str, user_id: str self, provider: str, user_id: str, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]: ) -> Optional[OAuthSessionModel]:
"""Get OAuth session by provider and user ID""" """Get OAuth session by provider and user ID"""
try: try:
with get_db() as db: with get_db_context(db) as db:
session = ( session = (
db.query(OAuthSession) db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id) .filter_by(provider=provider, user_id=user_id)
@@ -195,10 +199,12 @@ class OAuthSessionTable:
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 return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]: def get_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user""" """Get all OAuth sessions for a user"""
try: try:
with get_db() as db: with get_db_context(db) as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all() sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
results = [] results = []
@@ -213,11 +219,11 @@ class OAuthSessionTable:
return [] return []
def update_session_by_id( def update_session_by_id(
self, session_id: str, token: dict self, session_id: str, token: dict, db: Optional[Session] = None
) -> Optional[OAuthSessionModel]: ) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens""" """Update OAuth session tokens"""
try: try:
with get_db() as db: with get_db_context(db) as db:
current_time = int(time.time()) current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update( db.query(OAuthSession).filter_by(id=session_id).update(
@@ -239,10 +245,12 @@ class OAuthSessionTable:
log.error(f"Error updating OAuth session tokens: {e}") log.error(f"Error updating OAuth session tokens: {e}")
return None return None
def delete_session_by_id(self, session_id: str) -> bool: def delete_session_by_id(
self, session_id: str, db: Optional[Session] = None
) -> bool:
"""Delete an OAuth session""" """Delete an OAuth session"""
try: try:
with get_db() as db: with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete() result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit() db.commit()
return result > 0 return result > 0
@@ -250,10 +258,12 @@ class OAuthSessionTable:
log.error(f"Error deleting OAuth session: {e}") log.error(f"Error deleting OAuth session: {e}")
return False return False
def delete_sessions_by_user_id(self, user_id: str) -> bool: def delete_sessions_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> bool:
"""Delete all OAuth sessions for a user""" """Delete all OAuth sessions for a user"""
try: try:
with get_db() as db: with get_db_context(db) as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete() result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit() db.commit()
return True return True
@@ -261,10 +271,12 @@ class OAuthSessionTable:
log.error(f"Error deleting OAuth sessions by user ID: {e}") log.error(f"Error deleting OAuth sessions by user ID: {e}")
return False return False
def delete_sessions_by_provider(self, provider: str) -> bool: def delete_sessions_by_provider(
self, provider: str, db: Optional[Session] = None
) -> bool:
"""Delete all OAuth sessions for a provider""" """Delete all OAuth sessions for a provider"""
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(OAuthSession).filter_by(provider=provider).delete() db.query(OAuthSession).filter_by(provider=provider).delete()
db.commit() db.commit()
return True return True
+26 -15
View File
@@ -1,7 +1,8 @@
import time import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
@@ -62,6 +63,10 @@ class PromptUserResponse(PromptModel):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
class PromptAccessResponse(PromptUserResponse):
write_access: Optional[bool] = False
class PromptForm(BaseModel): class PromptForm(BaseModel):
command: str command: str
title: str title: str
@@ -71,7 +76,7 @@ class PromptForm(BaseModel):
class PromptsTable: class PromptsTable:
def insert_new_prompt( def insert_new_prompt(
self, user_id: str, form_data: PromptForm self, user_id: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]: ) -> Optional[PromptModel]:
prompt = PromptModel( prompt = PromptModel(
**{ **{
@@ -82,7 +87,7 @@ class PromptsTable:
) )
try: try:
with get_db() as db: with get_db_context(db) as db:
result = Prompt(**prompt.model_dump()) result = Prompt(**prompt.model_dump())
db.add(result) db.add(result)
db.commit() db.commit()
@@ -94,21 +99,23 @@ class PromptsTable:
except Exception: except Exception:
return None return None
def get_prompt_by_command(self, command: str) -> Optional[PromptModel]: def get_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first() prompt = db.query(Prompt).filter_by(command=command).first()
return PromptModel.model_validate(prompt) return PromptModel.model_validate(prompt)
except Exception: except Exception:
return None return None
def get_prompts(self) -> list[PromptUserResponse]: def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]:
with get_db() as db: with get_db_context(db) as db:
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all() all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
user_ids = list(set(prompt.user_id for prompt in all_prompts)) user_ids = list(set(prompt.user_id for prompt in all_prompts))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users} users_dict = {user.id: user for user in users}
prompts = [] prompts = []
@@ -126,10 +133,12 @@ class PromptsTable:
return prompts return prompts
def get_prompts_by_user_id( def get_prompts_by_user_id(
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[PromptUserResponse]: ) -> list[PromptUserResponse]:
prompts = self.get_prompts() prompts = self.get_prompts(db=db)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [ return [
prompt prompt
@@ -139,10 +148,10 @@ class PromptsTable:
] ]
def update_prompt_by_command( def update_prompt_by_command(
self, command: str, form_data: PromptForm self, command: str, form_data: PromptForm, db: Optional[Session] = None
) -> Optional[PromptModel]: ) -> Optional[PromptModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first() prompt = db.query(Prompt).filter_by(command=command).first()
prompt.title = form_data.title prompt.title = form_data.title
prompt.content = form_data.content prompt.content = form_data.content
@@ -153,9 +162,11 @@ class PromptsTable:
except Exception: except Exception:
return None return None
def delete_prompt_by_command(self, command: str) -> bool: def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Prompt).filter_by(command=command).delete() db.query(Prompt).filter_by(command=command).delete()
db.commit() db.commit()
+18 -11
View File
@@ -3,7 +3,8 @@ import time
import uuid import uuid
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -50,8 +51,10 @@ class TagChatIdForm(BaseModel):
class TagTable: class TagTable:
def insert_new_tag(self, name: str, user_id: str) -> Optional[TagModel]: def insert_new_tag(
with get_db() as db: self, name: str, user_id: str, db: Optional[Session] = None
) -> Optional[TagModel]:
with get_db_context(db) as db:
id = name.replace(" ", "_").lower() id = name.replace(" ", "_").lower()
tag = TagModel(**{"id": id, "user_id": user_id, "name": name}) tag = TagModel(**{"id": id, "user_id": user_id, "name": name})
try: try:
@@ -68,27 +71,29 @@ class TagTable:
return None return None
def get_tag_by_name_and_user_id( def get_tag_by_name_and_user_id(
self, name: str, user_id: str self, name: str, user_id: str, db: Optional[Session] = None
) -> Optional[TagModel]: ) -> Optional[TagModel]:
try: try:
id = name.replace(" ", "_").lower() id = name.replace(" ", "_").lower()
with get_db() as db: with get_db_context(db) as db:
tag = db.query(Tag).filter_by(id=id, user_id=user_id).first() tag = db.query(Tag).filter_by(id=id, user_id=user_id).first()
return TagModel.model_validate(tag) return TagModel.model_validate(tag)
except Exception: except Exception:
return None return None
def get_tags_by_user_id(self, user_id: str) -> list[TagModel]: def get_tags_by_user_id(
with get_db() as db: self, user_id: str, db: Optional[Session] = None
) -> list[TagModel]:
with get_db_context(db) as db:
return [ return [
TagModel.model_validate(tag) TagModel.model_validate(tag)
for tag in (db.query(Tag).filter_by(user_id=user_id).all()) for tag in (db.query(Tag).filter_by(user_id=user_id).all())
] ]
def get_tags_by_ids_and_user_id( def get_tags_by_ids_and_user_id(
self, ids: list[str], user_id: str self, ids: list[str], user_id: str, db: Optional[Session] = None
) -> list[TagModel]: ) -> list[TagModel]:
with get_db() as db: with get_db_context(db) as db:
return [ return [
TagModel.model_validate(tag) TagModel.model_validate(tag)
for tag in ( for tag in (
@@ -96,9 +101,11 @@ class TagTable:
) )
] ]
def delete_tag_by_name_and_user_id(self, name: str, user_id: str) -> bool: def delete_tag_by_name_and_user_id(
self, name: str, user_id: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
id = name.replace(" ", "_").lower() id = name.replace(" ", "_").lower()
res = db.query(Tag).filter_by(id=id, user_id=user_id).delete() res = db.query(Tag).filter_by(id=id, user_id=user_id).delete()
log.debug(f"res: {res}") log.debug(f"res: {res}")
+44 -25
View File
@@ -2,7 +2,8 @@ import logging
import time import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
@@ -96,6 +97,10 @@ class ToolUserResponse(ToolResponse):
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
class ToolAccessResponse(ToolUserResponse):
write_access: Optional[bool] = False
class ToolForm(BaseModel): class ToolForm(BaseModel):
id: str id: str
name: str name: str
@@ -110,9 +115,13 @@ class ToolValves(BaseModel):
class ToolsTable: class ToolsTable:
def insert_new_tool( def insert_new_tool(
self, user_id: str, form_data: ToolForm, specs: list[dict] self,
user_id: str,
form_data: ToolForm,
specs: list[dict],
db: Optional[Session] = None,
) -> Optional[ToolModel]: ) -> Optional[ToolModel]:
with get_db() as db: with get_db_context(db) as db:
tool = ToolModel( tool = ToolModel(
**{ **{
**form_data.model_dump(), **form_data.model_dump(),
@@ -136,21 +145,23 @@ class ToolsTable:
log.exception(f"Error creating a new tool: {e}") log.exception(f"Error creating a new tool: {e}")
return None return None
def get_tool_by_id(self, id: str) -> Optional[ToolModel]: def get_tool_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[ToolModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
tool = db.get(Tool, id) tool = db.get(Tool, id)
return ToolModel.model_validate(tool) return ToolModel.model_validate(tool)
except Exception: except Exception:
return None return None
def get_tools(self) -> list[ToolUserModel]: def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
with get_db() as db: with get_db_context(db) as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all() all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
user_ids = list(set(tool.user_id for tool in all_tools)) user_ids = list(set(tool.user_id for tool in all_tools))
users = Users.get_users_by_user_ids(user_ids) if user_ids else [] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users} users_dict = {user.id: user for user in users}
tools = [] tools = []
@@ -167,10 +178,12 @@ class ToolsTable:
return tools return tools
def get_tools_by_user_id( def get_tools_by_user_id(
self, user_id: str, permission: str = "write" self, user_id: str, permission: str = "write", db: Optional[Session] = None
) -> list[ToolUserModel]: ) -> list[ToolUserModel]:
tools = self.get_tools() tools = self.get_tools(db=db)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
return [ return [
tool tool
@@ -179,31 +192,35 @@ class ToolsTable:
or has_access(user_id, permission, tool.access_control, user_group_ids) or has_access(user_id, permission, tool.access_control, user_group_ids)
] ]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]: def get_tool_valves_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[dict]:
try: try:
with get_db() as db: with get_db_context(db) as db:
tool = db.get(Tool, id) tool = db.get(Tool, id)
return tool.valves if tool.valves else {} return tool.valves if tool.valves else {}
except Exception as e: except Exception as e:
log.exception(f"Error getting tool valves by id {id}") log.exception(f"Error getting tool valves by id {id}")
return None return None
def update_tool_valves_by_id(self, id: str, valves: dict) -> Optional[ToolValves]: def update_tool_valves_by_id(
self, id: str, valves: dict, db: Optional[Session] = None
) -> Optional[ToolValves]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).update( db.query(Tool).filter_by(id=id).update(
{"valves": valves, "updated_at": int(time.time())} {"valves": valves, "updated_at": int(time.time())}
) )
db.commit() db.commit()
return self.get_tool_by_id(id) return self.get_tool_by_id(id, db=db)
except Exception: except Exception:
return None return None
def get_user_valves_by_id_and_user_id( def get_user_valves_by_id_and_user_id(
self, id: str, user_id: str self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[dict]: ) -> Optional[dict]:
try: try:
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {} user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "tools" and "valves" settings # Check if user has "tools" and "valves" settings
@@ -220,10 +237,10 @@ class ToolsTable:
return None return None
def update_user_valves_by_id_and_user_id( def update_user_valves_by_id_and_user_id(
self, id: str, user_id: str, valves: dict self, id: str, user_id: str, valves: dict, db: Optional[Session] = None
) -> Optional[dict]: ) -> Optional[dict]:
try: try:
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
user_settings = user.settings.model_dump() if user.settings else {} user_settings = user.settings.model_dump() if user.settings else {}
# Check if user has "tools" and "valves" settings # Check if user has "tools" and "valves" settings
@@ -235,7 +252,7 @@ class ToolsTable:
user_settings["tools"]["valves"][id] = valves user_settings["tools"]["valves"][id] = valves
# Update the user settings in the database # Update the user settings in the database
Users.update_user_by_id(user_id, {"settings": user_settings}) Users.update_user_by_id(user_id, {"settings": user_settings}, db=db)
return user_settings["tools"]["valves"][id] return user_settings["tools"]["valves"][id]
except Exception as e: except Exception as e:
@@ -244,9 +261,11 @@ class ToolsTable:
) )
return None return None
def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: def update_tool_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[ToolModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).update( db.query(Tool).filter_by(id=id).update(
{**updated, "updated_at": int(time.time())} {**updated, "updated_at": int(time.time())}
) )
@@ -258,9 +277,9 @@ class ToolsTable:
except Exception: except Exception:
return None return None
def delete_tool_by_id(self, id: str) -> bool: def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(Tool).filter_by(id=id).delete() db.query(Tool).filter_by(id=id).delete()
db.commit() db.commit()
+92 -57
View File
@@ -1,7 +1,8 @@
import time import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from sqlalchemy.orm import Session
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
@@ -243,8 +244,9 @@ class UsersTable:
profile_image_url: str = "/user.png", profile_image_url: str = "/user.png",
role: str = "pending", role: str = "pending",
oauth: Optional[dict] = None, oauth: Optional[dict] = None,
db: Optional[Session] = None,
) -> Optional[UserModel]: ) -> Optional[UserModel]:
with get_db() as db: with get_db_context(db) as db:
user = UserModel( user = UserModel(
**{ **{
"id": id, "id": id,
@@ -267,17 +269,21 @@ class UsersTable:
else: else:
return None return None
def get_user_by_id(self, id: str) -> Optional[UserModel]: def get_user_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first() user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user) return UserModel.model_validate(user)
except Exception: except Exception:
return None return None
def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: def get_user_by_api_key(
self, api_key: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
user = ( user = (
db.query(User) db.query(User)
.join(ApiKey, User.id == ApiKey.user_id) .join(ApiKey, User.id == ApiKey.user_id)
@@ -288,17 +294,21 @@ class UsersTable:
except Exception: except Exception:
return None return None
def get_user_by_email(self, email: str) -> Optional[UserModel]: def get_user_by_email(
self, email: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).filter_by(email=email).first() user = db.query(User).filter_by(email=email).first()
return UserModel.model_validate(user) return UserModel.model_validate(user)
except Exception: except Exception:
return None return None
def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]: def get_user_by_oauth_sub(
self, provider: str, sub: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: # type: Session with get_db_context(db) as db: # type: Session
dialect_name = db.bind.dialect.name dialect_name = db.bind.dialect.name
query = db.query(User) query = db.query(User)
@@ -320,8 +330,9 @@ class UsersTable:
filter: Optional[dict] = None, filter: Optional[dict] = None,
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
db: Optional[Session] = None,
) -> dict: ) -> dict:
with get_db() as db: with get_db_context(db) as db:
# Join GroupMember so we can order by group_id when requested # Join GroupMember so we can order by group_id when requested
query = db.query(User) query = db.query(User)
@@ -452,8 +463,10 @@ class UsersTable:
"total": total, "total": total,
} }
def get_users_by_group_id(self, group_id: str) -> list[UserModel]: def get_users_by_group_id(
with get_db() as db: self, group_id: str, db: Optional[Session] = None
) -> list[UserModel]:
with get_db_context(db) as db:
users = ( users = (
db.query(User) db.query(User)
.join(GroupMember, User.id == GroupMember.user_id) .join(GroupMember, User.id == GroupMember.user_id)
@@ -462,30 +475,34 @@ class UsersTable:
) )
return [UserModel.model_validate(user) for user in users] return [UserModel.model_validate(user) for user in users]
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]: def get_users_by_user_ids(
with get_db() as db: self, user_ids: list[str], db: Optional[Session] = None
) -> list[UserStatusModel]:
with get_db_context(db) as db:
users = db.query(User).filter(User.id.in_(user_ids)).all() users = db.query(User).filter(User.id.in_(user_ids)).all()
return [UserModel.model_validate(user) for user in users] return [UserModel.model_validate(user) for user in users]
def get_num_users(self) -> Optional[int]: def get_num_users(self, db: Optional[Session] = None) -> Optional[int]:
with get_db() as db: with get_db_context(db) as db:
return db.query(User).count() return db.query(User).count()
def has_users(self) -> bool: def has_users(self, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
return db.query(db.query(User).exists()).scalar() return db.query(db.query(User).exists()).scalar()
def get_first_user(self) -> UserModel: def get_first_user(self, db: Optional[Session] = None) -> UserModel:
try: try:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).order_by(User.created_at).first() user = db.query(User).order_by(User.created_at).first()
return UserModel.model_validate(user) return UserModel.model_validate(user)
except Exception: except Exception:
return None return None
def get_user_webhook_url_by_id(self, id: str) -> Optional[str]: def get_user_webhook_url_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try: try:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first() user = db.query(User).filter_by(id=id).first()
if user.settings is None: if user.settings is None:
@@ -499,8 +516,8 @@ class UsersTable:
except Exception: except Exception:
return None return None
def get_num_users_active_today(self) -> Optional[int]: def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[int]:
with get_db() as db: with get_db_context(db) as db:
current_timestamp = int(datetime.datetime.now().timestamp()) current_timestamp = int(datetime.datetime.now().timestamp())
today_midnight_timestamp = current_timestamp - (current_timestamp % 86400) today_midnight_timestamp = current_timestamp - (current_timestamp % 86400)
query = db.query(User).filter( query = db.query(User).filter(
@@ -508,9 +525,11 @@ class UsersTable:
) )
return query.count() return query.count()
def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]: def update_user_role_by_id(
self, id: str, role: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(User).filter_by(id=id).update({"role": role}) db.query(User).filter_by(id=id).update({"role": role})
db.commit() db.commit()
user = db.query(User).filter_by(id=id).first() user = db.query(User).filter_by(id=id).first()
@@ -519,10 +538,10 @@ class UsersTable:
return None return None
def update_user_status_by_id( def update_user_status_by_id(
self, id: str, form_data: UserStatus self, id: str, form_data: UserStatus, db: Optional[Session] = None
) -> Optional[UserModel]: ) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(User).filter_by(id=id).update( db.query(User).filter_by(id=id).update(
{**form_data.model_dump(exclude_none=True)} {**form_data.model_dump(exclude_none=True)}
) )
@@ -534,10 +553,10 @@ class UsersTable:
return None return None
def update_user_profile_image_url_by_id( def update_user_profile_image_url_by_id(
self, id: str, profile_image_url: str self, id: str, profile_image_url: str, db: Optional[Session] = None
) -> Optional[UserModel]: ) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(User).filter_by(id=id).update( db.query(User).filter_by(id=id).update(
{"profile_image_url": profile_image_url} {"profile_image_url": profile_image_url}
) )
@@ -549,9 +568,11 @@ class UsersTable:
return None return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_last_active_by_id(self, id: str) -> Optional[UserModel]: def update_last_active_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(User).filter_by(id=id).update( db.query(User).filter_by(id=id).update(
{"last_active_at": int(time.time())} {"last_active_at": int(time.time())}
) )
@@ -563,7 +584,7 @@ class UsersTable:
return None return None
def update_user_oauth_by_id( def update_user_oauth_by_id(
self, id: str, provider: str, sub: str self, id: str, provider: str, sub: str, db: Optional[Session] = None
) -> Optional[UserModel]: ) -> Optional[UserModel]:
""" """
Update or insert an OAuth provider/sub pair into the user's oauth JSON field. Update or insert an OAuth provider/sub pair into the user's oauth JSON field.
@@ -574,7 +595,7 @@ class UsersTable:
} }
""" """
try: try:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first() user = db.query(User).filter_by(id=id).first()
if not user: if not user:
return None return None
@@ -594,9 +615,11 @@ class UsersTable:
except Exception: except Exception:
return None return None
def update_user_by_id(self, id: str, updated: dict) -> Optional[UserModel]: def update_user_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(User).filter_by(id=id).update(updated) db.query(User).filter_by(id=id).update(updated)
db.commit() db.commit()
@@ -607,10 +630,16 @@ class UsersTable:
print(e) print(e)
return None return None
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]: def update_user_settings_by_id(
self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[UserModel]:
try: try:
with get_db() as db: with get_db_context(db) as db:
user_settings = db.query(User).filter_by(id=id).first().settings user = db.query(User).filter_by(id=id).first()
if not user:
return None
user_settings = user.settings
if user_settings is None: if user_settings is None:
user_settings = {} user_settings = {}
@@ -625,15 +654,15 @@ class UsersTable:
except Exception: except Exception:
return None return None
def delete_user_by_id(self, id: str) -> bool: def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
# Remove User from Groups # Remove User from Groups
Groups.remove_user_from_all_groups(id) Groups.remove_user_from_all_groups(id)
# Delete User Chats # Delete User Chats
result = Chats.delete_chats_by_user_id(id) result = Chats.delete_chats_by_user_id(id, db=db)
if result: if result:
with get_db() as db: with get_db_context(db) as db:
# Delete User # Delete User
db.query(User).filter_by(id=id).delete() db.query(User).filter_by(id=id).delete()
db.commit() db.commit()
@@ -644,17 +673,21 @@ class UsersTable:
except Exception: except Exception:
return False return False
def get_user_api_key_by_id(self, id: str) -> Optional[str]: def get_user_api_key_by_id(
self, id: str, db: Optional[Session] = None
) -> Optional[str]:
try: try:
with get_db() as db: with get_db_context(db) as db:
api_key = db.query(ApiKey).filter_by(user_id=id).first() api_key = db.query(ApiKey).filter_by(user_id=id).first()
return api_key.key if api_key else None return api_key.key if api_key else None
except Exception: except Exception:
return None return None
def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: def update_user_api_key_by_id(
self, id: str, api_key: str, db: Optional[Session] = None
) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete() db.query(ApiKey).filter_by(user_id=id).delete()
db.commit() db.commit()
@@ -674,30 +707,32 @@ class UsersTable:
except Exception: except Exception:
return False return False
def delete_user_api_key_by_id(self, id: str) -> bool: def delete_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> bool:
try: try:
with get_db() as db: with get_db_context(db) as db:
db.query(ApiKey).filter_by(user_id=id).delete() db.query(ApiKey).filter_by(user_id=id).delete()
db.commit() db.commit()
return True return True
except Exception: except Exception:
return False return False
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]: def get_valid_user_ids(
with get_db() as db: self, user_ids: list[str], db: Optional[Session] = None
) -> list[str]:
with get_db_context(db) as db:
users = db.query(User).filter(User.id.in_(user_ids)).all() users = db.query(User).filter(User.id.in_(user_ids)).all()
return [user.id for user in users] return [user.id for user in users]
def get_super_admin_user(self) -> Optional[UserModel]: def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).filter_by(role="admin").first() user = db.query(User).filter_by(role="admin").first()
if user: if user:
return UserModel.model_validate(user) return UserModel.model_validate(user)
else: else:
return None return None
def get_active_user_count(self) -> int: def get_active_user_count(self, db: Optional[Session] = None) -> int:
with get_db() as db: with get_db_context(db) as db:
# Consider user active if last_active_at within the last 3 minutes # Consider user active if last_active_at within the last 3 minutes
three_minutes_ago = int(time.time()) - 180 three_minutes_ago = int(time.time()) - 180
count = ( count = (
@@ -705,8 +740,8 @@ class UsersTable:
) )
return count return count
def is_user_active(self, user_id: str) -> bool: def is_user_active(self, user_id: str, db: Optional[Session] = None) -> bool:
with get_db() as db: with get_db_context(db) as db:
user = db.query(User).filter_by(id=user_id).first() user = db.query(User).filter_by(id=user_id).first()
if user and user.last_active_at: if user and user.last_active_at:
# Consider user active if last_active_at within the last 3 minutes # Consider user active if last_active_at within the last 3 minutes
+2 -2
View File
@@ -30,7 +30,7 @@ from open_webui.retrieval.loaders.datalab_marker import DatalabMarkerLoader
from open_webui.retrieval.loaders.mineru import MinerULoader from open_webui.retrieval.loaders.mineru import MinerULoader
from open_webui.env import GLOBAL_LOG_LEVEL from open_webui.env import GLOBAL_LOG_LEVEL, REQUESTS_VERIFY
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -114,7 +114,7 @@ class TikaLoader:
endpoint += "/" endpoint += "/"
endpoint += "tika/text" endpoint += "tika/text"
r = requests.put(endpoint, data=data, headers=headers) r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY)
if r.ok: if r.ok:
raw_metadata = r.json() raw_metadata = r.json()
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from urllib.parse import quote from urllib.parse import quote
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, REQUESTS_VERIFY
from open_webui.retrieval.models.base_reranker import BaseReranker from open_webui.retrieval.models.base_reranker import BaseReranker
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
@@ -55,6 +55,7 @@ class ExternalReranker(BaseReranker):
headers=headers, headers=headers,
json=payload, json=payload,
timeout=self.timeout, timeout=self.timeout,
verify=REQUESTS_VERIFY,
) )
r.raise_for_status() r.raise_for_status()
+18 -5
View File
@@ -40,8 +40,10 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import ( from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT,
OFFLINE_MODE, OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS, ENABLE_FORWARD_USER_INFO_HEADERS,
AIOHTTP_CLIENT_SESSION_SSL,
) )
from open_webui.config import ( from open_webui.config import (
RAG_EMBEDDING_QUERY_PREFIX, RAG_EMBEDDING_QUERY_PREFIX,
@@ -595,7 +597,9 @@ async def agenerate_openai_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user: if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user) headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post( async with session.post(
f"{url}/embeddings", headers=headers, json=form_data f"{url}/embeddings", headers=headers, json=form_data
) as r: ) as r:
@@ -684,7 +688,9 @@ async def agenerate_azure_openai_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user: if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user) headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(full_url, headers=headers, json=form_data) as r: async with session.post(full_url, headers=headers, json=form_data) as r:
r.raise_for_status() r.raise_for_status()
data = await r.json() data = await r.json()
@@ -760,9 +766,14 @@ async def agenerate_ollama_batch_embeddings(
if ENABLE_FORWARD_USER_INFO_HEADERS and user: if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user) headers = include_user_info_headers(headers, user)
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post( async with session.post(
f"{url}/api/embed", headers=headers, json=form_data f"{url}/api/embed",
headers=headers,
json=form_data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r: ) as r:
r.raise_for_status() r.raise_for_status()
data = await r.json() data = await r.json()
@@ -791,7 +802,9 @@ def get_embedding_function(
return await asyncio.to_thread( return await asyncio.to_thread(
( (
lambda query, prefix=None: embedding_function.encode( lambda query, prefix=None: embedding_function.encode(
query, **({"prompt": prefix} if prefix else {}) query,
batch_size=int(embedding_batch_size),
**({"prompt": prefix} if prefix else {}),
).tolist() ).tolist()
), ),
query, query,
@@ -69,7 +69,11 @@ class ChromaClient(VectorDBBase):
return self.client.delete_collection(name=collection_name) return self.client.delete_collection(name=collection_name)
def search( def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results. # Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
try: try:
@@ -78,6 +82,7 @@ class ChromaClient(VectorDBBase):
result = collection.query( result = collection.query(
query_embeddings=vectors, query_embeddings=vectors,
n_results=limit, n_results=limit,
where=filter,
) )
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1 # chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
@@ -153,7 +153,11 @@ class ElasticsearchClient(VectorDBBase):
# Status: works # Status: works
def search( def search(
self, collection_name: str, vectors: list[list[float]], limit: int self,
collection_name: str,
vectors: list[list[float]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
query = { query = {
"size": limit, "size": limit,
@@ -179,7 +179,11 @@ class MilvusClient(VectorDBBase):
) )
def search( def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results. # Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
collection_name = collection_name.replace("-", "_") collection_name = collection_name.replace("-", "_")
@@ -157,7 +157,11 @@ class MilvusClient(VectorDBBase):
collection.insert(entities) collection.insert(entities)
def search( def search(
self, collection_name: str, vectors: List[List[float]], limit: int self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
if not vectors: if not vectors:
return None return None
@@ -0,0 +1,427 @@
from typing import Optional, List, Dict, Any
import logging
import re
import json
from sqlalchemy import (
func,
literal,
cast,
column,
create_engine,
Column,
Integer,
MetaData,
LargeBinary,
select,
text,
Text,
Table,
values,
)
from sqlalchemy.sql import true
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
from sqlalchemy.dialects.postgresql import JSONB, array
from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2
from sqlalchemy.dialects import registry
class OpenGaussDialect(PGDialect_psycopg2):
name = "opengauss"
def _get_server_version_info(self, connection):
try:
version = connection.exec_driver_sql("SELECT version()").scalar()
if not version:
return (9, 0, 0)
match = re.search(
r"openGauss\s+(\d+)\.(\d+)\.(\d+)(?:-\w+)?", version, re.IGNORECASE
)
if match:
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
return super()._get_server_version_info(connection)
except Exception:
return (9, 0, 0)
# Register dialect
registry.register("opengauss", __name__, "OpenGaussDialect")
from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
OPENGAUSS_DB_URL,
OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH,
OPENGAUSS_POOL_SIZE,
OPENGAUSS_POOL_MAX_OVERFLOW,
OPENGAUSS_POOL_TIMEOUT,
OPENGAUSS_POOL_RECYCLE,
)
from open_webui.env import SRC_LOG_LEVELS
VECTOR_LENGTH = OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class DocumentChunk(Base):
__tablename__ = "document_chunk"
id = Column(Text, primary_key=True)
vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True)
collection_name = Column(Text, nullable=False)
text = Column(Text, nullable=True)
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
class OpenGaussClient(VectorDBBase):
def __init__(self) -> None:
if not OPENGAUSS_DB_URL:
from open_webui.internal.db import ScopedSession
self.session = ScopedSession
else:
engine_kwargs = {"pool_pre_ping": True, "dialect": OpenGaussDialect()}
if isinstance(OPENGAUSS_POOL_SIZE, int) and OPENGAUSS_POOL_SIZE > 0:
engine_kwargs.update(
{
"pool_size": OPENGAUSS_POOL_SIZE,
"max_overflow": OPENGAUSS_POOL_MAX_OVERFLOW,
"pool_timeout": OPENGAUSS_POOL_TIMEOUT,
"pool_recycle": OPENGAUSS_POOL_RECYCLE,
"poolclass": QueuePool,
}
)
else:
engine_kwargs["poolclass"] = NullPool
engine = create_engine(OPENGAUSS_DB_URL, **engine_kwargs)
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
self.session = scoped_session(SessionLocal)
try:
connection = self.session.connection()
Base.metadata.create_all(bind=connection)
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_vector "
"ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);"
)
)
self.session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name "
"ON document_chunk (collection_name);"
)
)
self.session.commit()
log.info("OpenGauss vector database initialization completed.")
except Exception as e:
self.session.rollback()
log.exception(f"OpenGauss Initialization failed.: {e}")
raise
def check_vector_length(self) -> None:
metadata = MetaData()
try:
document_chunk_table = Table(
"document_chunk", metadata, autoload_with=self.session.bind
)
except NoSuchTableError:
return
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
if isinstance(vector_type, Vector):
db_vector_length = vector_type.dim
if db_vector_length != VECTOR_LENGTH:
raise Exception(
f"Vector dimension mismatch: configured {VECTOR_LENGTH} vs. {db_vector_length} in the database."
)
else:
raise Exception("The 'vector' column type is not Vector.")
else:
raise Exception(
"The 'vector' column does not exist in the 'document_chunk' table."
)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
current_length = len(vector)
if current_length < VECTOR_LENGTH:
vector += [0.0] * (VECTOR_LENGTH - current_length)
elif current_length > VECTOR_LENGTH:
vector = vector[:VECTOR_LENGTH]
return vector
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
try:
new_items = []
for item in items:
vector = self.adjust_vector_length(item["vector"])
new_chunk = DocumentChunk(
id=item["id"],
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=process_metadata(item["metadata"]),
)
new_items.append(new_chunk)
self.session.bulk_save_objects(new_items)
self.session.commit()
log.info(
f"Inserting {len(new_items)} items into collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to insert data: {e}")
raise
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
try:
for item in items:
vector = self.adjust_vector_length(item["vector"])
existing = (
self.session.query(DocumentChunk)
.filter(DocumentChunk.id == item["id"])
.first()
)
if existing:
existing.vector = vector
existing.text = item["text"]
existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = collection_name
else:
new_chunk = DocumentChunk(
id=item["id"],
vector=vector,
collection_name=collection_name,
text=item["text"],
vmetadata=process_metadata(item["metadata"]),
)
self.session.add(new_chunk)
self.session.commit()
log.info(
f"Inserting/updating {len(items)} items in collection '{collection_name}'."
)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to insert or update data.: {e}")
raise
def search(
self,
collection_name: str,
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]:
try:
if not vectors:
return None
vectors = [self.adjust_vector_length(vector) for vector in vectors]
num_queries = len(vectors)
def vector_expr(vector):
return cast(array(vector), Vector(VECTOR_LENGTH))
qid_col = column("qid", Integer)
q_vector_col = column("q_vector", Vector(VECTOR_LENGTH))
query_vectors = (
values(qid_col, q_vector_col)
.data(
[(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)]
)
.alias("query_vectors")
)
result_fields = [
DocumentChunk.id,
DocumentChunk.text,
DocumentChunk.vmetadata,
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label(
"distance"
),
]
subq = (
select(*result_fields)
.where(DocumentChunk.collection_name == collection_name)
.order_by(
DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)
)
)
if limit is not None:
subq = subq.limit(limit)
subq = subq.lateral("result")
stmt = (
select(
query_vectors.c.qid,
subq.c.id,
subq.c.text,
subq.c.vmetadata,
subq.c.distance,
)
.select_from(query_vectors)
.join(subq, true())
.order_by(query_vectors.c.qid, subq.c.distance)
)
result_proxy = self.session.execute(stmt)
results = result_proxy.all()
ids = [[] for _ in range(num_queries)]
distances = [[] for _ in range(num_queries)]
documents = [[] for _ in range(num_queries)]
metadatas = [[] for _ in range(num_queries)]
for row in results:
qid = int(row.qid)
ids[qid].append(row.id)
distances[qid].append((2.0 - row.distance) / 2.0)
documents[qid].append(row.text)
metadatas[qid].append(row.vmetadata)
self.session.rollback()
return SearchResult(
ids=ids, distances=distances, documents=documents, metadatas=metadatas
)
except Exception as e:
self.session.rollback()
log.exception(f"Vector search failed: {e}")
return None
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
for key, value in filter.items():
query = query.filter(DocumentChunk.vmetadata[key].astext == str(value))
if limit is not None:
query = query.limit(limit)
results = query.all()
if not results:
return None
ids = [[result.id for result in results]]
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback()
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Conditional query failed: {e}")
return None
def get(
self, collection_name: str, limit: Optional[int] = None
) -> Optional[GetResult]:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
if limit is not None:
query = query.limit(limit)
results = query.all()
if not results:
return None
ids = [[result.id for result in results]]
documents = [[result.text for result in results]]
metadatas = [[result.vmetadata for result in results]]
self.session.rollback()
return GetResult(ids=ids, documents=documents, metadatas=metadatas)
except Exception as e:
self.session.rollback()
log.exception(f"Failed to retrieve data: {e}")
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
) -> None:
try:
query = self.session.query(DocumentChunk).filter(
DocumentChunk.collection_name == collection_name
)
if ids:
query = query.filter(DocumentChunk.id.in_(ids))
if filter:
for key, value in filter.items():
query = query.filter(
DocumentChunk.vmetadata[key].astext == str(value)
)
deleted = query.delete(synchronize_session=False)
self.session.commit()
log.info(f"Deleted {deleted} items from collection '{collection_name}'")
except Exception as e:
self.session.rollback()
log.exception(f"Failed to delete data: {e}")
raise
def reset(self) -> None:
try:
deleted = self.session.query(DocumentChunk).delete()
self.session.commit()
log.info(f"Reset completed. Deleted {deleted} items")
except Exception as e:
self.session.rollback()
log.exception(f"Reset failed: {e}")
raise
def close(self) -> None:
pass
def has_collection(self, collection_name: str) -> bool:
try:
exists = (
self.session.query(DocumentChunk)
.filter(DocumentChunk.collection_name == collection_name)
.first()
is not None
)
self.session.rollback()
return exists
except Exception as e:
self.session.rollback()
log.exception(f"Failed to check collection existence: {e}")
return False
def delete_collection(self, collection_name: str) -> None:
self.delete(collection_name)
log.info(f"Collection '{collection_name}' has been deleted")
@@ -113,7 +113,11 @@ class OpenSearchClient(VectorDBBase):
self.client.indices.delete(index=self._get_index_name(collection_name)) self.client.indices.delete(index=self._get_index_name(collection_name))
def search( def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
try: try:
if not self.has_collection(collection_name): if not self.has_collection(collection_name):
@@ -521,7 +521,11 @@ class Oracle23aiClient(VectorDBBase):
raise raise
def search( def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
""" """
Search for similar vectors in the database. Search for similar vectors in the database.
@@ -90,9 +90,9 @@ class PgvectorClient(VectorDBBase):
# if no pgvector uri, use the existing database connection # if no pgvector uri, use the existing database connection
if not PGVECTOR_DB_URL: if not PGVECTOR_DB_URL:
from open_webui.internal.db import Session from open_webui.internal.db import ScopedSession
self.session = Session self.session = ScopedSession
else: else:
if isinstance(PGVECTOR_POOL_SIZE, int): if isinstance(PGVECTOR_POOL_SIZE, int):
if PGVECTOR_POOL_SIZE > 0: if PGVECTOR_POOL_SIZE > 0:
@@ -427,7 +427,8 @@ class PgvectorClient(VectorDBBase):
self, self,
collection_name: str, collection_name: str,
vectors: List[List[float]], vectors: List[List[float]],
limit: Optional[int] = None, filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
try: try:
if not vectors: if not vectors:
@@ -475,9 +476,47 @@ class PgvectorClient(VectorDBBase):
) )
# Build the lateral subquery for each query vector # Build the lateral subquery for each query vector
where_clauses = [DocumentChunk.collection_name == collection_name]
# Apply metadata filter if provided
if filter:
for key, value in filter.items():
if isinstance(value, dict) and "$in" in value:
# Handle $in operator: {"field": {"$in": [values]}}
in_values = value["$in"]
if PGVECTOR_PGCRYPTO:
where_clauses.append(
pgcrypto_decrypt(
DocumentChunk.vmetadata,
PGVECTOR_PGCRYPTO_KEY,
JSONB,
)[key].astext.in_([str(v) for v in in_values])
)
else:
where_clauses.append(
DocumentChunk.vmetadata[key].astext.in_(
[str(v) for v in in_values]
)
)
else:
# Handle simple equality: {"field": "value"}
if PGVECTOR_PGCRYPTO:
where_clauses.append(
pgcrypto_decrypt(
DocumentChunk.vmetadata,
PGVECTOR_PGCRYPTO_KEY,
JSONB,
)[key].astext
== str(value)
)
else:
where_clauses.append(
DocumentChunk.vmetadata[key].astext == str(value)
)
subq = ( subq = (
select(*result_fields) select(*result_fields)
.where(DocumentChunk.collection_name == collection_name) .where(*where_clauses)
.order_by( .order_by(
(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)) (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))
) )
@@ -391,7 +391,11 @@ class PineconeClient(VectorDBBase):
) )
def search( def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
"""Search for similar vectors in a collection.""" """Search for similar vectors in a collection."""
if not vectors or not vectors[0]: if not vectors or not vectors[0]:
@@ -145,7 +145,11 @@ class QdrantClient(VectorDBBase):
) )
def search( def search(
self, collection_name: str, vectors: list[list[float | int]], limit: int self,
collection_name: str,
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results. # Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
if limit is None: if limit is None:
@@ -254,7 +254,11 @@ class QdrantClient(VectorDBBase):
) )
def search( def search(
self, collection_name: str, vectors: List[List[float | int]], limit: int self,
collection_name: str,
vectors: List[List[float | int]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
""" """
Search for the nearest neighbor items based on the vectors with tenant isolation. Search for the nearest neighbor items based on the vectors with tenant isolation.
@@ -295,7 +295,11 @@ class S3VectorClient(VectorDBBase):
raise raise
def search( def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
""" """
Search for similar vectors in a collection using multiple query vectors. Search for similar vectors in a collection using multiple query vectors.
@@ -159,7 +159,11 @@ class WeaviateClient(VectorDBBase):
) )
def search( def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
sane_collection_name = self._sanitize_collection_name(collection_name) sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name): if not self.client.collections.exists(sane_collection_name):
@@ -53,6 +53,10 @@ class Vector:
from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient from open_webui.retrieval.vector.dbs.pgvector import PgvectorClient
return PgvectorClient() return PgvectorClient()
case VectorType.OPENGAUSS:
from open_webui.retrieval.vector.dbs.opengauss import OpenGaussClient
return OpenGaussClient()
case VectorType.ELASTICSEARCH: case VectorType.ELASTICSEARCH:
from open_webui.retrieval.vector.dbs.elasticsearch import ( from open_webui.retrieval.vector.dbs.elasticsearch import (
ElasticsearchClient, ElasticsearchClient,
+5 -1
View File
@@ -53,7 +53,11 @@ class VectorDBBase(ABC):
@abstractmethod @abstractmethod
def search( def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int self,
collection_name: str,
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
) -> Optional[SearchResult]: ) -> Optional[SearchResult]:
"""Search for similar vectors in a collection.""" """Search for similar vectors in a collection."""
pass pass
@@ -12,3 +12,4 @@ class VectorType(StrEnum):
ORACLE23AI = "oracle23ai" ORACLE23AI = "oracle23ai"
S3VECTOR = "s3vector" S3VECTOR = "s3vector"
WEAVIATE = "weaviate" WEAVIATE = "weaviate"
OPENGAUSS = "opengauss"
@@ -1,4 +1,5 @@
import logging import logging
import time
from typing import Optional from typing import Optional
import requests import requests
@@ -25,6 +26,14 @@ def search_brave(
params = {"q": query, "count": count} params = {"q": query, "count": count}
response = requests.get(url, headers=headers, params=params) response = requests.get(url, headers=headers, params=params)
# Handle 429 rate limiting - Brave free tier allows 1 request/second
# If rate limited, wait 1 second and retry once before failing
if response.status_code == 429:
log.info("Brave Search API rate limited (429), retrying after 1 second...")
time.sleep(1)
response = requests.get(url, headers=headers, params=params)
response.raise_for_status() response.raise_for_status()
json_response = response.json() json_response = response.json()
@@ -13,12 +13,14 @@ def search_duckduckgo(
count: int, count: int,
filter_list: Optional[list[str]] = None, filter_list: Optional[list[str]] = None,
concurrent_requests: Optional[int] = None, concurrent_requests: Optional[int] = None,
backend: Optional[str] = "auto",
) -> list[SearchResult]: ) -> list[SearchResult]:
""" """
Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects. Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects.
Args: Args:
query (str): The query to search for query (str): The query to search for
count (int): The number of results to return count (int): The number of results to return
backend (str): The search backend to use (auto, duckduckgo, google, brave, etc.)
Returns: Returns:
list[SearchResult]: A list of search results list[SearchResult]: A list of search results
@@ -32,7 +34,7 @@ def search_duckduckgo(
# Use the ddgs.text() method to perform the search # Use the ddgs.text() method to perform the search
try: try:
search_results = ddgs.text( search_results = ddgs.text(
query, safesearch="moderate", max_results=count, backend="lite" query, safesearch="moderate", max_results=count, backend=backend
) )
except RatelimitException as e: except RatelimitException as e:
log.error(f"RatelimitException: {e}") log.error(f"RatelimitException: {e}")
@@ -7,17 +7,21 @@ from yarl import URL
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def search_jina(api_key: str, query: str, count: int) -> list[SearchResult]: def search_jina(
api_key: str, query: str, count: int, base_url: str = ""
) -> list[SearchResult]:
""" """
Search using Jina's Search API and return the results as a list of SearchResult objects. Search using Jina's Search API and return the results as a list of SearchResult objects.
Args: Args:
api_key (str): The Jina API key
query (str): The query to search for query (str): The query to search for
count (int): The number of results to return count (int): The number of results to return
base_url (str): Optional custom base URL for the Jina API
Returns: Returns:
list[SearchResult]: A list of search results list[SearchResult]: A list of search results
""" """
jina_search_endpoint = "https://s.jina.ai/" jina_search_endpoint = base_url if base_url else "https://s.jina.ai/"
headers = { headers = {
"Accept": "application/json", "Accept": "application/json",
+10 -2
View File
@@ -36,6 +36,7 @@ from open_webui.config import (
WEB_LOADER_TIMEOUT, WEB_LOADER_TIMEOUT,
FIRECRAWL_API_BASE_URL, FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY, FIRECRAWL_API_KEY,
FIRECRAWL_TIMEOUT,
TAVILY_API_KEY, TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH, TAVILY_EXTRACT_DEPTH,
EXTERNAL_WEB_LOADER_URL, EXTERNAL_WEB_LOADER_URL,
@@ -189,6 +190,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
continue_on_failure: bool = True, continue_on_failure: bool = True,
api_key: Optional[str] = None, api_key: Optional[str] = None,
api_url: Optional[str] = None, api_url: Optional[str] = None,
timeout: Optional[int] = None,
mode: Literal["crawl", "scrape", "map"] = "scrape", mode: Literal["crawl", "scrape", "map"] = "scrape",
proxy: Optional[Dict[str, str]] = None, proxy: Optional[Dict[str, str]] = None,
params: Optional[Dict] = None, params: Optional[Dict] = None,
@@ -231,6 +233,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
self.continue_on_failure = continue_on_failure self.continue_on_failure = continue_on_failure
self.api_key = api_key self.api_key = api_key
self.api_url = api_url self.api_url = api_url
self.timeout = timeout
self.mode = mode self.mode = mode
self.params = params or {} self.params = params or {}
@@ -253,7 +256,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ignore_invalid_urls=True, ignore_invalid_urls=True,
remove_base64_images=True, remove_base64_images=True,
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
wait_timeout=len(self.web_paths) * 3, wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
**self.params, **self.params,
) )
@@ -294,7 +297,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ignore_invalid_urls=True, ignore_invalid_urls=True,
remove_base64_images=True, remove_base64_images=True,
max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values max_age=300000, # 5 minutes https://docs.firecrawl.dev/features/fast-scraping#common-maxage-values
wait_timeout=len(self.web_paths) * 3, wait_timeout=self.timeout if self.timeout else len(self.web_paths) * 3,
**self.params, **self.params,
) )
@@ -697,6 +700,11 @@ def get_web_loader(
WebLoaderClass = SafeFireCrawlLoader WebLoaderClass = SafeFireCrawlLoader
web_loader_args["api_key"] = FIRECRAWL_API_KEY.value web_loader_args["api_key"] = FIRECRAWL_API_KEY.value
web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value
if FIRECRAWL_TIMEOUT.value:
try:
web_loader_args["timeout"] = int(FIRECRAWL_TIMEOUT.value)
except ValueError:
pass
if WEB_LOADER_ENGINE.value == "tavily": if WEB_LOADER_ENGINE.value == "tavily":
WebLoaderClass = SafeTavilyLoader WebLoaderClass = SafeTavilyLoader
+28 -2
View File
@@ -35,12 +35,16 @@ from pydantic import BaseModel
from open_webui.utils.misc import strict_match_mime_type from open_webui.utils.misc import strict_match_mime_type
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
from open_webui.config import ( from open_webui.config import (
WHISPER_MODEL_AUTO_UPDATE, WHISPER_MODEL_AUTO_UPDATE,
WHISPER_COMPUTE_TYPE,
WHISPER_MODEL_DIR, WHISPER_MODEL_DIR,
WHISPER_VAD_FILTER,
CACHE_DIR, CACHE_DIR,
WHISPER_LANGUAGE, WHISPER_LANGUAGE,
WHISPER_MULTILINGUAL,
ELEVENLABS_API_BASE_URL, ELEVENLABS_API_BASE_URL,
) )
@@ -129,7 +133,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
faster_whisper_kwargs = { faster_whisper_kwargs = {
"model_size_or_path": model, "model_size_or_path": model,
"device": DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu", "device": DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu",
"compute_type": "int8", "compute_type": WHISPER_COMPUTE_TYPE,
"download_root": WHISPER_MODEL_DIR, "download_root": WHISPER_MODEL_DIR,
"local_files_only": not auto_update, "local_files_only": not auto_update,
} }
@@ -328,6 +332,20 @@ def load_speech_pipeline(request):
@router.post("/speech") @router.post("/speech")
async def speech(request: Request, user=Depends(get_verified_user)): async def speech(request: Request, user=Depends(get_verified_user)):
if request.app.state.config.TTS_ENGINE == "":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != "admin" and not has_permission(
user.id, "chat.tts", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
body = await request.body() body = await request.body()
name = hashlib.sha256( name = hashlib.sha256(
body body
@@ -585,8 +603,9 @@ def transcription_handler(request, file_path, metadata, user=None):
segments, info = model.transcribe( segments, info = model.transcribe(
file_path, file_path,
beam_size=5, beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER, vad_filter=WHISPER_VAD_FILTER,
language=languages[0], language=languages[0],
multilingual=WHISPER_MULTILINGUAL,
) )
log.info( log.info(
"Detected language '%s' with probability %f" "Detected language '%s' with probability %f"
@@ -1150,6 +1169,13 @@ def transcription(
language: Optional[str] = Form(None), language: Optional[str] = Form(None),
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
if user.role != "admin" and not has_permission(
user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
log.info(f"file.content_type: {file.content_type}") log.info(f"file.content_type: {file.content_type}")
stt_supported_content_types = getattr( stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
+138 -40
View File
@@ -62,6 +62,8 @@ from open_webui.utils.auth import (
get_password_hash, get_password_hash,
get_http_authorization_cred, get_http_authorization_cred,
) )
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
from open_webui.utils.access_control import get_permissions, has_permission from open_webui.utils.access_control import get_permissions, has_permission
from open_webui.utils.groups import apply_default_group_assignment from open_webui.utils.groups import apply_default_group_assignment
@@ -103,7 +105,10 @@ class SessionUserInfoResponse(SessionUserResponse, UserStatus):
@router.get("/", response_model=SessionUserInfoResponse) @router.get("/", response_model=SessionUserInfoResponse)
async def get_session_user( async def get_session_user(
request: Request, response: Response, user=Depends(get_current_user) request: Request,
response: Response,
user=Depends(get_current_user),
db: Session = Depends(get_session),
): ):
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
@@ -137,7 +142,7 @@ async def get_session_user(
) )
user_permissions = get_permissions( user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS user.id, request.app.state.config.USER_PERMISSIONS, db=db
) )
return { return {
@@ -166,12 +171,15 @@ async def get_session_user(
@router.post("/update/profile", response_model=UserProfileImageResponse) @router.post("/update/profile", response_model=UserProfileImageResponse)
async def update_profile( async def update_profile(
form_data: UpdateProfileForm, session_user=Depends(get_verified_user) form_data: UpdateProfileForm,
session_user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if session_user: if session_user:
user = Users.update_user_by_id( user = Users.update_user_by_id(
session_user.id, session_user.id,
form_data.model_dump(), form_data.model_dump(),
db=db,
) )
if user: if user:
return user return user
@@ -181,6 +189,32 @@ async def update_profile(
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################
# Update Timezone
############################
class UpdateTimezoneForm(BaseModel):
timezone: str
@router.post("/update/timezone")
async def update_timezone(
form_data: UpdateTimezoneForm,
session_user=Depends(get_current_user),
db: Session = Depends(get_session),
):
if session_user:
Users.update_user_by_id(
session_user.id,
{"timezone": form_data.timezone},
db=db,
)
return {"status": True}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################ ############################
# Update Password # Update Password
############################ ############################
@@ -188,13 +222,17 @@ async def update_profile(
@router.post("/update/password", response_model=bool) @router.post("/update/password", response_model=bool)
async def update_password( async def update_password(
form_data: UpdatePasswordForm, session_user=Depends(get_current_user) form_data: UpdatePasswordForm,
session_user=Depends(get_current_user),
db: Session = Depends(get_session),
): ):
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
if session_user: if session_user:
user = Auths.authenticate_user( user = Auths.authenticate_user(
session_user.email, lambda pw: verify_password(form_data.password, pw) session_user.email,
lambda pw: verify_password(form_data.password, pw),
db=db,
) )
if user: if user:
@@ -203,7 +241,7 @@ async def update_password(
except Exception as e: except Exception as e:
raise HTTPException(400, detail=str(e)) raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.new_password) hashed = get_password_hash(form_data.new_password)
return Auths.update_user_password_by_id(user.id, hashed) return Auths.update_user_password_by_id(user.id, hashed, db=db)
else: else:
raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD) raise HTTPException(400, detail=ERROR_MESSAGES.INCORRECT_PASSWORD)
else: else:
@@ -214,7 +252,12 @@ async def update_password(
# LDAP Authentication # LDAP Authentication
############################ ############################
@router.post("/ldap", response_model=SessionUserResponse) @router.post("/ldap", response_model=SessionUserResponse)
async def ldap_auth(request: Request, response: Response, form_data: LdapForm): async def ldap_auth(
request: Request,
response: Response,
form_data: LdapForm,
db: Session = Depends(get_session),
):
# Security checks FIRST - before loading any config # Security checks FIRST - before loading any config
if not request.app.state.config.ENABLE_LDAP: if not request.app.state.config.ENABLE_LDAP:
raise HTTPException(400, detail="LDAP authentication is not enabled") raise HTTPException(400, detail="LDAP authentication is not enabled")
@@ -400,12 +443,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
if not connection_user.bind(): if not connection_user.bind():
raise HTTPException(400, "Authentication failed.") raise HTTPException(400, "Authentication failed.")
user = Users.get_user_by_email(email) user = Users.get_user_by_email(email, db=db)
if not user: if not user:
try: try:
role = ( role = (
"admin" "admin"
if not Users.has_users() if not Users.has_users(db=db)
else request.app.state.config.DEFAULT_USER_ROLE else request.app.state.config.DEFAULT_USER_ROLE
) )
@@ -414,6 +457,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
password=str(uuid.uuid4()), password=str(uuid.uuid4()),
name=cn, name=cn,
role=role, role=role,
db=db,
) )
if not user: if not user:
@@ -424,6 +468,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
apply_default_group_assignment( apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID, request.app.state.config.DEFAULT_GROUP_ID,
user.id, user.id,
db=db,
) )
except HTTPException: except HTTPException:
@@ -434,7 +479,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
500, detail="Internal error occurred during LDAP user creation." 500, detail="Internal error occurred during LDAP user creation."
) )
user = Auths.authenticate_user_by_email(email) user = Auths.authenticate_user_by_email(email, db=db)
if user: if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN) expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
@@ -464,7 +509,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
) )
user_permissions = get_permissions( user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS user.id, request.app.state.config.USER_PERMISSIONS, db=db
) )
if ( if (
@@ -473,9 +518,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
and user_groups and user_groups
): ):
if ENABLE_LDAP_GROUP_CREATION: if ENABLE_LDAP_GROUP_CREATION:
Groups.create_groups_by_group_names(user.id, user_groups) Groups.create_groups_by_group_names(user.id, user_groups, db=db)
try: try:
Groups.sync_groups_by_group_names(user.id, user_groups) Groups.sync_groups_by_group_names(user.id, user_groups, db=db)
log.info( log.info(
f"Successfully synced groups for user {user.id}: {user_groups}" f"Successfully synced groups for user {user.id}: {user_groups}"
) )
@@ -508,7 +553,12 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
@router.post("/signin", response_model=SessionUserResponse) @router.post("/signin", response_model=SessionUserResponse)
async def signin(request: Request, response: Response, form_data: SigninForm): async def signin(
request: Request,
response: Response,
form_data: SigninForm,
db: Session = Depends(get_session),
):
if not ENABLE_PASSWORD_AUTH: if not ENABLE_PASSWORD_AUTH:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -529,14 +579,15 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
except Exception as e: except Exception as e:
pass pass
if not Users.get_user_by_email(email.lower()): if not Users.get_user_by_email(email.lower(), db=db):
await signup( await signup(
request, request,
response, response,
SignupForm(email=email, password=str(uuid.uuid4()), name=name), SignupForm(email=email, password=str(uuid.uuid4()), name=name),
db=db,
) )
user = Auths.authenticate_user_by_email(email) user = Auths.authenticate_user_by_email(email, db=db)
if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin": if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin":
group_names = request.headers.get( group_names = request.headers.get(
WEBUI_AUTH_TRUSTED_GROUPS_HEADER, "" WEBUI_AUTH_TRUSTED_GROUPS_HEADER, ""
@@ -544,28 +595,33 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
group_names = [name.strip() for name in group_names if name.strip()] group_names = [name.strip() for name in group_names if name.strip()]
if group_names: if group_names:
Groups.sync_groups_by_group_names(user.id, group_names) Groups.sync_groups_by_group_names(user.id, group_names, db=db)
elif WEBUI_AUTH == False: elif WEBUI_AUTH == False:
admin_email = "admin@localhost" admin_email = "admin@localhost"
admin_password = "admin" admin_password = "admin"
if Users.get_user_by_email(admin_email.lower()): if Users.get_user_by_email(admin_email.lower(), db=db):
user = Auths.authenticate_user( user = Auths.authenticate_user(
admin_email.lower(), lambda pw: verify_password(admin_password, pw) admin_email.lower(),
lambda pw: verify_password(admin_password, pw),
db=db,
) )
else: else:
if Users.has_users(): if Users.has_users(db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS) raise HTTPException(400, detail=ERROR_MESSAGES.EXISTING_USERS)
await signup( await signup(
request, request,
response, response,
SignupForm(email=admin_email, password=admin_password, name="User"), SignupForm(email=admin_email, password=admin_password, name="User"),
db=db,
) )
user = Auths.authenticate_user( user = Auths.authenticate_user(
admin_email.lower(), lambda pw: verify_password(admin_password, pw) admin_email.lower(),
lambda pw: verify_password(admin_password, pw),
db=db,
) )
else: else:
if signin_rate_limiter.is_limited(form_data.email.lower()): if signin_rate_limiter.is_limited(form_data.email.lower()):
@@ -584,7 +640,9 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
form_data.password = password_bytes.decode("utf-8", errors="ignore") form_data.password = password_bytes.decode("utf-8", errors="ignore")
user = Auths.authenticate_user( user = Auths.authenticate_user(
form_data.email.lower(), lambda pw: verify_password(form_data.password, pw) form_data.email.lower(),
lambda pw: verify_password(form_data.password, pw),
db=db,
) )
if user: if user:
@@ -616,7 +674,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
) )
user_permissions = get_permissions( user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS user.id, request.app.state.config.USER_PERMISSIONS, db=db
) )
return { return {
@@ -640,8 +698,13 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
@router.post("/signup", response_model=SessionUserResponse) @router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm): async def signup(
has_users = Users.has_users() request: Request,
response: Response,
form_data: SignupForm,
db: Session = Depends(get_session),
):
has_users = Users.has_users(db=db)
if WEBUI_AUTH: if WEBUI_AUTH:
if ( if (
@@ -663,7 +726,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
) )
if Users.get_user_by_email(form_data.email.lower()): if Users.get_user_by_email(form_data.email.lower(), db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try: try:
@@ -681,6 +744,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
form_data.name, form_data.name,
form_data.profile_image_url, form_data.profile_image_url,
role, role,
db=db,
) )
if user: if user:
@@ -723,7 +787,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
) )
user_permissions = get_permissions( user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS user.id, request.app.state.config.USER_PERMISSIONS, db=db
) )
if not has_users: if not has_users:
@@ -733,6 +797,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
apply_default_group_assignment( apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID, request.app.state.config.DEFAULT_GROUP_ID,
user.id, user.id,
db=db,
) )
return { return {
@@ -754,7 +819,9 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
@router.get("/signout") @router.get("/signout")
async def signout(request: Request, response: Response): async def signout(
request: Request, response: Response, db: Session = Depends(get_session)
):
# get auth token from headers or cookies # get auth token from headers or cookies
token = None token = None
@@ -776,7 +843,7 @@ async def signout(request: Request, response: Response):
if oauth_session_id: if oauth_session_id:
response.delete_cookie("oauth_session_id") response.delete_cookie("oauth_session_id")
session = OAuthSessions.get_session_by_id(oauth_session_id) session = OAuthSessions.get_session_by_id(oauth_session_id, db=db)
oauth_server_metadata_url = ( oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider) request.app.state.oauth_manager.get_server_metadata_url(session.provider)
if session if session
@@ -839,14 +906,17 @@ async def signout(request: Request, response: Response):
@router.post("/add", response_model=SigninResponse) @router.post("/add", response_model=SigninResponse)
async def add_user( async def add_user(
request: Request, form_data: AddUserForm, user=Depends(get_admin_user) request: Request,
form_data: AddUserForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
if not validate_email_format(form_data.email.lower()): if not validate_email_format(form_data.email.lower()):
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
) )
if Users.get_user_by_email(form_data.email.lower()): if Users.get_user_by_email(form_data.email.lower(), db=db):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
try: try:
@@ -862,12 +932,14 @@ async def add_user(
form_data.name, form_data.name,
form_data.profile_image_url, form_data.profile_image_url,
form_data.role, form_data.role,
db=db,
) )
if user: if user:
apply_default_group_assignment( apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID, request.app.state.config.DEFAULT_GROUP_ID,
user.id, user.id,
db=db,
) )
token = create_token(data={"id": user.id}) token = create_token(data={"id": user.id})
@@ -895,7 +967,9 @@ async def add_user(
@router.get("/admin/details") @router.get("/admin/details")
async def get_admin_details(request: Request, user=Depends(get_current_user)): async def get_admin_details(
request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)
):
if request.app.state.config.SHOW_ADMIN_DETAILS: if request.app.state.config.SHOW_ADMIN_DETAILS:
admin_email = request.app.state.config.ADMIN_EMAIL admin_email = request.app.state.config.ADMIN_EMAIL
admin_name = None admin_name = None
@@ -903,11 +977,11 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}") log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}")
if admin_email: if admin_email:
admin = Users.get_user_by_email(admin_email) admin = Users.get_user_by_email(admin_email, db=db)
if admin: if admin:
admin_name = admin.name admin_name = admin.name
else: else:
admin = Users.get_first_user() admin = Users.get_first_user(db=db)
if admin: if admin:
admin_email = admin.email admin_email = admin.email
admin_name = admin.name admin_name = admin.name
@@ -929,6 +1003,7 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
async def get_admin_config(request: Request, user=Depends(get_admin_user)): async def get_admin_config(request: Request, user=Depends(get_admin_user)):
return { return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL,
"WEBUI_URL": request.app.state.config.WEBUI_URL, "WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
@@ -940,9 +1015,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
@@ -951,6 +1029,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
class AdminConfig(BaseModel): class AdminConfig(BaseModel):
SHOW_ADMIN_DETAILS: bool SHOW_ADMIN_DETAILS: bool
ADMIN_EMAIL: Optional[str] = None
WEBUI_URL: str WEBUI_URL: str
ENABLE_SIGNUP: bool ENABLE_SIGNUP: bool
ENABLE_API_KEYS: bool ENABLE_API_KEYS: bool
@@ -962,9 +1041,12 @@ class AdminConfig(BaseModel):
ENABLE_COMMUNITY_SHARING: bool ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool ENABLE_MESSAGE_RATING: bool
ENABLE_FOLDERS: bool ENABLE_FOLDERS: bool
FOLDER_MAX_FILE_COUNT: Optional[int | str] = None
ENABLE_CHANNELS: bool ENABLE_CHANNELS: bool
ENABLE_MEMORIES: bool
ENABLE_NOTES: bool ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool ENABLE_USER_WEBHOOKS: bool
ENABLE_USER_STATUS: bool
PENDING_USER_OVERLAY_TITLE: Optional[str] = None PENDING_USER_OVERLAY_TITLE: Optional[str] = None
PENDING_USER_OVERLAY_CONTENT: Optional[str] = None PENDING_USER_OVERLAY_CONTENT: Optional[str] = None
RESPONSE_WATERMARK: Optional[str] = None RESPONSE_WATERMARK: Optional[str] = None
@@ -975,6 +1057,7 @@ async def update_admin_config(
request: Request, form_data: AdminConfig, user=Depends(get_admin_user) request: Request, form_data: AdminConfig, user=Depends(get_admin_user)
): ):
request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL
request.app.state.config.WEBUI_URL = form_data.WEBUI_URL request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
@@ -987,7 +1070,11 @@ async def update_admin_config(
) )
request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS
request.app.state.config.FOLDER_MAX_FILE_COUNT = (
int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else ""
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]: if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
@@ -1007,6 +1094,7 @@ async def update_admin_config(
request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING
request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS
request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS
request.app.state.config.PENDING_USER_OVERLAY_TITLE = ( request.app.state.config.PENDING_USER_OVERLAY_TITLE = (
form_data.PENDING_USER_OVERLAY_TITLE form_data.PENDING_USER_OVERLAY_TITLE
@@ -1019,6 +1107,7 @@ async def update_admin_config(
return { return {
"SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
"ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL,
"WEBUI_URL": request.app.state.config.WEBUI_URL, "WEBUI_URL": request.app.state.config.WEBUI_URL,
"ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
"ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS,
@@ -1030,9 +1119,12 @@ async def update_admin_config(
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS,
"FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
"ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS,
"PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE,
"PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT,
"RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK,
@@ -1149,7 +1241,9 @@ async def update_ldap_config(
# create api key # create api key
@router.post("/api_key", response_model=ApiKey) @router.post("/api_key", response_model=ApiKey)
async def generate_api_key(request: Request, user=Depends(get_current_user)): async def generate_api_key(
request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)
):
if not request.app.state.config.ENABLE_API_KEYS or not has_permission( if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS
): ):
@@ -1159,7 +1253,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
) )
api_key = create_api_key() api_key = create_api_key()
success = Users.update_user_api_key_by_id(user.id, api_key) success = Users.update_user_api_key_by_id(user.id, api_key, db=db)
if success: if success:
return { return {
@@ -1171,14 +1265,18 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)):
# delete api key # delete api key
@router.delete("/api_key", response_model=bool) @router.delete("/api_key", response_model=bool)
async def delete_api_key(user=Depends(get_current_user)): async def delete_api_key(
return Users.delete_user_api_key_by_id(user.id) user=Depends(get_current_user), db: Session = Depends(get_session)
):
return Users.delete_user_api_key_by_id(user.id, db=db)
# get api key # get api key
@router.get("/api_key", response_model=ApiKey) @router.get("/api_key", response_model=ApiKey)
async def get_api_key(user=Depends(get_current_user)): async def get_api_key(
api_key = Users.get_user_api_key_by_id(user.id) user=Depends(get_current_user), db: Session = Depends(get_session)
):
api_key = Users.get_user_api_key_by_id(user.id, db=db)
if api_key: if api_key:
return { return {
"api_key": api_key, "api_key": api_key,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -6,6 +6,7 @@ import aiohttp
from typing import Optional from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config from open_webui.config import get_config, save_config
from open_webui.config import BannerModel from open_webui.config import BannerModel
@@ -228,7 +229,10 @@ async def verify_tool_servers_config(
log.debug( log.debug(
f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}" f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
) )
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
async with session.get( async with session.get(
discovery_url discovery_url
) as oauth_server_metadata_response: ) as oauth_server_metadata_response:
+305 -24
View File
@@ -1,5 +1,7 @@
from typing import Optional from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.models.users import Users, UserModel from open_webui.models.users import Users, UserModel
@@ -10,15 +12,268 @@ from open_webui.models.feedbacks import (
FeedbackForm, FeedbackForm,
FeedbackUserResponse, FeedbackUserResponse,
FeedbackListResponse, FeedbackListResponse,
LeaderboardFeedbackData,
ModelHistoryEntry,
ModelHistoryResponse,
Feedbacks, Feedbacks,
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Leaderboard Elo Rating Computation
#
# How it works:
# 1. Each model starts with a rating of 1000
# 2. When a user picks a winner between two models, ratings are adjusted:
# - Winner gains points, loser loses points
# - The amount depends on expected outcome (upset = bigger change)
# 3. The Elo formula: new_rating = old_rating + K * (actual - expected)
# - K=32 controls how much ratings can change per match
# - expected = probability of winning based on current ratings
#
# Query-based re-ranking (optional):
# When a user searches for a topic (e.g., "coding"), we want to show
# which models perform best FOR THAT TOPIC. We do this by:
# 1. Computing semantic similarity between the query and each feedback's tags
# 2. Using that similarity as a weight in the Elo calculation
# 3. Feedbacks about "coding" contribute more to the final ranking
# 4. Feedbacks about unrelated topics (e.g., "cooking") contribute less
# This gives topic-specific leaderboards without needing separate data.
import os
EMBEDDING_MODEL_NAME = os.environ.get(
"AUXILIARY_EMBEDDING_MODEL", "TaylorAI/bge-micro-v2"
)
_embedding_model = None
def _get_embedding_model():
global _embedding_model
if _embedding_model is None:
try:
from sentence_transformers import SentenceTransformer
_embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
except Exception as e:
log.error(f"Embedding model load failed: {e}")
return _embedding_model
def _calculate_elo(
feedbacks: list[LeaderboardFeedbackData], similarities: dict = None
) -> dict:
"""
Calculate Elo ratings for models based on user feedback.
Each feedback represents a comparison where a user rated one model
against its opponents (sibling_model_ids). Rating=1 means the model won,
rating=-1 means it lost.
The Elo system adjusts ratings based on:
- Current rating difference (upsets cause bigger swings)
- Optional similarity weights (for query-based filtering)
Returns: {model_id: {"rating": float, "won": int, "lost": int}}
"""
K_FACTOR = 32 # Standard Elo K-factor for rating volatility
model_stats = {}
def get_or_create_stats(model_id):
if model_id not in model_stats:
model_stats[model_id] = {"rating": 1000.0, "won": 0, "lost": 0}
return model_stats[model_id]
for feedback in feedbacks:
data = feedback.data or {}
winner_id = data.get("model_id")
rating_value = str(data.get("rating", ""))
if not winner_id or rating_value not in ("1", "-1"):
continue
won = rating_value == "1"
weight = similarities.get(feedback.id, 1.0) if similarities else 1.0
for opponent_id in data.get("sibling_model_ids") or []:
winner = get_or_create_stats(winner_id)
opponent = get_or_create_stats(opponent_id)
expected = 1 / (1 + 10 ** ((opponent["rating"] - winner["rating"]) / 400))
winner["rating"] += K_FACTOR * ((1 if won else 0) - expected) * weight
opponent["rating"] += (
K_FACTOR * ((0 if won else 1) - (1 - expected)) * weight
)
if won:
winner["won"] += 1
opponent["lost"] += 1
else:
winner["lost"] += 1
opponent["won"] += 1
return model_stats
def _get_top_tags(feedbacks: list[LeaderboardFeedbackData], limit: int = 5) -> dict:
"""
Count tag occurrences per model and return the most frequent ones.
Each feedback can have tags describing the conversation topic.
This aggregates those tags per model to show what topics each model
is commonly used for.
Returns: {model_id: [{"tag": str, "count": int}, ...]}
"""
from collections import defaultdict
tag_counts = defaultdict(lambda: defaultdict(int))
for feedback in feedbacks:
data = feedback.data or {}
model_id = data.get("model_id")
if model_id:
for tag in data.get("tags", []):
tag_counts[model_id][tag] += 1
return {
model_id: [
{"tag": tag, "count": count}
for tag, count in sorted(tags.items(), key=lambda x: -x[1])[:limit]
]
for model_id, tags in tag_counts.items()
}
def _compute_similarities(feedbacks: list[LeaderboardFeedbackData], query: str) -> dict:
"""
Compute how relevant each feedback is to a search query.
Uses embeddings to find semantic similarity between the query and
each feedback's tags. Higher similarity means the feedback is more
relevant to what the user searched for.
This is used to weight Elo calculations - feedbacks matching the
query have more influence on the final rankings.
Returns: {feedback_id: similarity_score (0-1)}
"""
import numpy as np
embedding_model = _get_embedding_model()
if not embedding_model:
return {}
all_tags = list(
{
tag
for feedback in feedbacks
if feedback.data
for tag in feedback.data.get("tags", [])
}
)
if not all_tags:
return {}
try:
tag_embeddings = embedding_model.encode(all_tags)
query_embedding = embedding_model.encode([query])[0]
except Exception as e:
log.error(f"Embedding error: {e}")
return {}
# Vectorized cosine similarity
tag_norms = np.linalg.norm(tag_embeddings, axis=1)
query_norm = np.linalg.norm(query_embedding)
similarities = np.dot(tag_embeddings, query_embedding) / (
tag_norms * query_norm + 1e-9
)
tag_similarity_map = dict(zip(all_tags, similarities.tolist()))
return {
feedback.id: max(
(
tag_similarity_map.get(tag, 0)
for tag in (feedback.data or {}).get("tags", [])
),
default=0,
)
for feedback in feedbacks
}
class LeaderboardEntry(BaseModel):
model_id: str
rating: int
won: int
lost: int
count: int
top_tags: list[dict]
class LeaderboardResponse(BaseModel):
entries: list[LeaderboardEntry]
@router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard(
query: Optional[str] = None,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get model leaderboard with Elo ratings. Query filters by tag similarity."""
feedbacks = Feedbacks.get_feedbacks_for_leaderboard(db=db)
similarities = None
if query and query.strip():
similarities = await run_in_threadpool(
_compute_similarities, feedbacks, query.strip()
)
elo_stats = _calculate_elo(feedbacks, similarities)
tags_by_model = _get_top_tags(feedbacks)
entries = sorted(
[
LeaderboardEntry(
model_id=mid,
rating=round(s["rating"]),
won=s["won"],
lost=s["lost"],
count=s["won"] + s["lost"],
top_tags=tags_by_model.get(mid, []),
)
for mid, s in elo_stats.items()
],
key=lambda e: e.rating,
reverse=True,
)
return LeaderboardResponse(entries=entries)
@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse)
async def get_model_history(
model_id: str,
days: int = 30,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get daily win/loss history for a specific model."""
history = Feedbacks.get_model_evaluation_history(
model_id=model_id, days=days, db=db
)
return ModelHistoryResponse(model_id=model_id, history=history)
############################ ############################
# GetConfig # GetConfig
############################ ############################
@@ -60,38 +315,49 @@ async def update_config(
@router.get("/feedbacks/all", response_model=list[FeedbackResponse]) @router.get("/feedbacks/all", response_model=list[FeedbackResponse])
async def get_all_feedbacks(user=Depends(get_admin_user)): async def get_all_feedbacks(
feedbacks = Feedbacks.get_all_feedbacks() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
return feedbacks return feedbacks
@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse]) @router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse])
async def get_all_feedback_ids(user=Depends(get_admin_user)): async def get_all_feedback_ids(
feedbacks = Feedbacks.get_all_feedbacks() user=Depends(get_admin_user), db: Session = Depends(get_session)
return feedbacks ):
return Feedbacks.get_all_feedback_ids(db=db)
@router.delete("/feedbacks/all") @router.delete("/feedbacks/all")
async def delete_all_feedbacks(user=Depends(get_admin_user)): async def delete_all_feedbacks(
success = Feedbacks.delete_all_feedbacks() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
success = Feedbacks.delete_all_feedbacks(db=db)
return success return success
@router.get("/feedbacks/all/export", response_model=list[FeedbackModel]) @router.get("/feedbacks/all/export", response_model=list[FeedbackModel])
async def export_all_feedbacks(user=Depends(get_admin_user)): async def export_all_feedbacks(
feedbacks = Feedbacks.get_all_feedbacks() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
return feedbacks return feedbacks
@router.get("/feedbacks/user", response_model=list[FeedbackUserResponse]) @router.get("/feedbacks/user", response_model=list[FeedbackUserResponse])
async def get_feedbacks(user=Depends(get_verified_user)): async def get_feedbacks(
feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id) user=Depends(get_verified_user), db: Session = Depends(get_session)
):
feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id, db=db)
return feedbacks return feedbacks
@router.delete("/feedbacks", response_model=bool) @router.delete("/feedbacks", response_model=bool)
async def delete_feedbacks(user=Depends(get_verified_user)): async def delete_feedbacks(
success = Feedbacks.delete_feedbacks_by_user_id(user.id) user=Depends(get_verified_user), db: Session = Depends(get_session)
):
success = Feedbacks.delete_feedbacks_by_user_id(user.id, db=db)
return success return success
@@ -104,6 +370,7 @@ async def get_feedbacks(
direction: Optional[str] = None, direction: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_admin_user), user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
@@ -116,7 +383,7 @@ async def get_feedbacks(
if direction: if direction:
filter["direction"] = direction filter["direction"] = direction
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit) result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db)
return result return result
@@ -125,8 +392,11 @@ async def create_feedback(
request: Request, request: Request,
form_data: FeedbackForm, form_data: FeedbackForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
feedback = Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data) feedback = Feedbacks.insert_new_feedback(
user_id=user.id, form_data=form_data, db=db
)
if not feedback: if not feedback:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -137,11 +407,15 @@ async def create_feedback(
@router.get("/feedback/{id}", response_model=FeedbackModel) @router.get("/feedback/{id}", response_model=FeedbackModel)
async def get_feedback_by_id(id: str, user=Depends(get_verified_user)): async def get_feedback_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin": if user.role == "admin":
feedback = Feedbacks.get_feedback_by_id(id=id) feedback = Feedbacks.get_feedback_by_id(id=id, db=db)
else: else:
feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id) feedback = Feedbacks.get_feedback_by_id_and_user_id(
id=id, user_id=user.id, db=db
)
if not feedback: if not feedback:
raise HTTPException( raise HTTPException(
@@ -153,13 +427,16 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/feedback/{id}", response_model=FeedbackModel) @router.post("/feedback/{id}", response_model=FeedbackModel)
async def update_feedback_by_id( async def update_feedback_by_id(
id: str, form_data: FeedbackForm, user=Depends(get_verified_user) id: str,
form_data: FeedbackForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role == "admin": if user.role == "admin":
feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data) feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db)
else: else:
feedback = Feedbacks.update_feedback_by_id_and_user_id( feedback = Feedbacks.update_feedback_by_id_and_user_id(
id=id, user_id=user.id, form_data=form_data id=id, user_id=user.id, form_data=form_data, db=db
) )
if not feedback: if not feedback:
@@ -171,11 +448,15 @@ async def update_feedback_by_id(
@router.delete("/feedback/{id}") @router.delete("/feedback/{id}")
async def delete_feedback_by_id(id: str, user=Depends(get_verified_user)): async def delete_feedback_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin": if user.role == "admin":
success = Feedbacks.delete_feedback_by_id(id=id) success = Feedbacks.delete_feedback_by_id(id=id, db=db)
else: else:
success = Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id) success = Feedbacks.delete_feedback_by_id_and_user_id(
id=id, user_id=user.id, db=db
)
if not success: if not success:
raise HTTPException( raise HTTPException(
+190 -110
View File
@@ -2,7 +2,6 @@ import logging
import os import os
import uuid import uuid
import json import json
from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote
@@ -22,6 +21,8 @@ from fastapi import (
) )
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session, SessionLocal
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@@ -62,9 +63,12 @@ router = APIRouter()
# TODO: Optimize this function to use the knowledge_file table for faster lookups. # TODO: Optimize this function to use the knowledge_file table for faster lookups.
def has_access_to_file( def has_access_to_file(
file_id: Optional[str], access_type: str, user=Depends(get_verified_user) file_id: Optional[str],
access_type: str,
user=Depends(get_verified_user),
db: Optional[Session] = None,
) -> bool: ) -> bool:
file = Files.get_file_by_id(file_id) file = Files.get_file_by_id(file_id, db=db)
log.debug(f"Checking if user has {access_type} access to file") log.debug(f"Checking if user has {access_type} access to file")
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -73,31 +77,33 @@ def has_access_to_file(
) )
# Check if the file is associated with any knowledge bases the user has access to # Check if the file is associated with any knowledge bases the user has access to
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id) knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for knowledge_base in knowledge_bases: for knowledge_base in knowledge_bases:
if knowledge_base.user_id == user.id or has_access( if knowledge_base.user_id == user.id or has_access(
user.id, access_type, knowledge_base.access_control, user_group_ids user.id, access_type, knowledge_base.access_control, user_group_ids, db=db
): ):
return True return True
knowledge_base_id = file.meta.get("collection_name") if file.meta else None knowledge_base_id = file.meta.get("collection_name") if file.meta else None
if knowledge_base_id: if knowledge_base_id:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
user.id, access_type user.id, access_type, db=db
) )
for knowledge_base in knowledge_bases: for knowledge_base in knowledge_bases:
if knowledge_base.id == knowledge_base_id: if knowledge_base.id == knowledge_base_id:
return True return True
# Check if the file is associated with any channels the user has access to # Check if the file is associated with any channels the user has access to
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id) channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
if access_type == "read" and channels: if access_type == "read" and channels:
return True return True
# Check if the file is associated with any chats the user has access to # Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats # TODO: Granular access control for chats
chats = Chats.get_shared_chats_by_file_id(file_id) chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats: if chats:
return True return True
@@ -109,47 +115,78 @@ def has_access_to_file(
############################ ############################
def process_uploaded_file(request, file, file_path, file_item, file_metadata, user): def process_uploaded_file(
try: request,
if file.content_type: file,
stt_supported_content_types = getattr( file_path,
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] file_item,
) file_metadata,
user,
db: Optional[Session] = None,
):
def _process_handler(db_session):
try:
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
if strict_match_mime_type(stt_supported_content_types, file.content_type): if strict_match_mime_type(
file_path = Storage.get_file(file_path) stt_supported_content_types, file.content_type
result = transcribe(request, file_path, file_metadata, user) ):
file_path_processed = Storage.get_file(file_path)
result = transcribe(
request, file_path_processed, file_metadata, user
)
process_file(
request,
ProcessFileForm(
file_id=file_item.id, content=result.get("text", "")
),
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(
request,
ProcessFileForm(file_id=file_item.id),
user=user,
db=db_session,
)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file( process_file(
request, request,
ProcessFileForm( ProcessFileForm(file_id=file_item.id),
file_id=file_item.id, content=result.get("text", "")
),
user=user, user=user,
db=db_session,
) )
elif (not file.content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
)
else:
log.info(
f"File type {file.content_type} is not provided, but trying to process anyway"
)
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
except Exception as e: except Exception as e:
log.error(f"Error processing file: {file_item.id}") log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id( Files.update_file_data_by_id(
file_item.id, file_item.id,
{ {
"status": "failed", "status": "failed",
"error": str(e.detail) if hasattr(e, "detail") else str(e), "error": str(e.detail) if hasattr(e, "detail") else str(e),
}, },
) db=db_session,
)
if db:
_process_handler(db)
else:
with SessionLocal() as db_session:
_process_handler(db_session)
@router.post("/", response_model=FileModelResponse) @router.post("/", response_model=FileModelResponse)
@@ -161,6 +198,7 @@ def upload_file(
process: bool = Query(True), process: bool = Query(True),
process_in_background: bool = Query(True), process_in_background: bool = Query(True),
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
return upload_file_handler( return upload_file_handler(
request, request,
@@ -170,6 +208,7 @@ def upload_file(
process_in_background=process_in_background, process_in_background=process_in_background,
user=user, user=user,
background_tasks=background_tasks, background_tasks=background_tasks,
db=db,
) )
@@ -181,6 +220,7 @@ def upload_file_handler(
process_in_background: bool = Query(True), process_in_background: bool = Query(True),
user=Depends(get_verified_user), user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None, background_tasks: Optional[BackgroundTasks] = None,
db: Optional[Session] = None,
): ):
log.info(f"file.content_type: {file.content_type} {process}") log.info(f"file.content_type: {file.content_type} {process}")
@@ -248,14 +288,17 @@ def upload_file_handler(
}, },
} }
), ),
db=db,
) )
if "channel_id" in file_metadata: if "channel_id" in file_metadata:
channel = Channels.get_channel_by_id_and_user_id( channel = Channels.get_channel_by_id_and_user_id(
file_metadata["channel_id"], user.id file_metadata["channel_id"], user.id, db=db
) )
if channel: if channel:
Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id) Channels.add_file_to_channel_by_id(
channel.id, file_item.id, user.id, db=db
)
if process: if process:
if background_tasks and process_in_background: if background_tasks and process_in_background:
@@ -277,6 +320,7 @@ def upload_file_handler(
file_item, file_item,
file_metadata, file_metadata,
user, user,
db=db,
) )
return {"status": True, **file_item.model_dump()} return {"status": True, **file_item.model_dump()}
else: else:
@@ -302,11 +346,15 @@ def upload_file_handler(
@router.get("/", response_model=list[FileModelResponse]) @router.get("/", response_model=list[FileModelResponse])
async def list_files(user=Depends(get_verified_user), content: bool = Query(True)): async def list_files(
user=Depends(get_verified_user),
content: bool = Query(True),
db: Session = Depends(get_session),
):
if user.role == "admin": if user.role == "admin":
files = Files.get_files() files = Files.get_files(db=db)
else: else:
files = Files.get_files_by_user_id(user.id) files = Files.get_files_by_user_id(user.id, db=db)
if not content: if not content:
for file in files: for file in files:
@@ -328,34 +376,41 @@ async def search_files(
description="Filename pattern to search for. Supports wildcards such as '*.txt'", description="Filename pattern to search for. Supports wildcards such as '*.txt'",
), ),
content: bool = Query(True), content: bool = Query(True),
skip: int = Query(0, ge=0, description="Number of files to skip"),
limit: int = Query(
100, ge=1, le=1000, description="Maximum number of files to return"
),
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
""" """
Search for files by filename with support for wildcard patterns. Search for files by filename with support for wildcard patterns.
Uses SQL-based filtering with pagination for better performance.
""" """
# Get files according to user role # Determine user_id: null for admin (search all), user.id for regular users
if user.role == "admin": user_id = None if user.role == "admin" else user.id
files = Files.get_files()
else:
files = Files.get_files_by_user_id(user.id)
# Get matching files # Use optimized database query with pagination
matching_files = [ files = Files.search_files(
file for file in files if fnmatch(file.filename.lower(), filename.lower()) user_id=user_id,
] filename=filename,
skip=skip,
limit=limit,
db=db,
)
if not matching_files: if not files:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="No files found matching the pattern.", detail="No files found matching the pattern.",
) )
if not content: if not content:
for file in matching_files: for file in files:
if "content" in file.data: if file.data and "content" in file.data:
del file.data["content"] del file.data["content"]
return matching_files return files
############################ ############################
@@ -364,8 +419,10 @@ async def search_files(
@router.delete("/all") @router.delete("/all")
async def delete_all_files(user=Depends(get_admin_user)): async def delete_all_files(
result = Files.delete_all_files() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
result = Files.delete_all_files(db=db)
if result: if result:
try: try:
Storage.delete_all_files() Storage.delete_all_files()
@@ -391,8 +448,10 @@ async def delete_all_files(user=Depends(get_admin_user)):
@router.get("/{id}", response_model=Optional[FileModel]) @router.get("/{id}", response_model=Optional[FileModel])
async def get_file_by_id(id: str, user=Depends(get_verified_user)): async def get_file_by_id(
file = Files.get_file_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -403,7 +462,7 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "read", user) or has_access_to_file(id, "read", user, db=db)
): ):
return file return file
else: else:
@@ -415,9 +474,12 @@ async def get_file_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/process/status") @router.get("/{id}/process/status")
async def get_file_process_status( async def get_file_process_status(
id: str, stream: bool = Query(False), user=Depends(get_verified_user) id: str,
stream: bool = Query(False),
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -428,37 +490,40 @@ async def get_file_process_status(
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "read", user) or has_access_to_file(id, "read", user, db=db)
): ):
if stream: if stream:
MAX_FILE_PROCESSING_DURATION = 3600 * 2 MAX_FILE_PROCESSING_DURATION = 3600 * 2
async def event_stream(file_item): async def event_stream(file_id):
if file_item: # NOTE: We intentionally do NOT capture the request's db session here.
for _ in range(MAX_FILE_PROCESSING_DURATION): # Each poll creates its own short-lived session to avoid holding a
file_item = Files.get_file_by_id(file_item.id) # connection for hours. A WebSocket push would be more efficient.
if file_item: for _ in range(MAX_FILE_PROCESSING_DURATION):
data = file_item.model_dump().get("data", {}) file_item = Files.get_file_by_id(file_id) # Creates own session
status = data.get("status") if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
if status: if status:
event = {"status": status} event = {"status": status}
if status == "failed": if status == "failed":
event["error"] = data.get("error") event["error"] = data.get("error")
yield f"data: {json.dumps(event)}\n\n" yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"): if status in ("completed", "failed"):
break
else:
# Legacy
break break
else:
# Legacy
break
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
break
await asyncio.sleep(0.5) await asyncio.sleep(1)
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
return StreamingResponse( return StreamingResponse(
event_stream(file), event_stream(file.id),
media_type="text/event-stream", media_type="text/event-stream",
) )
else: else:
@@ -476,8 +541,10 @@ async def get_file_process_status(
@router.get("/{id}/data/content") @router.get("/{id}/data/content")
async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_data_content_by_id(
file = Files.get_file_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -488,7 +555,7 @@ async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "read", user) or has_access_to_file(id, "read", user, db=db)
): ):
return {"content": file.data.get("content", "")} return {"content": file.data.get("content", "")}
else: else:
@@ -509,9 +576,13 @@ class ContentForm(BaseModel):
@router.post("/{id}/data/content/update") @router.post("/{id}/data/content/update")
async def update_file_data_content_by_id( async def update_file_data_content_by_id(
request: Request, id: str, form_data: ContentForm, user=Depends(get_verified_user) request: Request,
id: str,
form_data: ContentForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -522,7 +593,7 @@ async def update_file_data_content_by_id(
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "write", user) or has_access_to_file(id, "write", user, db=db)
): ):
try: try:
process_file( process_file(
@@ -530,7 +601,7 @@ async def update_file_data_content_by_id(
ProcessFileForm(file_id=id, content=form_data.content), ProcessFileForm(file_id=id, content=form_data.content),
user=user, user=user,
) )
file = Files.get_file_by_id(id=id) file = Files.get_file_by_id(id=id, db=db)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
log.error(f"Error processing file: {file.id}") log.error(f"Error processing file: {file.id}")
@@ -550,9 +621,12 @@ async def update_file_data_content_by_id(
@router.get("/{id}/content") @router.get("/{id}/content")
async def get_file_content_by_id( async def get_file_content_by_id(
id: str, user=Depends(get_verified_user), attachment: bool = Query(False) id: str,
user=Depends(get_verified_user),
attachment: bool = Query(False),
db: Session = Depends(get_session),
): ):
file = Files.get_file_by_id(id) file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -563,7 +637,7 @@ async def get_file_content_by_id(
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "read", user) or has_access_to_file(id, "read", user, db=db)
): ):
try: try:
file_path = Storage.get_file(file.path) file_path = Storage.get_file(file.path)
@@ -619,8 +693,10 @@ async def get_file_content_by_id(
@router.get("/{id}/content/html") @router.get("/{id}/content/html")
async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_html_file_content_by_id(
file = Files.get_file_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -628,7 +704,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
file_user = Users.get_user_by_id(file.user_id) file_user = Users.get_user_by_id(file.user_id, db=db)
if not file_user.role == "admin": if not file_user.role == "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -638,7 +714,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "read", user) or has_access_to_file(id, "read", user, db=db)
): ):
try: try:
file_path = Storage.get_file(file.path) file_path = Storage.get_file(file.path)
@@ -668,8 +744,10 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/{id}/content/{file_name}") @router.get("/{id}/content/{file_name}")
async def get_file_content_by_id(id: str, user=Depends(get_verified_user)): async def get_file_content_by_id(
file = Files.get_file_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -680,7 +758,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "read", user) or has_access_to_file(id, "read", user, db=db)
): ):
file_path = file.path file_path = file.path
@@ -730,8 +808,10 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
@router.delete("/{id}") @router.delete("/{id}")
async def delete_file_by_id(id: str, user=Depends(get_verified_user)): async def delete_file_by_id(
file = Files.get_file_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
file = Files.get_file_by_id(id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
@@ -742,10 +822,10 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
file.user_id == user.id file.user_id == user.id
or user.role == "admin" or user.role == "admin"
or has_access_to_file(id, "write", user) or has_access_to_file(id, "write", user, db=db)
): ):
result = Files.delete_file_by_id(id) result = Files.delete_file_by_id(id, db=db)
if result: if result:
try: try:
Storage.delete_file(file.path) Storage.delete_file(file.path)
+57 -30
View File
@@ -22,6 +22,8 @@ from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
@@ -44,7 +46,11 @@ router = APIRouter()
@router.get("/", response_model=list[FolderNameIdResponse]) @router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(request: Request, user=Depends(get_verified_user)): async def get_folders(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if request.app.state.config.ENABLE_FOLDERS is False: if request.app.state.config.ENABLE_FOLDERS is False:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -55,22 +61,23 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
user.id, user.id,
"features.folders", "features.folders",
request.app.state.config.USER_PERMISSIONS, request.app.state.config.USER_PERMISSIONS,
db=db,
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
folders = Folders.get_folders_by_user_id(user.id) folders = Folders.get_folders_by_user_id(user.id, db=db)
# Verify folder data integrity # Verify folder data integrity
folder_list = [] folder_list = []
for folder in folders: for folder in folders:
if folder.parent_id and not Folders.get_folder_by_id_and_user_id( if folder.parent_id and not Folders.get_folder_by_id_and_user_id(
folder.parent_id, user.id folder.parent_id, user.id, db=db
): ):
folder = Folders.update_folder_parent_id_by_id_and_user_id( folder = Folders.update_folder_parent_id_by_id_and_user_id(
folder.id, user.id, None folder.id, user.id, None, db=db
) )
if folder.data: if folder.data:
@@ -80,12 +87,12 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
if file.get("type") == "file": if file.get("type") == "file":
if Files.check_access_by_user_id( if Files.check_access_by_user_id(
file.get("id"), user.id, "read" file.get("id"), user.id, "read", db=db
): ):
valid_files.append(file) valid_files.append(file)
elif file.get("type") == "collection": elif file.get("type") == "collection":
if Knowledges.check_access_by_user_id( if Knowledges.check_access_by_user_id(
file.get("id"), user.id, "read" file.get("id"), user.id, "read", db=db
): ):
valid_files.append(file) valid_files.append(file)
else: else:
@@ -93,7 +100,7 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
folder.data["files"] = valid_files folder.data["files"] = valid_files
Folders.update_folder_by_id_and_user_id( Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data) folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
) )
folder_list.append(FolderNameIdResponse(**folder.model_dump())) folder_list.append(FolderNameIdResponse(**folder.model_dump()))
@@ -107,9 +114,13 @@ async def get_folders(request: Request, user=Depends(get_verified_user)):
@router.post("/") @router.post("/")
def create_folder(form_data: FolderForm, user=Depends(get_verified_user)): def create_folder(
form_data: FolderForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
folder = Folders.get_folder_by_parent_id_and_user_id_and_name( folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
None, user.id, form_data.name None, user.id, form_data.name, db=db
) )
if folder: if folder:
@@ -119,7 +130,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
) )
try: try:
folder = Folders.insert_new_folder(user.id, form_data) folder = Folders.insert_new_folder(user.id, form_data, db=db)
return folder return folder
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@@ -136,8 +147,10 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
@router.get("/{id}", response_model=Optional[FolderModel]) @router.get("/{id}", response_model=Optional[FolderModel])
async def get_folder_by_id(id: str, user=Depends(get_verified_user)): async def get_folder_by_id(
folder = Folders.get_folder_by_id_and_user_id(id, user.id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder: if folder:
return folder return folder
else: else:
@@ -154,15 +167,18 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update") @router.post("/{id}/update")
async def update_folder_name_by_id( async def update_folder_name_by_id(
id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user) id: str,
form_data: FolderUpdateForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder: if folder:
if form_data.name is not None: if form_data.name is not None:
# Check if folder with same name exists # Check if folder with same name exists
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name folder.parent_id, user.id, form_data.name, db=db
) )
if existing_folder and existing_folder.id != id: if existing_folder and existing_folder.id != id:
raise HTTPException( raise HTTPException(
@@ -171,7 +187,9 @@ async def update_folder_name_by_id(
) )
try: try:
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data) folder = Folders.update_folder_by_id_and_user_id(
id, user.id, form_data, db=db
)
return folder return folder
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@@ -198,12 +216,15 @@ class FolderParentIdForm(BaseModel):
@router.post("/{id}/update/parent") @router.post("/{id}/update/parent")
async def update_folder_parent_id_by_id( async def update_folder_parent_id_by_id(
id: str, form_data: FolderParentIdForm, user=Depends(get_verified_user) id: str,
form_data: FolderParentIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder: if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
form_data.parent_id, user.id, folder.name form_data.parent_id, user.id, folder.name, db=db
) )
if existing_folder: if existing_folder:
@@ -214,7 +235,7 @@ async def update_folder_parent_id_by_id(
try: try:
folder = Folders.update_folder_parent_id_by_id_and_user_id( folder = Folders.update_folder_parent_id_by_id_and_user_id(
id, user.id, form_data.parent_id id, user.id, form_data.parent_id, db=db
) )
return folder return folder
except Exception as e: except Exception as e:
@@ -242,13 +263,16 @@ class FolderIsExpandedForm(BaseModel):
@router.post("/{id}/update/expanded") @router.post("/{id}/update/expanded")
async def update_folder_is_expanded_by_id( async def update_folder_is_expanded_by_id(
id: str, form_data: FolderIsExpandedForm, user=Depends(get_verified_user) id: str,
form_data: FolderIsExpandedForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
if folder: if folder:
try: try:
folder = Folders.update_folder_is_expanded_by_id_and_user_id( folder = Folders.update_folder_is_expanded_by_id_and_user_id(
id, user.id, form_data.is_expanded id, user.id, form_data.is_expanded, db=db
) )
return folder return folder
except Exception as e: except Exception as e:
@@ -276,10 +300,11 @@ async def delete_folder_by_id(
id: str, id: str,
delete_contents: Optional[bool] = True, delete_contents: Optional[bool] = True,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if Chats.count_chats_by_folder_id_and_user_id(id, user.id): if Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db):
chat_delete_permission = has_permission( chat_delete_permission = has_permission(
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS, db=db
) )
if user.role != "admin" and not chat_delete_permission: if user.role != "admin" and not chat_delete_permission:
raise HTTPException( raise HTTPException(
@@ -288,19 +313,21 @@ async def delete_folder_by_id(
) )
folders = [] folders = []
folders.append(Folders.get_folder_by_id_and_user_id(id, user.id)) folders.append(Folders.get_folder_by_id_and_user_id(id, user.id, db=db))
while folders: while folders:
folder = folders.pop() folder = folders.pop()
if folder: if folder:
try: try:
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id) folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db)
for folder_id in folder_ids: for folder_id in folder_ids:
if delete_contents: if delete_contents:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id) Chats.delete_chats_by_user_id_and_folder_id(
user.id, folder_id, db=db
)
else: else:
Chats.move_chats_by_user_id_and_folder_id( Chats.move_chats_by_user_id_and_folder_id(
user.id, folder_id, None user.id, folder_id, None, db=db
) )
return True return True
@@ -314,7 +341,7 @@ async def delete_folder_by_id(
finally: finally:
# Get all subfolders # Get all subfolders
subfolders = Folders.get_folders_by_parent_id_and_user_id( subfolders = Folders.get_folders_by_parent_id_and_user_id(
folder.id, user.id folder.id, user.id, db=db
) )
folders.extend(subfolders) folders.extend(subfolders)
+98 -42
View File
@@ -6,6 +6,7 @@ import aiohttp
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.functions import ( from open_webui.models.functions import (
FunctionForm, FunctionForm,
FunctionModel, FunctionModel,
@@ -24,6 +25,8 @@ from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -37,13 +40,17 @@ router = APIRouter()
@router.get("/", response_model=list[FunctionResponse]) @router.get("/", response_model=list[FunctionResponse])
async def get_functions(user=Depends(get_verified_user)): async def get_functions(
return Functions.get_functions() user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return Functions.get_functions(db=db)
@router.get("/list", response_model=list[FunctionUserResponse]) @router.get("/list", response_model=list[FunctionUserResponse])
async def get_function_list(user=Depends(get_admin_user)): async def get_function_list(
return Functions.get_function_list() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Functions.get_function_list(db=db)
############################ ############################
@@ -52,8 +59,12 @@ async def get_function_list(user=Depends(get_admin_user)):
@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel]) @router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)): async def get_functions(
return Functions.get_functions(include_valves=include_valves) include_valves: bool = False,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
return Functions.get_functions(include_valves=include_valves, db=db)
############################ ############################
@@ -110,7 +121,9 @@ async def load_function_from_url(
) )
try: try:
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.get( async with session.get(
url, headers={"Content-Type": "application/json"} url, headers={"Content-Type": "application/json"}
) as resp: ) as resp:
@@ -142,7 +155,10 @@ class SyncFunctionsForm(BaseModel):
@router.post("/sync", response_model=list[FunctionWithValvesModel]) @router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions( async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user) request: Request,
form_data: SyncFunctionsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
try: try:
for function in form_data.functions: for function in form_data.functions:
@@ -164,7 +180,7 @@ async def sync_functions(
) )
raise e raise e
return Functions.sync_functions(user.id, form_data.functions) return Functions.sync_functions(user.id, form_data.functions, db=db)
except Exception as e: except Exception as e:
log.exception(f"Failed to load a function: {e}") log.exception(f"Failed to load a function: {e}")
raise HTTPException( raise HTTPException(
@@ -180,7 +196,10 @@ async def sync_functions(
@router.post("/create", response_model=Optional[FunctionResponse]) @router.post("/create", response_model=Optional[FunctionResponse])
async def create_new_function( async def create_new_function(
request: Request, form_data: FunctionForm, user=Depends(get_admin_user) request: Request,
form_data: FunctionForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
if not form_data.id.isidentifier(): if not form_data.id.isidentifier():
raise HTTPException( raise HTTPException(
@@ -190,7 +209,7 @@ async def create_new_function(
form_data.id = form_data.id.lower() form_data.id = form_data.id.lower()
function = Functions.get_function_by_id(form_data.id) function = Functions.get_function_by_id(form_data.id, db=db)
if function is None: if function is None:
try: try:
form_data.content = replace_imports(form_data.content) form_data.content = replace_imports(form_data.content)
@@ -203,13 +222,17 @@ async def create_new_function(
FUNCTIONS = request.app.state.FUNCTIONS FUNCTIONS = request.app.state.FUNCTIONS
FUNCTIONS[form_data.id] = function_module FUNCTIONS[form_data.id] = function_module
function = Functions.insert_new_function(user.id, function_type, form_data) function = Functions.insert_new_function(
user.id, function_type, form_data, db=db
)
function_cache_dir = CACHE_DIR / "functions" / form_data.id function_cache_dir = CACHE_DIR / "functions" / form_data.id
function_cache_dir.mkdir(parents=True, exist_ok=True) function_cache_dir.mkdir(parents=True, exist_ok=True)
if function_type == "filter" and getattr(function_module, "toggle", None): if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True}) Functions.update_function_metadata_by_id(
form_data.id, {"toggle": True}, db=db
)
if function: if function:
return function return function
@@ -237,8 +260,10 @@ async def create_new_function(
@router.get("/id/{id}", response_model=Optional[FunctionModel]) @router.get("/id/{id}", response_model=Optional[FunctionModel])
async def get_function_by_id(id: str, user=Depends(get_admin_user)): async def get_function_by_id(
function = Functions.get_function_by_id(id) id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function: if function:
return function return function
@@ -255,11 +280,13 @@ async def get_function_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/toggle", response_model=Optional[FunctionModel]) @router.post("/id/{id}/toggle", response_model=Optional[FunctionModel])
async def toggle_function_by_id(id: str, user=Depends(get_admin_user)): async def toggle_function_by_id(
function = Functions.get_function_by_id(id) id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function: if function:
function = Functions.update_function_by_id( function = Functions.update_function_by_id(
id, {"is_active": not function.is_active} id, {"is_active": not function.is_active}, db=db
) )
if function: if function:
@@ -282,11 +309,13 @@ async def toggle_function_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/toggle/global", response_model=Optional[FunctionModel]) @router.post("/id/{id}/toggle/global", response_model=Optional[FunctionModel])
async def toggle_global_by_id(id: str, user=Depends(get_admin_user)): async def toggle_global_by_id(
function = Functions.get_function_by_id(id) id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function: if function:
function = Functions.update_function_by_id( function = Functions.update_function_by_id(
id, {"is_global": not function.is_global} id, {"is_global": not function.is_global}, db=db
) )
if function: if function:
@@ -310,7 +339,11 @@ async def toggle_global_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/update", response_model=Optional[FunctionModel]) @router.post("/id/{id}/update", response_model=Optional[FunctionModel])
async def update_function_by_id( async def update_function_by_id(
request: Request, id: str, form_data: FunctionForm, user=Depends(get_admin_user) request: Request,
id: str,
form_data: FunctionForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
try: try:
form_data.content = replace_imports(form_data.content) form_data.content = replace_imports(form_data.content)
@@ -325,10 +358,10 @@ async def update_function_by_id(
updated = {**form_data.model_dump(exclude={"id"}), "type": function_type} updated = {**form_data.model_dump(exclude={"id"}), "type": function_type}
log.debug(updated) log.debug(updated)
function = Functions.update_function_by_id(id, updated) function = Functions.update_function_by_id(id, updated, db=db)
if function_type == "filter" and getattr(function_module, "toggle", None): if function_type == "filter" and getattr(function_module, "toggle", None):
Functions.update_function_metadata_by_id(id, {"toggle": True}) Functions.update_function_metadata_by_id(id, {"toggle": True}, db=db)
if function: if function:
return function return function
@@ -352,9 +385,12 @@ async def update_function_by_id(
@router.delete("/id/{id}/delete", response_model=bool) @router.delete("/id/{id}/delete", response_model=bool)
async def delete_function_by_id( async def delete_function_by_id(
request: Request, id: str, user=Depends(get_admin_user) request: Request,
id: str,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
result = Functions.delete_function_by_id(id) result = Functions.delete_function_by_id(id, db=db)
if result: if result:
FUNCTIONS = request.app.state.FUNCTIONS FUNCTIONS = request.app.state.FUNCTIONS
@@ -370,11 +406,13 @@ async def delete_function_by_id(
@router.get("/id/{id}/valves", response_model=Optional[dict]) @router.get("/id/{id}/valves", response_model=Optional[dict])
async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)): async def get_function_valves_by_id(
function = Functions.get_function_by_id(id) id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function: if function:
try: try:
valves = Functions.get_function_valves_by_id(id) valves = Functions.get_function_valves_by_id(id, db=db)
return valves return valves
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -395,9 +433,12 @@ async def get_function_valves_by_id(id: str, user=Depends(get_admin_user)):
@router.get("/id/{id}/valves/spec", response_model=Optional[dict]) @router.get("/id/{id}/valves/spec", response_model=Optional[dict])
async def get_function_valves_spec_by_id( async def get_function_valves_spec_by_id(
request: Request, id: str, user=Depends(get_admin_user) request: Request,
id: str,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id, db=db)
if function: if function:
function_module, function_type, frontmatter = get_function_module_from_cache( function_module, function_type, frontmatter = get_function_module_from_cache(
request, id request, id
@@ -421,9 +462,13 @@ async def get_function_valves_spec_by_id(
@router.post("/id/{id}/valves/update", response_model=Optional[dict]) @router.post("/id/{id}/valves/update", response_model=Optional[dict])
async def update_function_valves_by_id( async def update_function_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_admin_user) request: Request,
id: str,
form_data: dict,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id, db=db)
if function: if function:
function_module, function_type, frontmatter = get_function_module_from_cache( function_module, function_type, frontmatter = get_function_module_from_cache(
request, id request, id
@@ -437,7 +482,7 @@ async def update_function_valves_by_id(
valves = Valves(**form_data) valves = Valves(**form_data)
valves_dict = valves.model_dump(exclude_unset=True) valves_dict = valves.model_dump(exclude_unset=True)
Functions.update_function_valves_by_id(id, valves_dict) Functions.update_function_valves_by_id(id, valves_dict, db=db)
return valves_dict return valves_dict
except Exception as e: except Exception as e:
log.exception(f"Error updating function values by id {id}: {e}") log.exception(f"Error updating function values by id {id}: {e}")
@@ -464,11 +509,15 @@ async def update_function_valves_by_id(
@router.get("/id/{id}/valves/user", response_model=Optional[dict]) @router.get("/id/{id}/valves/user", response_model=Optional[dict])
async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user)): async def get_function_user_valves_by_id(
function = Functions.get_function_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
function = Functions.get_function_by_id(id, db=db)
if function: if function:
try: try:
user_valves = Functions.get_user_valves_by_id_and_user_id(id, user.id) user_valves = Functions.get_user_valves_by_id_and_user_id(
id, user.id, db=db
)
return user_valves return user_valves
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -484,9 +533,12 @@ async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user
@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict]) @router.get("/id/{id}/valves/user/spec", response_model=Optional[dict])
async def get_function_user_valves_spec_by_id( async def get_function_user_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user) request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id, db=db)
if function: if function:
function_module, function_type, frontmatter = get_function_module_from_cache( function_module, function_type, frontmatter = get_function_module_from_cache(
request, id request, id
@@ -505,9 +557,13 @@ async def get_function_user_valves_spec_by_id(
@router.post("/id/{id}/valves/user/update", response_model=Optional[dict]) @router.post("/id/{id}/valves/user/update", response_model=Optional[dict])
async def update_function_user_valves_by_id( async def update_function_user_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user) request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
function = Functions.get_function_by_id(id) function = Functions.get_function_by_id(id, db=db)
if function: if function:
function_module, function_type, frontmatter = get_function_module_from_cache( function_module, function_type, frontmatter = get_function_module_from_cache(
@@ -522,7 +578,7 @@ async def update_function_user_valves_by_id(
user_valves = UserValves(**form_data) user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True) user_valves_dict = user_valves.model_dump(exclude_unset=True)
Functions.update_user_valves_by_id_and_user_id( Functions.update_user_valves_by_id_and_user_id(
id, user.id, user_valves_dict id, user.id, user_valves_dict, db=db
) )
return user_valves_dict return user_valves_dict
except Exception as e: except Exception as e:
+58 -29
View File
@@ -16,6 +16,9 @@ from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
@@ -29,16 +32,21 @@ router = APIRouter()
@router.get("/", response_model=list[GroupResponse]) @router.get("/", response_model=list[GroupResponse])
async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)): async def get_groups(
share: Optional[bool] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
filter = {} filter = {}
# Admins can share to all groups regardless of share setting
if user.role != "admin": if user.role != "admin":
filter["member_id"] = user.id filter["member_id"] = user.id
if share is not None:
filter["share"] = share
if share is not None: groups = Groups.get_groups(filter=filter, db=db)
filter["share"] = share
groups = Groups.get_groups(filter=filter)
return groups return groups
@@ -49,13 +57,17 @@ async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_use
@router.post("/create", response_model=Optional[GroupResponse]) @router.post("/create", response_model=Optional[GroupResponse])
async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)): async def create_new_group(
form_data: GroupForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try: try:
group = Groups.insert_new_group(user.id, form_data) group = Groups.insert_new_group(user.id, form_data, db=db)
if group: if group:
return GroupResponse( return GroupResponse(
**group.model_dump(), **group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id), member_count=Groups.get_group_member_count_by_id(group.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -76,12 +88,14 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
@router.get("/id/{id}", response_model=Optional[GroupResponse]) @router.get("/id/{id}", response_model=Optional[GroupResponse])
async def get_group_by_id(id: str, user=Depends(get_admin_user)): async def get_group_by_id(
group = Groups.get_group_by_id(id) id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
group = Groups.get_group_by_id(id, db=db)
if group: if group:
return GroupResponse( return GroupResponse(
**group.model_dump(), **group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id), member_count=Groups.get_group_member_count_by_id(group.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -101,13 +115,15 @@ class GroupExportResponse(GroupResponse):
@router.get("/id/{id}/export", response_model=Optional[GroupExportResponse]) @router.get("/id/{id}/export", response_model=Optional[GroupExportResponse])
async def export_group_by_id(id: str, user=Depends(get_admin_user)): async def export_group_by_id(
group = Groups.get_group_by_id(id) id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
group = Groups.get_group_by_id(id, db=db)
if group: if group:
return GroupExportResponse( return GroupExportResponse(
**group.model_dump(), **group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id), member_count=Groups.get_group_member_count_by_id(group.id, db=db),
user_ids=Groups.get_group_user_ids_by_id(group.id), user_ids=Groups.get_group_user_ids_by_id(group.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -122,9 +138,11 @@ async def export_group_by_id(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/users", response_model=list[UserInfoResponse]) @router.post("/id/{id}/users", response_model=list[UserInfoResponse])
async def get_users_in_group(id: str, user=Depends(get_admin_user)): async def get_users_in_group(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
try: try:
users = Users.get_users_by_group_id(id) users = Users.get_users_by_group_id(id, db=db)
return users return users
except Exception as e: except Exception as e:
log.exception(f"Error adding users to group {id}: {e}") log.exception(f"Error adding users to group {id}: {e}")
@@ -141,14 +159,17 @@ async def get_users_in_group(id: str, user=Depends(get_admin_user)):
@router.post("/id/{id}/update", response_model=Optional[GroupResponse]) @router.post("/id/{id}/update", response_model=Optional[GroupResponse])
async def update_group_by_id( async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user) id: str,
form_data: GroupUpdateForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
try: try:
group = Groups.update_group_by_id(id, form_data) group = Groups.update_group_by_id(id, form_data, db=db)
if group: if group:
return GroupResponse( return GroupResponse(
**group.model_dump(), **group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id), member_count=Groups.get_group_member_count_by_id(group.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -170,17 +191,20 @@ async def update_group_by_id(
@router.post("/id/{id}/users/add", response_model=Optional[GroupResponse]) @router.post("/id/{id}/users/add", response_model=Optional[GroupResponse])
async def add_user_to_group( async def add_user_to_group(
id: str, form_data: UserIdsForm, user=Depends(get_admin_user) id: str,
form_data: UserIdsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
try: try:
if form_data.user_ids: if form_data.user_ids:
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids) form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids, db=db)
group = Groups.add_users_to_group(id, form_data.user_ids) group = Groups.add_users_to_group(id, form_data.user_ids, db=db)
if group: if group:
return GroupResponse( return GroupResponse(
**group.model_dump(), **group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id), member_count=Groups.get_group_member_count_by_id(group.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -197,14 +221,17 @@ async def add_user_to_group(
@router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse]) @router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse])
async def remove_users_from_group( async def remove_users_from_group(
id: str, form_data: UserIdsForm, user=Depends(get_admin_user) id: str,
form_data: UserIdsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
try: try:
group = Groups.remove_users_from_group(id, form_data.user_ids) group = Groups.remove_users_from_group(id, form_data.user_ids, db=db)
if group: if group:
return GroupResponse( return GroupResponse(
**group.model_dump(), **group.model_dump(),
member_count=Groups.get_group_member_count_by_id(group.id), member_count=Groups.get_group_member_count_by_id(group.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -225,9 +252,11 @@ async def remove_users_from_group(
@router.delete("/id/{id}/delete", response_model=bool) @router.delete("/id/{id}/delete", response_model=bool)
async def delete_group_by_id(id: str, user=Depends(get_admin_user)): async def delete_group_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
try: try:
result = Groups.delete_group_by_id(id) result = Groups.delete_group_by_id(id, db=db)
if result: if result:
return result return result
else: else:
+48 -9
View File
@@ -21,7 +21,10 @@ from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.routers.files import upload_file_handler, get_file_content_by_id from open_webui.routers.files import upload_file_handler, get_file_content_by_id
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.headers import include_user_info_headers from open_webui.utils.headers import include_user_info_headers
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.images.comfyui import ( from open_webui.utils.images.comfyui import (
ComfyUICreateImageForm, ComfyUICreateImageForm,
ComfyUIEditImageForm, ComfyUIEditImageForm,
@@ -461,6 +464,7 @@ class CreateImageForm(BaseModel):
prompt: str prompt: str
size: Optional[str] = None size: Optional[str] = None
n: int = 1 n: int = 1
steps: Optional[int] = None
negative_prompt: Optional[str] = None negative_prompt: Optional[str] = None
@@ -496,7 +500,7 @@ def get_image_data(data: str, headers=None):
return None, None return None, None
def upload_image(request, image_data, content_type, metadata, user): def upload_image(request, image_data, content_type, metadata, user, db=None):
image_format = mimetypes.guess_extension(content_type) image_format = mimetypes.guess_extension(content_type)
file = UploadFile( file = UploadFile(
file=io.BytesIO(image_data), file=io.BytesIO(image_data),
@@ -524,6 +528,7 @@ def upload_image(request, image_data, content_type, metadata, user):
message_id=message_id, message_id=message_id,
file_ids=[file_item.id], file_ids=[file_item.id],
user_id=user.id, user_id=user.id,
db=db,
) )
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
@@ -534,6 +539,20 @@ def upload_image(request, image_data, content_type, metadata, user):
async def generate_images( async def generate_images(
request: Request, form_data: CreateImageForm, user=Depends(get_verified_user) request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)
): ):
if not request.app.state.config.ENABLE_IMAGE_GENERATION:
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "features.image_generation", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return await image_generations(request, form_data, user=user) return await image_generations(request, form_data, user=user)
@@ -703,8 +722,15 @@ async def image_generations(
"n": form_data.n, "n": form_data.n,
} }
if request.app.state.config.IMAGE_STEPS is not None: if (
data["steps"] = request.app.state.config.IMAGE_STEPS request.app.state.config.IMAGE_STEPS is not None
or form_data.steps is not None
):
data["steps"] = (
form_data.steps
if form_data.steps is not None
else request.app.state.config.IMAGE_STEPS
)
if form_data.negative_prompt is not None: if form_data.negative_prompt is not None:
data["negative_prompt"] = form_data.negative_prompt data["negative_prompt"] = form_data.negative_prompt
@@ -762,8 +788,15 @@ async def image_generations(
"height": height, "height": height,
} }
if request.app.state.config.IMAGE_STEPS is not None: if (
data["steps"] = request.app.state.config.IMAGE_STEPS request.app.state.config.IMAGE_STEPS is not None
or form_data.steps is not None
):
data["steps"] = (
form_data.steps
if form_data.steps is not None
else request.app.state.config.IMAGE_STEPS
)
if form_data.negative_prompt is not None: if form_data.negative_prompt is not None:
data["negative_prompt"] = form_data.negative_prompt data["negative_prompt"] = form_data.negative_prompt
@@ -844,6 +877,9 @@ async def image_edits(
try: try:
async def load_url_image(data): async def load_url_image(data):
if data.startswith("data:"):
return data
if data.startswith("http://") or data.startswith("https://"): if data.startswith("http://") or data.startswith("https://"):
r = await asyncio.to_thread(requests.get, data) r = await asyncio.to_thread(requests.get, data)
r.raise_for_status() r.raise_for_status()
@@ -851,10 +887,14 @@ async def image_edits(
image_data = base64.b64encode(r.content).decode("utf-8") image_data = base64.b64encode(r.content).decode("utf-8")
return f"data:{r.headers['content-type']};base64,{image_data}" return f"data:{r.headers['content-type']};base64,{image_data}"
elif data.startswith("/api/v1/files"): else:
file_id = data.split("/api/v1/files/")[1].split("/content")[0] file_id = None
file_response = await get_file_content_by_id(file_id, user) if data.startswith("/api/v1/files"):
file_id = data.split("/api/v1/files/")[1].split("/content")[0]
else:
file_id = data
file_response = await get_file_content_by_id(file_id, user)
if isinstance(file_response, FileResponse): if isinstance(file_response, FileResponse):
file_path = file_response.path file_path = file_response.path
@@ -864,7 +904,6 @@ async def image_edits(
mime_type, _ = mimetypes.guess_type(file_path) mime_type, _ = mimetypes.guess_type(file_path)
return f"data:{mime_type};base64,{image_data}" return f"data:{mime_type};base64,{image_data}"
return data return data
# Load image(s) from URL(s) if necessary # Load image(s) from URL(s) if necessary
+247 -59
View File
@@ -1,9 +1,14 @@
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.responses import StreamingResponse
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
import logging import logging
import io
import zipfile
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.knowledge import ( from open_webui.models.knowledge import (
KnowledgeFileListResponse, KnowledgeFileListResponse,
@@ -23,7 +28,7 @@ from open_webui.routers.retrieval import (
from open_webui.storage.provider import Storage from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
@@ -41,6 +46,54 @@ router = APIRouter()
PAGE_ITEM_COUNT = 30 PAGE_ITEM_COUNT = 30
############################
# Knowledge Base Embedding
############################
KNOWLEDGE_BASES_COLLECTION = "knowledge-bases"
async def embed_knowledge_base_metadata(
request: Request,
knowledge_base_id: str,
name: str,
description: str,
) -> bool:
"""Generate and store embedding for knowledge base."""
try:
content = f"{name}\n\n{description}" if description else name
embedding = await request.app.state.EMBEDDING_FUNCTION(content)
VECTOR_DB_CLIENT.upsert(
collection_name=KNOWLEDGE_BASES_COLLECTION,
items=[
{
"id": knowledge_base_id,
"text": content,
"vector": embedding,
"metadata": {
"knowledge_base_id": knowledge_base_id,
},
}
],
)
return True
except Exception as e:
log.error(f"Failed to embed knowledge base {knowledge_base_id}: {e}")
return False
def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool:
"""Remove knowledge base embedding."""
try:
VECTOR_DB_CLIENT.delete(
collection_name=KNOWLEDGE_BASES_COLLECTION,
ids=[knowledge_base_id],
)
return True
except Exception as e:
log.debug(f"Failed to remove embedding for {knowledge_base_id}: {e}")
return False
class KnowledgeAccessResponse(KnowledgeUserResponse): class KnowledgeAccessResponse(KnowledgeUserResponse):
write_access: Optional[bool] = False write_access: Optional[bool] = False
@@ -52,21 +105,25 @@ class KnowledgeAccessListResponse(BaseModel):
@router.get("/", response_model=KnowledgeAccessListResponse) @router.get("/", response_model=KnowledgeAccessListResponse)
async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)): async def get_knowledge_bases(
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
page = max(page, 1) page = max(page, 1)
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit skip = (page - 1) * limit
filter = {} filter = {}
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id) groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in groups] filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases( result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit user.id, filter=filter, skip=skip, limit=limit, db=db
) )
return KnowledgeAccessListResponse( return KnowledgeAccessListResponse(
@@ -75,7 +132,10 @@ async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified
**knowledge_base.model_dump(), **knowledge_base.model_dump(),
write_access=( write_access=(
user.id == knowledge_base.user_id user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control) or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
)
), ),
) )
for knowledge_base in result.items for knowledge_base in result.items
@@ -90,6 +150,7 @@ async def search_knowledge_bases(
view_option: Optional[str] = None, view_option: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
page = max(page, 1) page = max(page, 1)
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
@@ -102,14 +163,14 @@ async def search_knowledge_bases(
filter["view_option"] = view_option filter["view_option"] = view_option
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id) groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in groups] filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id filter["user_id"] = user.id
result = Knowledges.search_knowledge_bases( result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit user.id, filter=filter, skip=skip, limit=limit, db=db
) )
return KnowledgeAccessListResponse( return KnowledgeAccessListResponse(
@@ -118,7 +179,10 @@ async def search_knowledge_bases(
**knowledge_base.model_dump(), **knowledge_base.model_dump(),
write_access=( write_access=(
user.id == knowledge_base.user_id user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control) or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(
user.id, "write", knowledge_base.access_control, db=db
)
), ),
) )
for knowledge_base in result.items for knowledge_base in result.items
@@ -132,6 +196,7 @@ async def search_knowledge_files(
query: Optional[str] = None, query: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
page = max(page, 1) page = max(page, 1)
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
@@ -141,13 +206,15 @@ async def search_knowledge_files(
if query: if query:
filter["query"] = query filter["query"] = query
groups = Groups.get_groups_by_member_id(user.id) groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in groups] filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id filter["user_id"] = user.id
return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit) return Knowledges.search_knowledge_files(
filter=filter, skip=skip, limit=limit, db=db
)
############################ ############################
@@ -157,10 +224,13 @@ async def search_knowledge_files(
@router.post("/create", response_model=Optional[KnowledgeResponse]) @router.post("/create", response_model=Optional[KnowledgeResponse])
async def create_new_knowledge( async def create_new_knowledge(
request: Request, form_data: KnowledgeForm, user=Depends(get_verified_user) request: Request,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -175,13 +245,21 @@ async def create_new_knowledge(
user.id, user.id,
"sharing.public_knowledge", "sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS, request.app.state.config.USER_PERMISSIONS,
db=db,
) )
): ):
form_data.access_control = {} form_data.access_control = {}
knowledge = Knowledges.insert_new_knowledge(user.id, form_data) knowledge = Knowledges.insert_new_knowledge(user.id, form_data, db=db)
if knowledge: if knowledge:
# Embed knowledge base for semantic search
await embed_knowledge_base_metadata(
request,
knowledge.id,
knowledge.name,
knowledge.description,
)
return knowledge return knowledge
else: else:
raise HTTPException( raise HTTPException(
@@ -196,20 +274,24 @@ async def create_new_knowledge(
@router.post("/reindex", response_model=bool) @router.post("/reindex", response_model=bool)
async def reindex_knowledge_files(request: Request, user=Depends(get_verified_user)): async def reindex_knowledge_files(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin": if user.role != "admin":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
knowledge_bases = Knowledges.get_knowledge_bases() knowledge_bases = Knowledges.get_knowledge_bases(db=db)
log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases") log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases")
for knowledge_base in knowledge_bases: for knowledge_base in knowledge_bases:
try: try:
files = Knowledges.get_files_by_id(knowledge_base.id) files = Knowledges.get_files_by_id(knowledge_base.id, db=db)
try: try:
if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
VECTOR_DB_CLIENT.delete_collection( VECTOR_DB_CLIENT.delete_collection(
@@ -229,6 +311,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
file_id=file.id, collection_name=knowledge_base.id file_id=file.id, collection_name=knowledge_base.id
), ),
user=user, user=user,
db=db,
) )
except Exception as e: except Exception as e:
log.error( log.error(
@@ -253,6 +336,30 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
return True return True
############################
# ReindexKnowledgeBases
############################
@router.post("/metadata/reindex", response_model=dict)
async def reindex_knowledge_base_metadata_embeddings(
request: Request,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Batch embed all existing knowledge bases. Admin only."""
knowledge_bases = Knowledges.get_knowledge_bases(db=db)
log.info(f"Reindexing embeddings for {len(knowledge_bases)} knowledge bases")
success_count = 0
for kb in knowledge_bases:
if await embed_knowledge_base_metadata(request, kb.id, kb.name, kb.description):
success_count += 1
log.info(f"Embedding reindex complete: {success_count}/{len(knowledge_bases)}")
return {"total": len(knowledge_bases), "success": success_count}
############################ ############################
# GetKnowledgeById # GetKnowledgeById
############################ ############################
@@ -264,26 +371,34 @@ class KnowledgeFilesResponse(KnowledgeResponse):
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)): async def get_knowledge_by_id(
knowledge = Knowledges.get_knowledge_by_id(id=id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if knowledge: if knowledge:
if ( if (
user.role == "admin" user.role == "admin"
or knowledge.user_id == user.id or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control) or has_access(user.id, "read", knowledge.access_control, db=db)
): ):
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
write_access=( write_access=(
user.id == knowledge.user_id user.id == knowledge.user_id
or has_access(user.id, "write", knowledge.access_control) or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or has_access(user.id, "write", knowledge.access_control, db=db)
), ),
) )
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
@@ -299,8 +414,9 @@ async def update_knowledge_by_id(
id: str, id: str,
form_data: KnowledgeForm, form_data: KnowledgeForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -309,7 +425,7 @@ async def update_knowledge_by_id(
# Is the user the original creator, in a group with write access, or an admin # Is the user the original creator, in a group with write access, or an admin
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -325,15 +441,23 @@ async def update_knowledge_by_id(
user.id, user.id,
"sharing.public_knowledge", "sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS, request.app.state.config.USER_PERMISSIONS,
db=db,
) )
): ):
form_data.access_control = {} form_data.access_control = {}
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data, db=db)
if knowledge: if knowledge:
# Re-embed knowledge base for semantic search
await embed_knowledge_base_metadata(
request,
knowledge.id,
knowledge.name,
knowledge.description,
)
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -356,9 +480,10 @@ async def get_knowledge_files_by_id(
direction: Optional[str] = None, direction: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -368,7 +493,7 @@ async def get_knowledge_files_by_id(
if not ( if not (
user.role == "admin" user.role == "admin"
or knowledge.user_id == user.id or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control) or has_access(user.id, "read", knowledge.access_control, db=db)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -391,7 +516,7 @@ async def get_knowledge_files_by_id(
filter["direction"] = direction filter["direction"] = direction
return Knowledges.search_files_by_id( return Knowledges.search_files_by_id(
id, user.id, filter=filter, skip=skip, limit=limit id, user.id, filter=filter, skip=skip, limit=limit, db=db
) )
@@ -410,8 +535,9 @@ def add_file_to_knowledge_by_id(
id: str, id: str,
form_data: KnowledgeFileIdForm, form_data: KnowledgeFileIdForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -420,7 +546,7 @@ def add_file_to_knowledge_by_id(
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -428,7 +554,7 @@ def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
file = Files.get_file_by_id(form_data.file_id) file = Files.get_file_by_id(form_data.file_id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -446,11 +572,12 @@ def add_file_to_knowledge_by_id(
request, request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id), ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user, user=user,
db=db,
) )
# Add file to knowledge base # Add file to knowledge base
Knowledges.add_file_to_knowledge_by_id( Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, user_id=user.id knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db
) )
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
@@ -462,7 +589,7 @@ def add_file_to_knowledge_by_id(
if knowledge: if knowledge:
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -477,8 +604,9 @@ def update_file_from_knowledge_by_id(
id: str, id: str,
form_data: KnowledgeFileIdForm, form_data: KnowledgeFileIdForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -487,7 +615,7 @@ def update_file_from_knowledge_by_id(
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
@@ -496,7 +624,7 @@ def update_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
file = Files.get_file_by_id(form_data.file_id) file = Files.get_file_by_id(form_data.file_id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -514,6 +642,7 @@ def update_file_from_knowledge_by_id(
request, request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id), ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user, user=user,
db=db,
) )
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -524,7 +653,7 @@ def update_file_from_knowledge_by_id(
if knowledge: if knowledge:
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -544,8 +673,9 @@ def remove_file_from_knowledge_by_id(
form_data: KnowledgeFileIdForm, form_data: KnowledgeFileIdForm,
delete_file: bool = Query(True), delete_file: bool = Query(True),
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -554,7 +684,7 @@ def remove_file_from_knowledge_by_id(
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -562,7 +692,7 @@ def remove_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
file = Files.get_file_by_id(form_data.file_id) file = Files.get_file_by_id(form_data.file_id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -570,7 +700,7 @@ def remove_file_from_knowledge_by_id(
) )
Knowledges.remove_file_from_knowledge_by_id( Knowledges.remove_file_from_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id knowledge_id=id, file_id=form_data.file_id, db=db
) )
# Remove content from the vector database # Remove content from the vector database
@@ -599,12 +729,12 @@ def remove_file_from_knowledge_by_id(
pass pass
# Delete file from database # Delete file from database
Files.delete_file_by_id(form_data.file_id) Files.delete_file_by_id(form_data.file_id, db=db)
if knowledge: if knowledge:
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
) )
else: else:
raise HTTPException( raise HTTPException(
@@ -619,8 +749,10 @@ def remove_file_from_knowledge_by_id(
@router.delete("/{id}/delete", response_model=bool) @router.delete("/{id}/delete", response_model=bool)
async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)): async def delete_knowledge_by_id(
knowledge = Knowledges.get_knowledge_by_id(id=id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -629,7 +761,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -640,7 +772,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})") log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})")
# Get all models # Get all models
models = Models.get_all_models() models = Models.get_all_models(db=db)
log.info(f"Found {len(models)} models to check for knowledge base {id}") log.info(f"Found {len(models)} models to check for knowledge base {id}")
# Update models that reference this knowledge base # Update models that reference this knowledge base
@@ -664,7 +796,7 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
access_control=model.access_control, access_control=model.access_control,
is_active=model.is_active, is_active=model.is_active,
) )
Models.update_model_by_id(model.id, model_form) Models.update_model_by_id(model.id, model_form, db=db)
# Clean up vector DB # Clean up vector DB
try: try:
@@ -672,7 +804,11 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
pass pass
result = Knowledges.delete_knowledge_by_id(id=id)
# Remove knowledge base embedding
remove_knowledge_base_metadata_embedding(id)
result = Knowledges.delete_knowledge_by_id(id=id, db=db)
return result return result
@@ -682,8 +818,10 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/reset", response_model=Optional[KnowledgeResponse]) @router.post("/{id}/reset", response_model=Optional[KnowledgeResponse])
async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)): async def reset_knowledge_by_id(
knowledge = Knowledges.get_knowledge_by_id(id=id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -692,7 +830,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -706,7 +844,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
log.debug(e) log.debug(e)
pass pass
knowledge = Knowledges.reset_knowledge_by_id(id=id) knowledge = Knowledges.reset_knowledge_by_id(id=id, db=db)
return knowledge return knowledge
@@ -721,11 +859,12 @@ async def add_files_to_knowledge_batch(
id: str, id: str,
form_data: list[KnowledgeFileIdForm], form_data: list[KnowledgeFileIdForm],
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
""" """
Add multiple files to a knowledge base Add multiple files to a knowledge base
""" """
knowledge = Knowledges.get_knowledge_by_id(id=id) knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge: if not knowledge:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -734,7 +873,7 @@ async def add_files_to_knowledge_batch(
if ( if (
knowledge.user_id != user.id knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control) and not has_access(user.id, "write", knowledge.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -746,7 +885,7 @@ async def add_files_to_knowledge_batch(
log.info(f"files/batch/add - {len(form_data)} files") log.info(f"files/batch/add - {len(form_data)} files")
files: List[FileModel] = [] files: List[FileModel] = []
for form in form_data: for form in form_data:
file = Files.get_file_by_id(form.file_id) file = Files.get_file_by_id(form.file_id, db=db)
if not file: if not file:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -760,6 +899,7 @@ async def add_files_to_knowledge_batch(
request=request, request=request,
form_data=BatchProcessFilesForm(files=files, collection_name=id), form_data=BatchProcessFilesForm(files=files, collection_name=id),
user=user, user=user,
db=db,
) )
except Exception as e: except Exception as e:
log.error( log.error(
@@ -771,7 +911,7 @@ async def add_files_to_knowledge_batch(
successful_file_ids = [r.file_id for r in result.results if r.status == "completed"] successful_file_ids = [r.file_id for r in result.results if r.status == "completed"]
for file_id in successful_file_ids: for file_id in successful_file_ids:
Knowledges.add_file_to_knowledge_by_id( Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=file_id, user_id=user.id knowledge_id=id, file_id=file_id, user_id=user.id, db=db
) )
# If there were any errors, include them in the response # If there were any errors, include them in the response
@@ -779,7 +919,7 @@ async def add_files_to_knowledge_batch(
error_details = [f"{err.file_id}: {err.error}" for err in result.errors] error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
warnings={ warnings={
"message": "Some files failed to process", "message": "Some files failed to process",
"errors": error_details, "errors": error_details,
@@ -788,5 +928,53 @@ async def add_files_to_knowledge_batch(
return KnowledgeFilesResponse( return KnowledgeFilesResponse(
**knowledge.model_dump(), **knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
)
############################
# ExportKnowledgeById
############################
@router.get("/{id}/export")
async def export_knowledge_by_id(
id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
"""
Export a knowledge base as a zip file containing .txt files.
Admin only.
"""
knowledge = Knowledges.get_knowledge_by_id(id=id, db=db)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
files = Knowledges.get_files_by_id(id, db=db)
# Create zip file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for file in files:
content = file.data.get("content", "") if file.data else ""
if content:
# Use original filename with .txt extension
filename = file.filename
if not filename.endswith(".txt"):
filename = f"{filename}.txt"
zf.writestr(filename, content)
zip_buffer.seek(0)
# Sanitize knowledge name for filename
safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in knowledge.name)
zip_filename = f"{safe_name}.zip"
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f"attachment; filename={zip_filename}"},
) )
+135 -13
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel from pydantic import BaseModel
import logging import logging
import asyncio import asyncio
@@ -7,7 +7,11 @@ from typing import Optional
from open_webui.models.memories import Memories, MemoryModel from open_webui.models.memories import Memories, MemoryModel
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.utils.auth import get_verified_user from open_webui.utils.auth import get_verified_user
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.utils.access_control import has_permission
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -25,8 +29,26 @@ async def get_embeddings(request: Request):
@router.get("/", response_model=list[MemoryModel]) @router.get("/", response_model=list[MemoryModel])
async def get_memories(user=Depends(get_verified_user)): async def get_memories(
return Memories.get_memories_by_user_id(user.id) request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return Memories.get_memories_by_user_id(user.id, db=db)
############################ ############################
@@ -47,8 +69,23 @@ async def add_memory(
request: Request, request: Request,
form_data: AddMemoryForm, form_data: AddMemoryForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
memory = Memories.insert_new_memory(user.id, form_data.content) if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = Memories.insert_new_memory(user.id, form_data.content, db=db)
vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user)
@@ -79,9 +116,26 @@ class QueryMemoryForm(BaseModel):
@router.post("/query") @router.post("/query")
async def query_memory( async def query_memory(
request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user) request: Request,
form_data: QueryMemoryForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
memories = Memories.get_memories_by_user_id(user.id) if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memories = Memories.get_memories_by_user_id(user.id, db=db)
if not memories: if not memories:
raise HTTPException(status_code=404, detail="No memories found for user") raise HTTPException(status_code=404, detail="No memories found for user")
@@ -101,11 +155,27 @@ async def query_memory(
############################ ############################
@router.post("/reset", response_model=bool) @router.post("/reset", response_model=bool)
async def reset_memory_from_vector_db( async def reset_memory_from_vector_db(
request: Request, user=Depends(get_verified_user) request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}") VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}")
memories = Memories.get_memories_by_user_id(user.id) memories = Memories.get_memories_by_user_id(user.id, db=db)
# Generate vectors in parallel # Generate vectors in parallel
vectors = await asyncio.gather( vectors = await asyncio.gather(
@@ -140,8 +210,26 @@ async def reset_memory_from_vector_db(
@router.delete("/delete/user", response_model=bool) @router.delete("/delete/user", response_model=bool)
async def delete_memory_by_user_id(user=Depends(get_verified_user)): async def delete_memory_by_user_id(
result = Memories.delete_memories_by_user_id(user.id) request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Memories.delete_memories_by_user_id(user.id, db=db)
if result: if result:
try: try:
@@ -164,9 +252,24 @@ async def update_memory_by_id(
request: Request, request: Request,
form_data: MemoryUpdateModel, form_data: MemoryUpdateModel,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
memory = Memories.update_memory_by_id_and_user_id( memory = Memories.update_memory_by_id_and_user_id(
memory_id, user.id, form_data.content memory_id, user.id, form_data.content, db=db
) )
if memory is None: if memory is None:
raise HTTPException(status_code=404, detail="Memory not found") raise HTTPException(status_code=404, detail="Memory not found")
@@ -198,8 +301,27 @@ async def update_memory_by_id(
@router.delete("/{memory_id}", response_model=bool) @router.delete("/{memory_id}", response_model=bool)
async def delete_memory_by_id(memory_id: str, user=Depends(get_verified_user)): async def delete_memory_by_id(
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id) memory_id: str,
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_MEMORIES:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not has_permission(
user.id, "features.memories", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db)
if result: if result:
VECTOR_DB_CLIENT.delete( VECTOR_DB_CLIENT.delete(
+121 -52
View File
@@ -11,6 +11,8 @@ from open_webui.models.models import (
ModelModel, ModelModel,
ModelResponse, ModelResponse,
ModelListResponse, ModelListResponse,
ModelAccessListResponse,
ModelAccessResponse,
Models, Models,
) )
@@ -30,6 +32,8 @@ from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -49,7 +53,7 @@ PAGE_ITEM_COUNT = 30
@router.get( @router.get(
"/list", response_model=ModelListResponse "/list", response_model=ModelAccessListResponse
) # do NOT use "/" as path, conflicts with main.py ) # do NOT use "/" as path, conflicts with main.py
async def get_models( async def get_models(
query: Optional[str] = None, query: Optional[str] = None,
@@ -59,6 +63,7 @@ async def get_models(
direction: Optional[str] = None, direction: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
@@ -79,13 +84,27 @@ async def get_models(
filter["direction"] = direction filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id) groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in groups] filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id filter["user_id"] = user.id
return Models.search_models(user.id, filter=filter, skip=skip, limit=limit) result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
return ModelAccessListResponse(
items=[
ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
),
)
for model in result.items
],
total=result.total,
)
########################### ###########################
@@ -94,8 +113,10 @@ async def get_models(
@router.get("/base", response_model=list[ModelResponse]) @router.get("/base", response_model=list[ModelResponse])
async def get_base_models(user=Depends(get_admin_user)): async def get_base_models(
return Models.get_base_models() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Models.get_base_models(db=db)
########################### ###########################
@@ -104,11 +125,13 @@ async def get_base_models(user=Depends(get_admin_user)):
@router.get("/tags", response_model=list[str]) @router.get("/tags", response_model=list[str])
async def get_model_tags(user=Depends(get_verified_user)): async def get_model_tags(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
models = Models.get_models() models = Models.get_models(db=db)
else: else:
models = Models.get_models_by_user_id(user.id) models = Models.get_models_by_user_id(user.id, db=db)
tags_set = set() tags_set = set()
for model in models: for model in models:
@@ -132,16 +155,17 @@ async def create_new_model(
request: Request, request: Request,
form_data: ModelForm, form_data: ModelForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
model = Models.get_model_by_id(form_data.id) model = Models.get_model_by_id(form_data.id, db=db)
if model: if model:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -155,7 +179,7 @@ async def create_new_model(
) )
else: else:
model = Models.insert_new_model(form_data, user.id) model = Models.insert_new_model(form_data, user.id, db=db)
if model: if model:
return model return model
else: else:
@@ -171,9 +195,16 @@ async def create_new_model(
@router.get("/export", response_model=list[ModelModel]) @router.get("/export", response_model=list[ModelModel])
async def export_models(request: Request, user=Depends(get_verified_user)): async def export_models(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "workspace.models_export", request.app.state.config.USER_PERMISSIONS user.id,
"workspace.models_export",
request.app.state.config.USER_PERMISSIONS,
db=db,
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -181,9 +212,9 @@ async def export_models(request: Request, user=Depends(get_verified_user)):
) )
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return Models.get_models() return Models.get_models(db=db)
else: else:
return Models.get_models_by_user_id(user.id) return Models.get_models_by_user_id(user.id, db=db)
############################ ############################
@@ -200,9 +231,13 @@ async def import_models(
request: Request, request: Request,
user=Depends(get_verified_user), user=Depends(get_verified_user),
form_data: ModelsImportForm = (...), form_data: ModelsImportForm = (...),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "workspace.models_import", request.app.state.config.USER_PERMISSIONS user.id,
"workspace.models_import",
request.app.state.config.USER_PERMISSIONS,
db=db,
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -216,7 +251,7 @@ async def import_models(
model_id = model_data.get("id") model_id = model_data.get("id")
if model_id and is_valid_model_id(model_id): if model_id and is_valid_model_id(model_id):
existing_model = Models.get_model_by_id(model_id) existing_model = Models.get_model_by_id(model_id, db=db)
if existing_model: if existing_model:
# Update existing model # Update existing model
model_data["meta"] = model_data.get("meta", {}) model_data["meta"] = model_data.get("meta", {})
@@ -225,13 +260,15 @@ async def import_models(
updated_model = ModelForm( updated_model = ModelForm(
**{**existing_model.model_dump(), **model_data} **{**existing_model.model_dump(), **model_data}
) )
Models.update_model_by_id(model_id, updated_model) Models.update_model_by_id(model_id, updated_model, db=db)
else: else:
# Insert new model # Insert new model
model_data["meta"] = model_data.get("meta", {}) model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {}) model_data["params"] = model_data.get("params", {})
new_model = ModelForm(**model_data) new_model = ModelForm(**model_data)
Models.insert_new_model(user_id=user.id, form_data=new_model) Models.insert_new_model(
user_id=user.id, form_data=new_model, db=db
)
return True return True
else: else:
raise HTTPException(status_code=400, detail="Invalid JSON format") raise HTTPException(status_code=400, detail="Invalid JSON format")
@@ -251,9 +288,12 @@ class SyncModelsForm(BaseModel):
@router.post("/sync", response_model=list[ModelModel]) @router.post("/sync", response_model=list[ModelModel])
async def sync_models( async def sync_models(
request: Request, form_data: SyncModelsForm, user=Depends(get_admin_user) request: Request,
form_data: SyncModelsForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
return Models.sync_models(user.id, form_data.models) return Models.sync_models(user.id, form_data.models, db=db)
########################### ###########################
@@ -266,19 +306,33 @@ class ModelIdForm(BaseModel):
# Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id # Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id
@router.get("/model", response_model=Optional[ModelResponse]) @router.get("/model", response_model=Optional[ModelAccessResponse])
async def get_model_by_id(id: str, user=Depends(get_verified_user)): async def get_model_by_id(
model = Models.get_model_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
model = Models.get_model_by_id(id, db=db)
if model: if model:
if ( if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or model.user_id == user.id or model.user_id == user.id
or has_access(user.id, "read", model.access_control) or has_access(user.id, "read", model.access_control, db=db)
): ):
return model return ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
@@ -289,38 +343,42 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/model/profile/image") @router.get("/model/profile/image")
async def get_model_profile_image(id: str, user=Depends(get_verified_user)): def get_model_profile_image(
model = Models.get_model_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
# Cache-control headers to prevent stale cached images ):
cache_headers = {"Cache-Control": "no-cache, must-revalidate"} model = Models.get_model_by_id(id, db=db)
if model: if model:
etag = f'"{model.updated_at}"' if model.updated_at else None
if model.meta.profile_image_url: if model.meta.profile_image_url:
if model.meta.profile_image_url.startswith("http"): if model.meta.profile_image_url.startswith("http"):
return Response( return Response(
status_code=status.HTTP_302_FOUND, status_code=status.HTTP_302_FOUND,
headers={"Location": model.meta.profile_image_url, **cache_headers}, headers={"Location": model.meta.profile_image_url},
) )
elif model.meta.profile_image_url.startswith("data:image"): elif model.meta.profile_image_url.startswith("data:image"):
try: try:
header, base64_data = model.meta.profile_image_url.split(",", 1) header, base64_data = model.meta.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data) image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data) image_buffer = io.BytesIO(image_data)
media_type = header.split(";")[0].lstrip("data:")
headers = {"Content-Disposition": "inline"}
if etag:
headers["ETag"] = etag
return StreamingResponse( return StreamingResponse(
image_buffer, image_buffer,
media_type="image/png", media_type=media_type,
headers={ headers=headers,
"Content-Disposition": "inline; filename=image.png",
**cache_headers,
},
) )
except Exception as e: except Exception as e:
pass pass
return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers) return FileResponse(f"{STATIC_DIR}/favicon.png")
else: else:
return FileResponse(f"{STATIC_DIR}/favicon.png", headers=cache_headers) return FileResponse(f"{STATIC_DIR}/favicon.png")
############################ ############################
@@ -329,15 +387,17 @@ async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
@router.post("/model/toggle", response_model=Optional[ModelResponse]) @router.post("/model/toggle", response_model=Optional[ModelResponse])
async def toggle_model_by_id(id: str, user=Depends(get_verified_user)): async def toggle_model_by_id(
model = Models.get_model_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
model = Models.get_model_by_id(id, db=db)
if model: if model:
if ( if (
user.role == "admin" user.role == "admin"
or model.user_id == user.id or model.user_id == user.id
or has_access(user.id, "write", model.access_control) or has_access(user.id, "write", model.access_control, db=db)
): ):
model = Models.toggle_model_by_id(id) model = Models.toggle_model_by_id(id, db=db)
if model: if model:
return model return model
@@ -367,8 +427,9 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user)):
async def update_model_by_id( async def update_model_by_id(
form_data: ModelForm, form_data: ModelForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
model = Models.get_model_by_id(form_data.id) model = Models.get_model_by_id(form_data.id, db=db)
if not model: if not model:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -377,7 +438,7 @@ async def update_model_by_id(
if ( if (
model.user_id != user.id model.user_id != user.id
and not has_access(user.id, "write", model.access_control) and not has_access(user.id, "write", model.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -385,7 +446,9 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump())) model = Models.update_model_by_id(
form_data.id, ModelForm(**form_data.model_dump()), db=db
)
return model return model
@@ -395,8 +458,12 @@ async def update_model_by_id(
@router.post("/model/delete", response_model=bool) @router.post("/model/delete", response_model=bool)
async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_user)): async def delete_model_by_id(
model = Models.get_model_by_id(form_data.id) form_data: ModelIdForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
model = Models.get_model_by_id(form_data.id, db=db)
if not model: if not model:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -406,18 +473,20 @@ async def delete_model_by_id(form_data: ModelIdForm, user=Depends(get_verified_u
if ( if (
user.role != "admin" user.role != "admin"
and model.user_id != user.id and model.user_id != user.id
and not has_access(user.id, "write", model.access_control) and not has_access(user.id, "write", model.access_control, db=db)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
result = Models.delete_model_by_id(form_data.id) result = Models.delete_model_by_id(form_data.id, db=db)
return result return result
@router.delete("/delete/all", response_model=bool) @router.delete("/delete/all", response_model=bool)
async def delete_all_models(user=Depends(get_admin_user)): async def delete_all_models(
result = Models.delete_all_models() user=Depends(get_admin_user), db: Session = Depends(get_session)
):
result = Models.delete_all_models(db=db)
return result return result
+70 -27
View File
@@ -28,6 +28,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -49,10 +51,13 @@ class NoteItemResponse(BaseModel):
@router.get("/", response_model=list[NoteItemResponse]) @router.get("/", response_model=list[NoteItemResponse])
async def get_notes( async def get_notes(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user) request: Request,
page: Optional[int] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -65,16 +70,23 @@ async def get_notes(
limit = 60 limit = 60
skip = (page - 1) * limit skip = (page - 1) * limit
notes = [ notes = Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit, db=db)
if not notes:
return []
user_ids = list(set(note.user_id for note in notes))
users = {user.id: user for user in Users.get_users_by_user_ids(user_ids, db=db)}
return [
NoteUserResponse( NoteUserResponse(
**{ **{
**note.model_dump(), **note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), "user": UserResponse(**users[note.user_id].model_dump()),
} }
) )
for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit) for note in notes
if note.user_id in users
] ]
return notes
@router.get("/search", response_model=NoteListResponse) @router.get("/search", response_model=NoteListResponse)
@@ -87,9 +99,10 @@ async def search_notes(
direction: Optional[str] = None, direction: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -115,13 +128,13 @@ async def search_notes(
filter["direction"] = direction filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id) groups = Groups.get_groups_by_member_id(user.id, db=db)
if groups: if groups:
filter["group_ids"] = [group.id for group in groups] filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit) return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
############################ ############################
@@ -131,10 +144,13 @@ async def search_notes(
@router.post("/create", response_model=Optional[NoteModel]) @router.post("/create", response_model=Optional[NoteModel])
async def create_new_note( async def create_new_note(
request: Request, form_data: NoteForm, user=Depends(get_verified_user) request: Request,
form_data: NoteForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -142,7 +158,7 @@ async def create_new_note(
) )
try: try:
note = Notes.insert_new_note(form_data, user.id) note = Notes.insert_new_note(user.id, form_data, db=db)
return note return note
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
@@ -161,16 +177,21 @@ class NoteResponse(NoteModel):
@router.get("/{id}", response_model=Optional[NoteResponse]) @router.get("/{id}", response_model=Optional[NoteResponse])
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): async def get_note_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
note = Notes.get_note_by_id(id) note = Notes.get_note_by_id(id, db=db)
if not note: if not note:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
@@ -178,7 +199,11 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
if user.role != "admin" and ( if user.role != "admin" and (
user.id != note.user_id user.id != note.user_id
and (not has_access(user.id, type="read", access_control=note.access_control)) and (
not has_access(
user.id, type="read", access_control=note.access_control, db=db
)
)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -188,7 +213,11 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
user.role == "admin" user.role == "admin"
or (user.id == note.user_id) or (user.id == note.user_id)
or has_access( or has_access(
user.id, type="write", access_control=note.access_control, strict=False user.id,
type="write",
access_control=note.access_control,
strict=False,
db=db,
) )
) )
@@ -202,17 +231,21 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
@router.post("/{id}/update", response_model=Optional[NoteModel]) @router.post("/{id}/update", response_model=Optional[NoteModel])
async def update_note_by_id( async def update_note_by_id(
request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user) request: Request,
id: str,
form_data: NoteForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
note = Notes.get_note_by_id(id) note = Notes.get_note_by_id(id, db=db)
if not note: if not note:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
@@ -220,7 +253,9 @@ async def update_note_by_id(
if user.role != "admin" and ( if user.role != "admin" and (
user.id != note.user_id user.id != note.user_id
and not has_access(user.id, type="write", access_control=note.access_control) and not has_access(
user.id, type="write", access_control=note.access_control, db=db
)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@@ -234,12 +269,13 @@ async def update_note_by_id(
user.id, user.id,
"sharing.public_notes", "sharing.public_notes",
request.app.state.config.USER_PERMISSIONS, request.app.state.config.USER_PERMISSIONS,
db=db,
) )
): ):
form_data.access_control = {} form_data.access_control = {}
try: try:
note = Notes.update_note_by_id(id, form_data) note = Notes.update_note_by_id(id, form_data, db=db)
await sio.emit( await sio.emit(
"note-events", "note-events",
note.model_dump(), note.model_dump(),
@@ -260,16 +296,21 @@ async def update_note_by_id(
@router.delete("/{id}/delete", response_model=bool) @router.delete("/{id}/delete", response_model=bool)
async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): async def delete_note_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
note = Notes.get_note_by_id(id) note = Notes.get_note_by_id(id, db=db)
if not note: if not note:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
@@ -277,14 +318,16 @@ async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified
if user.role != "admin" and ( if user.role != "admin" and (
user.id != note.user_id user.id != note.user_id
and not has_access(user.id, type="write", access_control=note.access_control) and not has_access(
user.id, type="write", access_control=note.access_control, db=db
)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
) )
try: try:
note = Notes.delete_note_by_id(id) note = Notes.delete_note_by_id(id, db=db)
return True return True
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
+34 -14
View File
@@ -38,6 +38,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, validator from pydantic import BaseModel, ConfigDict, validator
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models from open_webui.models.models import Models
@@ -421,14 +424,14 @@ async def get_all_models(request: Request, user: UserModel = None):
return models return models
async def get_filtered_models(models, user): async def get_filtered_models(models, user, db=None):
# Filter models based on user access control # Filter models based on user access control
filtered_models = [] filtered_models = []
for model in models.get("models", []): for model in models.get("models", []):
model_info = Models.get_model_by_id(model["model"]) model_info = Models.get_model_by_id(model["model"], db=db)
if model_info: if model_info:
if user.id == model_info.user_id or has_access( if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control user.id, type="read", access_control=model_info.access_control, db=db
): ):
filtered_models.append(model) filtered_models.append(model)
return filtered_models return filtered_models
@@ -1253,6 +1256,8 @@ async def generate_chat_completion(
url_idx: Optional[int] = None, url_idx: Optional[int] = None,
user=Depends(get_verified_user), user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False, bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
db: Session = Depends(get_session),
): ):
if BYPASS_MODEL_ACCESS_CONTROL: if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True bypass_filter = True
@@ -1274,7 +1279,7 @@ async def generate_chat_completion(
del payload["metadata"] del payload["metadata"]
model_id = payload["model"] model_id = payload["model"]
model_info = Models.get_model_by_id(model_id) model_info = Models.get_model_by_id(model_id, db=db)
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
@@ -1291,14 +1296,18 @@ async def generate_chat_completion(
system = params.pop("system", None) system = params.pop("system", None)
payload = apply_model_params_to_body_ollama(params, payload) payload = apply_model_params_to_body_ollama(params, payload)
payload = apply_system_prompt_to_body(system, payload, metadata, user) if not bypass_system_prompt:
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model # Check if user has access to the model
if not bypass_filter and user.role == "user": if not bypass_filter and user.role == "user":
if not ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or has_access( or has_access(
user.id, type="read", access_control=model_info.access_control user.id,
type="read",
access_control=model_info.access_control,
db=db,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1370,6 +1379,7 @@ async def generate_openai_completion(
form_data: dict, form_data: dict,
url_idx: Optional[int] = None, url_idx: Optional[int] = None,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
metadata = form_data.pop("metadata", None) metadata = form_data.pop("metadata", None)
@@ -1390,7 +1400,7 @@ async def generate_openai_completion(
if ":" not in model_id: if ":" not in model_id:
model_id = f"{model_id}:latest" model_id = f"{model_id}:latest"
model_info = Models.get_model_by_id(model_id) model_info = Models.get_model_by_id(model_id, db=db)
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
payload["model"] = model_info.base_model_id payload["model"] = model_info.base_model_id
@@ -1404,7 +1414,10 @@ async def generate_openai_completion(
if not ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or has_access( or has_access(
user.id, type="read", access_control=model_info.access_control user.id,
type="read",
access_control=model_info.access_control,
db=db,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1449,6 +1462,7 @@ async def generate_openai_chat_completion(
form_data: dict, form_data: dict,
url_idx: Optional[int] = None, url_idx: Optional[int] = None,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
metadata = form_data.pop("metadata", None) metadata = form_data.pop("metadata", None)
@@ -1469,7 +1483,7 @@ async def generate_openai_chat_completion(
if ":" not in model_id: if ":" not in model_id:
model_id = f"{model_id}:latest" model_id = f"{model_id}:latest"
model_info = Models.get_model_by_id(model_id) model_info = Models.get_model_by_id(model_id, db=db)
if model_info: if model_info:
if model_info.base_model_id: if model_info.base_model_id:
payload["model"] = model_info.base_model_id payload["model"] = model_info.base_model_id
@@ -1487,7 +1501,10 @@ async def generate_openai_chat_completion(
if not ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or has_access( or has_access(
user.id, type="read", access_control=model_info.access_control user.id,
type="read",
access_control=model_info.access_control,
db=db,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -1530,6 +1547,7 @@ async def get_openai_models(
request: Request, request: Request,
url_idx: Optional[int] = None, url_idx: Optional[int] = None,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
models = [] models = []
@@ -1582,10 +1600,13 @@ async def get_openai_models(
# Filter models based on user access control # Filter models based on user access control
filtered_models = [] filtered_models = []
for model in models: for model in models:
model_info = Models.get_model_by_id(model["id"]) model_info = Models.get_model_by_id(model["id"], db=db)
if model_info: if model_info:
if user.id == model_info.user_id or has_access( if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control user.id,
type="read",
access_control=model_info.access_control,
db=db,
): ):
filtered_models.append(model) filtered_models.append(model)
models = filtered_models models = filtered_models
@@ -1652,11 +1673,10 @@ async def download_file_stream(
if done: if done:
file.close() file.close()
hashed = calculate_sha256(file_path, chunk_size)
with open(file_path, "rb") as file: with open(file_path, "rb") as file:
chunk_size = 1024 * 1024 * 2 chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
url = f"{ollama_url}/api/blobs/sha256:{hashed}" url = f"{ollama_url}/api/blobs/sha256:{hashed}"
with requests.Session() as session: with requests.Session() as session:
response = session.post(url, data=file, timeout=30) response = session.post(url, data=file, timeout=30)
+15 -6
View File
@@ -19,6 +19,9 @@ from fastapi.responses import (
) )
from pydantic import BaseModel from pydantic import BaseModel
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.models import Models from open_webui.models.models import Models
from open_webui.config import ( from open_webui.config import (
@@ -453,14 +456,14 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
return responses return responses
async def get_filtered_models(models, user): async def get_filtered_models(models, user, db=None):
# Filter models based on user access control # Filter models based on user access control
filtered_models = [] filtered_models = []
for model in models.get("data", []): for model in models.get("data", []):
model_info = Models.get_model_by_id(model["id"]) model_info = Models.get_model_by_id(model["id"], db=db)
if model_info: if model_info:
if user.id == model_info.user_id or has_access( if user.id == model_info.user_id or has_access(
user.id, type="read", access_control=model_info.access_control user.id, type="read", access_control=model_info.access_control, db=db
): ):
filtered_models.append(model) filtered_models.append(model)
return filtered_models return filtered_models
@@ -797,6 +800,8 @@ async def generate_chat_completion(
form_data: dict, form_data: dict,
user=Depends(get_verified_user), user=Depends(get_verified_user),
bypass_filter: Optional[bool] = False, bypass_filter: Optional[bool] = False,
bypass_system_prompt: bool = False,
db: Session = Depends(get_session),
): ):
if BYPASS_MODEL_ACCESS_CONTROL: if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True bypass_filter = True
@@ -807,7 +812,7 @@ async def generate_chat_completion(
metadata = payload.pop("metadata", None) metadata = payload.pop("metadata", None)
model_id = form_data.get("model") model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id) model_info = Models.get_model_by_id(model_id, db=db)
# Check model info and override the payload # Check model info and override the payload
if model_info: if model_info:
@@ -826,14 +831,18 @@ async def generate_chat_completion(
system = params.pop("system", None) system = params.pop("system", None)
payload = apply_model_params_to_body_openai(params, payload) payload = apply_model_params_to_body_openai(params, payload)
payload = apply_system_prompt_to_body(system, payload, metadata, user) if not bypass_system_prompt:
payload = apply_system_prompt_to_body(system, payload, metadata, user)
# Check if user has access to the model # Check if user has access to the model
if not bypass_filter and user.role == "user": if not bypass_filter and user.role == "user":
if not ( if not (
user.id == model_info.user_id user.id == model_info.user_id
or has_access( or has_access(
user.id, type="read", access_control=model_info.access_control user.id,
type="read",
access_control=model_info.access_control,
db=db,
) )
): ):
raise HTTPException( raise HTTPException(
+60 -24
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request
from open_webui.models.prompts import ( from open_webui.models.prompts import (
PromptForm, PromptForm,
PromptUserResponse, PromptUserResponse,
PromptAccessResponse,
PromptModel, PromptModel,
Prompts, Prompts,
) )
@@ -11,6 +12,8 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
router = APIRouter() router = APIRouter()
@@ -20,23 +23,37 @@ router = APIRouter()
@router.get("/", response_model=list[PromptModel]) @router.get("/", response_model=list[PromptModel])
async def get_prompts(user=Depends(get_verified_user)): async def get_prompts(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts() prompts = Prompts.get_prompts(db=db)
else: else:
prompts = Prompts.get_prompts_by_user_id(user.id, "read") prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
return prompts return prompts
@router.get("/list", response_model=list[PromptUserResponse]) @router.get("/list", response_model=list[PromptAccessResponse])
async def get_prompt_list(user=Depends(get_verified_user)): async def get_prompt_list(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
prompts = Prompts.get_prompts() prompts = Prompts.get_prompts(db=db)
else: else:
prompts = Prompts.get_prompts_by_user_id(user.id, "write") prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
return prompts return [
PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
for prompt in prompts
]
############################ ############################
@@ -46,16 +63,23 @@ async def get_prompt_list(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[PromptModel]) @router.post("/create", response_model=Optional[PromptModel])
async def create_new_prompt( async def create_new_prompt(
request: Request, form_data: PromptForm, user=Depends(get_verified_user) request: Request,
form_data: PromptForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not ( if user.role != "admin" and not (
has_permission( has_permission(
user.id, "workspace.prompts", request.app.state.config.USER_PERMISSIONS user.id,
"workspace.prompts",
request.app.state.config.USER_PERMISSIONS,
db=db,
) )
or has_permission( or has_permission(
user.id, user.id,
"workspace.prompts_import", "workspace.prompts_import",
request.app.state.config.USER_PERMISSIONS, request.app.state.config.USER_PERMISSIONS,
db=db,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -63,9 +87,9 @@ async def create_new_prompt(
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
prompt = Prompts.get_prompt_by_command(form_data.command) prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if prompt is None: if prompt is None:
prompt = Prompts.insert_new_prompt(user.id, form_data) prompt = Prompts.insert_new_prompt(user.id, form_data, db=db)
if prompt: if prompt:
return prompt return prompt
@@ -84,17 +108,26 @@ async def create_new_prompt(
############################ ############################
@router.get("/command/{command}", response_model=Optional[PromptModel]) @router.get("/command/{command}", response_model=Optional[PromptAccessResponse])
async def get_prompt_by_command(command: str, user=Depends(get_verified_user)): async def get_prompt_by_command(
prompt = Prompts.get_prompt_by_command(f"/{command}") command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
if prompt: if prompt:
if ( if (
user.role == "admin" user.role == "admin"
or prompt.user_id == user.id or prompt.user_id == user.id
or has_access(user.id, "read", prompt.access_control) or has_access(user.id, "read", prompt.access_control, db=db)
): ):
return prompt return PromptAccessResponse(
**prompt.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == prompt.user_id
or has_access(user.id, "write", prompt.access_control, db=db)
),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -112,8 +145,9 @@ async def update_prompt_by_command(
command: str, command: str,
form_data: PromptForm, form_data: PromptForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
prompt = Prompts.get_prompt_by_command(f"/{command}") prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
if not prompt: if not prompt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -123,7 +157,7 @@ async def update_prompt_by_command(
# Is the user the original creator, in a group with write access, or an admin # Is the user the original creator, in a group with write access, or an admin
if ( if (
prompt.user_id != user.id prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control) and not has_access(user.id, "write", prompt.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -131,7 +165,7 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
prompt = Prompts.update_prompt_by_command(f"/{command}", form_data) prompt = Prompts.update_prompt_by_command(f"/{command}", form_data, db=db)
if prompt: if prompt:
return prompt return prompt
else: else:
@@ -147,8 +181,10 @@ async def update_prompt_by_command(
@router.delete("/command/{command}/delete", response_model=bool) @router.delete("/command/{command}/delete", response_model=bool)
async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)): async def delete_prompt_by_command(
prompt = Prompts.get_prompt_by_command(f"/{command}") command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_command(f"/{command}", db=db)
if not prompt: if not prompt:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -157,7 +193,7 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)
if ( if (
prompt.user_id != user.id prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control) and not has_access(user.id, "write", prompt.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -165,5 +201,5 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
result = Prompts.delete_prompt_by_command(f"/{command}") result = Prompts.delete_prompt_by_command(f"/{command}", db=db)
return result return result
+189 -56
View File
@@ -39,6 +39,8 @@ from langchain_core.documents import Document
from open_webui.models.files import FileModel, FileUpdateForm, Files from open_webui.models.files import FileModel, FileUpdateForm, Files
from open_webui.models.knowledge import Knowledges from open_webui.models.knowledge import Knowledges
from open_webui.storage.provider import Storage from open_webui.storage.provider import Storage
from open_webui.internal.db import get_session
from sqlalchemy.orm import Session
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
@@ -91,6 +93,7 @@ from open_webui.utils.misc import (
sanitize_text_for_db, sanitize_text_for_db,
) )
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.config import ( from open_webui.config import (
ENV, ENV,
@@ -110,6 +113,7 @@ from open_webui.env import (
SENTENCE_TRANSFORMERS_MODEL_KWARGS, SENTENCE_TRANSFORMERS_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION,
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
@@ -188,6 +192,7 @@ def get_rf(
raise Exception(ERROR_MESSAGES.DEFAULT(e)) raise Exception(ERROR_MESSAGES.DEFAULT(e))
else: else:
import sentence_transformers import sentence_transformers
import torch
try: try:
rf = sentence_transformers.CrossEncoder( rf = sentence_transformers.CrossEncoder(
@@ -196,6 +201,11 @@ def get_rf(
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE, trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND, backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS, model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
activation_fn=(
torch.nn.Sigmoid()
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION
else None
),
) )
except Exception as e: except Exception as e:
log.error(f"CrossEncoder: {e}") log.error(f"CrossEncoder: {e}")
@@ -494,7 +504,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, "RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT,
# Chunking settings # Chunking settings
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
"CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET,
"CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP,
# File upload settings # File upload settings
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
@@ -532,12 +544,14 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS, "SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS,
"SERPER_API_KEY": request.app.state.config.SERPER_API_KEY, "SERPER_API_KEY": request.app.state.config.SERPER_API_KEY,
"SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY, "SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY,
"DDGS_BACKEND": request.app.state.config.DDGS_BACKEND,
"TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY, "TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY,
"SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY, "SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY,
"SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE, "SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE,
"SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY,
"SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE,
"JINA_API_KEY": request.app.state.config.JINA_API_KEY, "JINA_API_KEY": request.app.state.config.JINA_API_KEY,
"JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL,
"BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"EXA_API_KEY": request.app.state.config.EXA_API_KEY, "EXA_API_KEY": request.app.state.config.EXA_API_KEY,
@@ -554,6 +568,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
"FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
"FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
"FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT,
"TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
"EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
"EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
@@ -592,12 +607,14 @@ class WebConfig(BaseModel):
SERPSTACK_HTTPS: Optional[bool] = None SERPSTACK_HTTPS: Optional[bool] = None
SERPER_API_KEY: Optional[str] = None SERPER_API_KEY: Optional[str] = None
SERPLY_API_KEY: Optional[str] = None SERPLY_API_KEY: Optional[str] = None
DDGS_BACKEND: Optional[str] = None
TAVILY_API_KEY: Optional[str] = None TAVILY_API_KEY: Optional[str] = None
SEARCHAPI_API_KEY: Optional[str] = None SEARCHAPI_API_KEY: Optional[str] = None
SEARCHAPI_ENGINE: Optional[str] = None SEARCHAPI_ENGINE: Optional[str] = None
SERPAPI_API_KEY: Optional[str] = None SERPAPI_API_KEY: Optional[str] = None
SERPAPI_ENGINE: Optional[str] = None SERPAPI_ENGINE: Optional[str] = None
JINA_API_KEY: Optional[str] = None JINA_API_KEY: Optional[str] = None
JINA_API_BASE_URL: Optional[str] = None
BING_SEARCH_V7_ENDPOINT: Optional[str] = None BING_SEARCH_V7_ENDPOINT: Optional[str] = None
BING_SEARCH_V7_SUBSCRIPTION_KEY: Optional[str] = None BING_SEARCH_V7_SUBSCRIPTION_KEY: Optional[str] = None
EXA_API_KEY: Optional[str] = None EXA_API_KEY: Optional[str] = None
@@ -614,6 +631,7 @@ class WebConfig(BaseModel):
PLAYWRIGHT_TIMEOUT: Optional[int] = None PLAYWRIGHT_TIMEOUT: Optional[int] = None
FIRECRAWL_API_KEY: Optional[str] = None FIRECRAWL_API_KEY: Optional[str] = None
FIRECRAWL_API_BASE_URL: Optional[str] = None FIRECRAWL_API_BASE_URL: Optional[str] = None
FIRECRAWL_TIMEOUT: Optional[str] = None
TAVILY_EXTRACT_DEPTH: Optional[str] = None TAVILY_EXTRACT_DEPTH: Optional[str] = None
EXTERNAL_WEB_SEARCH_URL: Optional[str] = None EXTERNAL_WEB_SEARCH_URL: Optional[str] = None
EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None
@@ -683,7 +701,9 @@ class ConfigForm(BaseModel):
# Chunking settings # Chunking settings
TEXT_SPLITTER: Optional[str] = None TEXT_SPLITTER: Optional[str] = None
ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: Optional[bool] = None
CHUNK_SIZE: Optional[int] = None CHUNK_SIZE: Optional[int] = None
CHUNK_MIN_SIZE_TARGET: Optional[int] = None
CHUNK_OVERLAP: Optional[int] = None CHUNK_OVERLAP: Optional[int] = None
# File upload settings # File upload settings
@@ -991,6 +1011,11 @@ async def update_rag_config(
if form_data.CHUNK_SIZE is not None if form_data.CHUNK_SIZE is not None
else request.app.state.config.CHUNK_SIZE else request.app.state.config.CHUNK_SIZE
) )
request.app.state.config.CHUNK_MIN_SIZE_TARGET = (
form_data.CHUNK_MIN_SIZE_TARGET
if form_data.CHUNK_MIN_SIZE_TARGET is not None
else request.app.state.config.CHUNK_MIN_SIZE_TARGET
)
request.app.state.config.CHUNK_OVERLAP = ( request.app.state.config.CHUNK_OVERLAP = (
form_data.CHUNK_OVERLAP form_data.CHUNK_OVERLAP
if form_data.CHUNK_OVERLAP is not None if form_data.CHUNK_OVERLAP is not None
@@ -1075,12 +1100,14 @@ async def update_rag_config(
request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS
request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY
request.app.state.config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY request.app.state.config.SERPLY_API_KEY = form_data.web.SERPLY_API_KEY
request.app.state.config.DDGS_BACKEND = form_data.web.DDGS_BACKEND
request.app.state.config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY request.app.state.config.TAVILY_API_KEY = form_data.web.TAVILY_API_KEY
request.app.state.config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY request.app.state.config.SEARCHAPI_API_KEY = form_data.web.SEARCHAPI_API_KEY
request.app.state.config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE request.app.state.config.SEARCHAPI_ENGINE = form_data.web.SEARCHAPI_ENGINE
request.app.state.config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY request.app.state.config.SERPAPI_API_KEY = form_data.web.SERPAPI_API_KEY
request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE
request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY
request.app.state.config.JINA_API_BASE_URL = form_data.web.JINA_API_BASE_URL
request.app.state.config.BING_SEARCH_V7_ENDPOINT = ( request.app.state.config.BING_SEARCH_V7_ENDPOINT = (
form_data.web.BING_SEARCH_V7_ENDPOINT form_data.web.BING_SEARCH_V7_ENDPOINT
) )
@@ -1112,6 +1139,7 @@ async def update_rag_config(
request.app.state.config.FIRECRAWL_API_BASE_URL = ( request.app.state.config.FIRECRAWL_API_BASE_URL = (
form_data.web.FIRECRAWL_API_BASE_URL form_data.web.FIRECRAWL_API_BASE_URL
) )
request.app.state.config.FIRECRAWL_TIMEOUT = form_data.web.FIRECRAWL_TIMEOUT
request.app.state.config.EXTERNAL_WEB_SEARCH_URL = ( request.app.state.config.EXTERNAL_WEB_SEARCH_URL = (
form_data.web.EXTERNAL_WEB_SEARCH_URL form_data.web.EXTERNAL_WEB_SEARCH_URL
) )
@@ -1188,6 +1216,8 @@ async def update_rag_config(
# Chunking settings # Chunking settings
"TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER,
"CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE,
"CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET,
"ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER,
"CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP,
# File upload settings # File upload settings
"FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE,
@@ -1231,6 +1261,7 @@ async def update_rag_config(
"SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY,
"SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE,
"JINA_API_KEY": request.app.state.config.JINA_API_KEY, "JINA_API_KEY": request.app.state.config.JINA_API_KEY,
"JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL,
"BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"EXA_API_KEY": request.app.state.config.EXA_API_KEY, "EXA_API_KEY": request.app.state.config.EXA_API_KEY,
@@ -1247,6 +1278,7 @@ async def update_rag_config(
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
"FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
"FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
"FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT,
"TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
"EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
"EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
@@ -1266,6 +1298,85 @@ async def update_rag_config(
#################################### ####################################
def can_merge_chunks(a: Document, b: Document) -> bool:
if a.metadata.get("source") != b.metadata.get("source"):
return False
a_file_id = a.metadata.get("file_id")
b_file_id = b.metadata.get("file_id")
if a_file_id is not None and b_file_id is not None:
return a_file_id == b_file_id
return True
def merge_docs_to_target_size(
request: Request,
chunks: list[Document],
) -> list[Document]:
"""
Best-effort normalization of chunk sizes.
Attempts to grow small chunks up to a desired minimum size,
without exceeding the maximum size or crossing source/file
boundaries.
"""
min_chunk_size_target = request.app.state.config.CHUNK_MIN_SIZE_TARGET
max_chunk_size = request.app.state.config.CHUNK_SIZE
if min_chunk_size_target <= 0:
return chunks
measure_chunk_size = len
if request.app.state.config.TEXT_SPLITTER == "token":
encoding = tiktoken.get_encoding(
str(request.app.state.config.TIKTOKEN_ENCODING_NAME)
)
measure_chunk_size = lambda text: len(encoding.encode(text))
processed_chunks: list[Document] = []
current_chunk: Document | None = None
current_content: str = ""
for next_chunk in chunks:
if current_chunk is None:
current_chunk = next_chunk
current_content = next_chunk.page_content
continue # First chunk initialization
proposed_content = f"{current_content}\n\n{next_chunk.page_content}"
can_merge = (
can_merge_chunks(current_chunk, next_chunk)
and measure_chunk_size(current_content) < min_chunk_size_target
and measure_chunk_size(proposed_content) <= max_chunk_size
)
if can_merge:
current_content = proposed_content
else:
processed_chunks.append(
Document(
page_content=current_content,
metadata={**current_chunk.metadata},
)
)
current_chunk = next_chunk
current_content = next_chunk.page_content
if current_chunk is not None:
processed_chunks.append(
Document(
page_content=current_content,
metadata={**current_chunk.metadata},
)
)
return processed_chunks
def save_docs_to_vector_db( def save_docs_to_vector_db(
request: Request, request: Request,
docs, docs,
@@ -1303,13 +1414,46 @@ def save_docs_to_vector_db(
filter={"hash": metadata["hash"]}, filter={"hash": metadata["hash"]},
) )
if result is not None: if result is not None and result.ids and len(result.ids) > 0:
existing_doc_ids = result.ids[0] existing_doc_ids = result.ids[0]
if existing_doc_ids: if existing_doc_ids:
log.info(f"Document with hash {metadata['hash']} already exists") log.info(f"Document with hash {metadata['hash']} already exists")
raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT) raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT)
if split: if split:
if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER:
log.info("Using markdown header text splitter")
# Define headers to split on - covering most common markdown header levels
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
("####", "Header 4"),
("#####", "Header 5"),
("######", "Header 6"),
],
strip_headers=False, # Keep headers in content for context
)
split_docs = []
for doc in docs:
split_docs.extend(
[
Document(
page_content=split_chunk.page_content,
metadata={**doc.metadata},
)
for split_chunk in markdown_splitter.split_text(
doc.page_content
)
]
)
docs = split_docs
if request.app.state.config.CHUNK_MIN_SIZE_TARGET > 0:
docs = merge_docs_to_target_size(request, docs)
if request.app.state.config.TEXT_SPLITTER in ["", "character"]: if request.app.state.config.TEXT_SPLITTER in ["", "character"]:
text_splitter = RecursiveCharacterTextSplitter( text_splitter = RecursiveCharacterTextSplitter(
chunk_size=request.app.state.config.CHUNK_SIZE, chunk_size=request.app.state.config.CHUNK_SIZE,
@@ -1330,52 +1474,6 @@ def save_docs_to_vector_db(
add_start_index=True, add_start_index=True,
) )
docs = text_splitter.split_documents(docs) docs = text_splitter.split_documents(docs)
elif request.app.state.config.TEXT_SPLITTER == "markdown_header":
log.info("Using markdown header text splitter")
# Define headers to split on - covering most common markdown header levels
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
("####", "Header 4"),
("#####", "Header 5"),
("######", "Header 6"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False, # Keep headers in content for context
)
md_split_docs = []
for doc in docs:
md_header_splits = markdown_splitter.split_text(doc.page_content)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=request.app.state.config.CHUNK_SIZE,
chunk_overlap=request.app.state.config.CHUNK_OVERLAP,
add_start_index=True,
)
md_header_splits = text_splitter.split_documents(md_header_splits)
# Convert back to Document objects, preserving original metadata
for split_chunk in md_header_splits:
headings_list = []
# Extract header values in order based on headers_to_split_on
for _, header_meta_key_name in headers_to_split_on:
if header_meta_key_name in split_chunk.metadata:
headings_list.append(
split_chunk.metadata[header_meta_key_name]
)
md_split_docs.append(
Document(
page_content=split_chunk.page_content,
metadata={**doc.metadata, "headings": headings_list},
)
)
docs = md_split_docs
else: else:
raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter")) raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter"))
@@ -1484,14 +1582,15 @@ def process_file(
request: Request, request: Request,
form_data: ProcessFileForm, form_data: ProcessFileForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
""" """
Process a file and save its content to the vector database. Process a file and save its content to the vector database.
""" """
if user.role == "admin": if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id) file = Files.get_file_by_id(form_data.file_id, db=db)
else: else:
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id) file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id, db=db)
if file: if file:
try: try:
@@ -1633,12 +1732,13 @@ def process_file(
Files.update_file_data_by_id( Files.update_file_data_by_id(
file.id, file.id,
{"content": text_content}, {"content": text_content},
db=db,
) )
hash = calculate_sha256_string(text_content) hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"}) Files.update_file_data_by_id(file.id, {"status": "completed"}, db=db)
Files.update_file_hash_by_id(file.id, hash, db=db)
return { return {
"status": True, "status": True,
"collection_name": None, "collection_name": None,
@@ -1667,12 +1767,15 @@ def process_file(
{ {
"collection_name": collection_name, "collection_name": collection_name,
}, },
db=db,
) )
Files.update_file_data_by_id( Files.update_file_data_by_id(
file.id, file.id,
{"status": "completed"}, {"status": "completed"},
db=db,
) )
Files.update_file_hash_by_id(file.id, hash, db=db)
return { return {
"status": True, "status": True,
@@ -1690,7 +1793,10 @@ def process_file(
Files.update_file_data_by_id( Files.update_file_data_by_id(
file.id, file.id,
{"status": "failed"}, {"status": "failed"},
db=db,
) )
# Clear the hash so the file can be re-uploaded after fixing the issue
Files.update_file_hash_by_id(file.id, None, db=db)
if "No pandoc was found" in str(e): if "No pandoc was found" in str(e):
raise HTTPException( raise HTTPException(
@@ -1972,6 +2078,7 @@ def search_web(
request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
backend=request.app.state.config.DDGS_BACKEND,
) )
elif engine == "tavily": elif engine == "tavily":
if request.app.state.config.TAVILY_API_KEY: if request.app.state.config.TAVILY_API_KEY:
@@ -2020,6 +2127,7 @@ def search_web(
request.app.state.config.JINA_API_KEY, request.app.state.config.JINA_API_KEY,
query, query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.JINA_API_BASE_URL,
) )
elif engine == "bing": elif engine == "bing":
return search_bing( return search_bing(
@@ -2106,6 +2214,19 @@ def search_web(
async def process_web_search( async def process_web_search(
request: Request, form_data: SearchForm, user=Depends(get_verified_user) request: Request, form_data: SearchForm, user=Depends(get_verified_user)
): ):
if not request.app.state.config.ENABLE_WEB_SEARCH:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if user.role != "admin" and not has_permission(
user.id, "features.web_search", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
urls = [] urls = []
result_items = [] result_items = []
@@ -2417,10 +2538,19 @@ class DeleteForm(BaseModel):
@router.post("/delete") @router.post("/delete")
def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin_user)): def delete_entries_from_collection(
form_data: DeleteForm,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
try: try:
if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name): if VECTOR_DB_CLIENT.has_collection(collection_name=form_data.collection_name):
file = Files.get_file_by_id(form_data.file_id) file = Files.get_file_by_id(form_data.file_id, db=db)
if not file:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
hash = file.hash hash = file.hash
VECTOR_DB_CLIENT.delete( VECTOR_DB_CLIENT.delete(
@@ -2436,9 +2566,9 @@ def delete_entries_from_collection(form_data: DeleteForm, user=Depends(get_admin
@router.post("/reset/db") @router.post("/reset/db")
def reset_vector_db(user=Depends(get_admin_user)): def reset_vector_db(user=Depends(get_admin_user), db: Session = Depends(get_session)):
VECTOR_DB_CLIENT.reset() VECTOR_DB_CLIENT.reset()
Knowledges.delete_all_knowledge() Knowledges.delete_all_knowledge(db=db)
@router.post("/reset/uploads") @router.post("/reset/uploads")
@@ -2496,6 +2626,7 @@ async def process_files_batch(
request: Request, request: Request,
form_data: BatchProcessFilesForm, form_data: BatchProcessFilesForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
) -> BatchProcessFilesResponse: ) -> BatchProcessFilesResponse:
""" """
Process a batch of files and save them to the vector database. Process a batch of files and save them to the vector database.
@@ -2558,7 +2689,9 @@ async def process_files_batch(
# Update all files with collection name # Update all files with collection name
for file_update, file_result in zip(file_updates, file_results): for file_update, file_result in zip(file_updates, file_results):
Files.update_file_by_id(id=file_result.file_id, form_data=file_update) Files.update_file_by_id(
id=file_result.file_id, form_data=file_update, db=db
)
file_result.status = "completed" file_result.status = "completed"
except Exception as e: except Exception as e:
+62 -43
View File
@@ -25,6 +25,10 @@ from open_webui.utils.auth import (
) )
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -296,7 +300,7 @@ def get_scim_auth(
) )
def user_to_scim(user: UserModel, request: Request) -> SCIMUser: def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser:
"""Convert internal User model to SCIM User""" """Convert internal User model to SCIM User"""
# Parse display name into name components # Parse display name into name components
name_parts = user.name.split(" ", 1) if user.name else ["", ""] name_parts = user.name.split(" ", 1) if user.name else ["", ""]
@@ -304,7 +308,7 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
family_name = name_parts[1] if len(name_parts) > 1 else "" family_name = name_parts[1] if len(name_parts) > 1 else ""
# Get user's groups # Get user's groups
user_groups = Groups.get_groups_by_member_id(user.id) user_groups = Groups.get_groups_by_member_id(user.id, db=db)
groups = [ groups = [
{ {
"value": group.id, "value": group.id,
@@ -345,13 +349,13 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
) )
def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup: def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup:
"""Convert internal Group model to SCIM Group""" """Convert internal Group model to SCIM Group"""
member_ids = Groups.get_group_user_ids_by_id(group.id) member_ids = Groups.get_group_user_ids_by_id(group.id, db) or []
members = [] members = []
for user_id in member_ids: for user_id in member_ids:
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if user: if user:
members.append( members.append(
SCIMGroupMember( SCIMGroupMember(
@@ -483,6 +487,7 @@ async def get_users(
count: int = Query(20, ge=1, le=100), count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None, filter: Optional[str] = None,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""List SCIM Users""" """List SCIM Users"""
skip = startIndex - 1 skip = startIndex - 1
@@ -494,20 +499,20 @@ async def get_users(
# In production, you'd want a more robust filter parser # In production, you'd want a more robust filter parser
if "userName eq" in filter: if "userName eq" in filter:
email = filter.split('"')[1] email = filter.split('"')[1]
user = Users.get_user_by_email(email) user = Users.get_user_by_email(email, db=db)
users_list = [user] if user else [] users_list = [user] if user else []
total = 1 if user else 0 total = 1 if user else 0
else: else:
response = Users.get_users(skip=skip, limit=limit) response = Users.get_users(skip=skip, limit=limit, db=db)
users_list = response["users"] users_list = response["users"]
total = response["total"] total = response["total"]
else: else:
response = Users.get_users(skip=skip, limit=limit) response = Users.get_users(skip=skip, limit=limit, db=db)
users_list = response["users"] users_list = response["users"]
total = response["total"] total = response["total"]
# Convert to SCIM format # Convert to SCIM format
scim_users = [user_to_scim(user, request) for user in users_list] scim_users = [user_to_scim(user, request, db=db) for user in users_list]
return SCIMListResponse( return SCIMListResponse(
totalResults=total, totalResults=total,
@@ -522,15 +527,16 @@ async def get_user(
user_id: str, user_id: str,
request: Request, request: Request,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Get SCIM User by ID""" """Get SCIM User by ID"""
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if not user: if not user:
return scim_error( return scim_error(
status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found" status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found"
) )
return user_to_scim(user, request) return user_to_scim(user, request, db=db)
@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED) @router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED)
@@ -538,10 +544,11 @@ async def create_user(
request: Request, request: Request,
user_data: SCIMUserCreateRequest, user_data: SCIMUserCreateRequest,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Create SCIM User""" """Create SCIM User"""
# Check if user already exists # Check if user already exists
existing_user = Users.get_user_by_email(user_data.userName) existing_user = Users.get_user_by_email(user_data.userName, db=db)
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
@@ -572,6 +579,7 @@ async def create_user(
email=email, email=email,
profile_image_url=profile_image, profile_image_url=profile_image,
role="user" if user_data.active else "pending", role="user" if user_data.active else "pending",
db=db,
) )
if not new_user: if not new_user:
@@ -580,7 +588,7 @@ async def create_user(
detail="Failed to create user", detail="Failed to create user",
) )
return user_to_scim(new_user, request) return user_to_scim(new_user, request, db=db)
@router.put("/Users/{user_id}", response_model=SCIMUser) @router.put("/Users/{user_id}", response_model=SCIMUser)
@@ -589,9 +597,10 @@ async def update_user(
request: Request, request: Request,
user_data: SCIMUserUpdateRequest, user_data: SCIMUserUpdateRequest,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Update SCIM User (full update)""" """Update SCIM User (full update)"""
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -624,14 +633,14 @@ async def update_user(
update_data["profile_image_url"] = user_data.photos[0].value update_data["profile_image_url"] = user_data.photos[0].value
# Update user # Update user
updated_user = Users.update_user_by_id(user_id, update_data) updated_user = Users.update_user_by_id(user_id, update_data, db=db)
if not updated_user: if not updated_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update user", detail="Failed to update user",
) )
return user_to_scim(updated_user, request) return user_to_scim(updated_user, request, db=db)
@router.patch("/Users/{user_id}", response_model=SCIMUser) @router.patch("/Users/{user_id}", response_model=SCIMUser)
@@ -640,9 +649,10 @@ async def patch_user(
request: Request, request: Request,
patch_data: SCIMPatchRequest, patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Update SCIM User (partial update)""" """Update SCIM User (partial update)"""
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -670,7 +680,7 @@ async def patch_user(
# Update user # Update user
if update_data: if update_data:
updated_user = Users.update_user_by_id(user_id, update_data) updated_user = Users.update_user_by_id(user_id, update_data, db=db)
if not updated_user: if not updated_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -679,7 +689,7 @@ async def patch_user(
else: else:
updated_user = user updated_user = user
return user_to_scim(updated_user, request) return user_to_scim(updated_user, request, db=db)
@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -687,16 +697,17 @@ async def delete_user(
user_id: str, user_id: str,
request: Request, request: Request,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Delete SCIM User""" """Delete SCIM User"""
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"User {user_id} not found", detail=f"User {user_id} not found",
) )
success = Users.delete_user_by_id(user_id) success = Users.delete_user_by_id(user_id, db=db)
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -714,10 +725,11 @@ async def get_groups(
count: int = Query(20, ge=1, le=100), count: int = Query(20, ge=1, le=100),
filter: Optional[str] = None, filter: Optional[str] = None,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""List SCIM Groups""" """List SCIM Groups"""
# Get all groups # Get all groups
groups_list = Groups.get_all_groups() groups_list = Groups.get_all_groups(db=db)
# Apply pagination # Apply pagination
total = len(groups_list) total = len(groups_list)
@@ -726,7 +738,7 @@ async def get_groups(
paginated_groups = groups_list[start:end] paginated_groups = groups_list[start:end]
# Convert to SCIM format # Convert to SCIM format
scim_groups = [group_to_scim(group, request) for group in paginated_groups] scim_groups = [group_to_scim(group, request, db=db) for group in paginated_groups]
return SCIMListResponse( return SCIMListResponse(
totalResults=total, totalResults=total,
@@ -741,16 +753,17 @@ async def get_group(
group_id: str, group_id: str,
request: Request, request: Request,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Get SCIM Group by ID""" """Get SCIM Group by ID"""
group = Groups.get_group_by_id(group_id) group = Groups.get_group_by_id(group_id, db=db)
if not group: if not group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found", detail=f"Group {group_id} not found",
) )
return group_to_scim(group, request) return group_to_scim(group, request, db=db)
@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) @router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED)
@@ -758,6 +771,7 @@ async def create_group(
request: Request, request: Request,
group_data: SCIMGroupCreateRequest, group_data: SCIMGroupCreateRequest,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Create SCIM Group""" """Create SCIM Group"""
# Extract member IDs # Extract member IDs
@@ -775,14 +789,14 @@ async def create_group(
) )
# Need to get the creating user's ID - we'll use the first admin # Need to get the creating user's ID - we'll use the first admin
admin_user = Users.get_super_admin_user() admin_user = Users.get_super_admin_user(db=db)
if not admin_user: if not admin_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No admin user found", detail="No admin user found",
) )
new_group = Groups.insert_new_group(admin_user.id, form) new_group = Groups.insert_new_group(admin_user.id, form, db=db)
if not new_group: if not new_group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -798,12 +812,12 @@ async def create_group(
description=new_group.description, description=new_group.description,
) )
Groups.update_group_by_id(new_group.id, update_form) Groups.update_group_by_id(new_group.id, update_form, db=db)
Groups.set_group_user_ids_by_id(new_group.id, member_ids) Groups.set_group_user_ids_by_id(new_group.id, member_ids, db=db)
new_group = Groups.get_group_by_id(new_group.id) new_group = Groups.get_group_by_id(new_group.id, db=db)
return group_to_scim(new_group, request) return group_to_scim(new_group, request, db=db)
@router.put("/Groups/{group_id}", response_model=SCIMGroup) @router.put("/Groups/{group_id}", response_model=SCIMGroup)
@@ -812,9 +826,10 @@ async def update_group(
request: Request, request: Request,
group_data: SCIMGroupUpdateRequest, group_data: SCIMGroupUpdateRequest,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Update SCIM Group (full update)""" """Update SCIM Group (full update)"""
group = Groups.get_group_by_id(group_id) group = Groups.get_group_by_id(group_id, db=db)
if not group: if not group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -832,17 +847,17 @@ async def update_group(
# Handle members if provided # Handle members if provided
if group_data.members is not None: if group_data.members is not None:
member_ids = [member.value for member in group_data.members] member_ids = [member.value for member in group_data.members]
Groups.set_group_user_ids_by_id(group_id, member_ids) Groups.set_group_user_ids_by_id(group_id, member_ids, db=db)
# Update group # Update group
updated_group = Groups.update_group_by_id(group_id, update_form) updated_group = Groups.update_group_by_id(group_id, update_form, db=db)
if not updated_group: if not updated_group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group", detail="Failed to update group",
) )
return group_to_scim(updated_group, request) return group_to_scim(updated_group, request, db=db)
@router.patch("/Groups/{group_id}", response_model=SCIMGroup) @router.patch("/Groups/{group_id}", response_model=SCIMGroup)
@@ -851,9 +866,10 @@ async def patch_group(
request: Request, request: Request,
patch_data: SCIMPatchRequest, patch_data: SCIMPatchRequest,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Update SCIM Group (partial update)""" """Update SCIM Group (partial update)"""
group = Groups.get_group_by_id(group_id) group = Groups.get_group_by_id(group_id, db=db)
if not group: if not group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
@@ -878,7 +894,7 @@ async def patch_group(
elif path == "members": elif path == "members":
# Replace all members # Replace all members
Groups.set_group_user_ids_by_id( Groups.set_group_user_ids_by_id(
group_id, [member["value"] for member in value] group_id, [member["value"] for member in value], db=db
) )
elif op == "add": elif op == "add":
@@ -887,22 +903,24 @@ async def patch_group(
if isinstance(value, list): if isinstance(value, list):
for member in value: for member in value:
if isinstance(member, dict) and "value" in member: if isinstance(member, dict) and "value" in member:
Groups.add_users_to_group(group_id, [member["value"]]) Groups.add_users_to_group(
group_id, [member["value"]], db=db
)
elif op == "remove": elif op == "remove":
if path and path.startswith("members[value eq"): if path and path.startswith("members[value eq"):
# Remove specific member # Remove specific member
member_id = path.split('"')[1] member_id = path.split('"')[1]
Groups.remove_users_from_group(group_id, [member_id]) Groups.remove_users_from_group(group_id, [member_id], db=db)
# Update group # Update group
updated_group = Groups.update_group_by_id(group_id, update_form) updated_group = Groups.update_group_by_id(group_id, update_form, db=db)
if not updated_group: if not updated_group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update group", detail="Failed to update group",
) )
return group_to_scim(updated_group, request) return group_to_scim(updated_group, request, db=db)
@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -910,16 +928,17 @@ async def delete_group(
group_id: str, group_id: str,
request: Request, request: Request,
_: bool = Depends(get_scim_auth), _: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
): ):
"""Delete SCIM Group""" """Delete SCIM Group"""
group = Groups.get_group_by_id(group_id) group = Groups.get_group_by_id(group_id, db=db)
if not group: if not group:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found", detail=f"Group {group_id} not found",
) )
success = Groups.delete_group_by_id(group_id) success = Groups.delete_group_by_id(group_id, db=db)
if not success: if not success:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+120 -48
View File
@@ -4,9 +4,12 @@ from typing import Optional
import time import time
import re import re
import aiohttp import aiohttp
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from open_webui.internal.db import get_session
from open_webui.models.oauth_sessions import OAuthSessions from open_webui.models.oauth_sessions import OAuthSessions
@@ -15,6 +18,7 @@ from open_webui.models.tools import (
ToolModel, ToolModel,
ToolResponse, ToolResponse,
ToolUserResponse, ToolUserResponse,
ToolAccessResponse,
Tools, Tools,
) )
from open_webui.utils.plugin import ( from open_webui.utils.plugin import (
@@ -51,11 +55,15 @@ def get_tool_module(request, tool_id, load_from_db=True):
@router.get("/", response_model=list[ToolUserResponse]) @router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)): async def get_tools(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
tools = [] tools = []
# Local Tools # Local Tools
for tool in Tools.get_tools(): for tool in Tools.get_tools(db=db):
tool_module = get_tool_module(request, tool.id) tool_module = get_tool_module(request, tool.id)
tools.append( tools.append(
ToolUserResponse( ToolUserResponse(
@@ -140,12 +148,14 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
# Admin can see all tools # Admin can see all tools
return tools return tools
else: else:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
tools = [ tools = [
tool tool
for tool in tools for tool in tools
if tool.user_id == user.id if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control, user_group_ids) or has_access(user.id, "read", tool.access_control, user_group_ids, db=db)
] ]
return tools return tools
@@ -155,13 +165,26 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
############################ ############################
@router.get("/list", response_model=list[ToolUserResponse]) @router.get("/list", response_model=list[ToolAccessResponse])
async def get_tool_list(user=Depends(get_verified_user)): async def get_tool_list(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
tools = Tools.get_tools() tools = Tools.get_tools(db=db)
else: else:
tools = Tools.get_tools_by_user_id(user.id, "write") tools = Tools.get_tools_by_user_id(user.id, "read", db=db)
return tools
return [
ToolAccessResponse(
**tool.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or has_access(user.id, "write", tool.access_control, db=db)
),
)
for tool in tools
]
############################ ############################
@@ -218,7 +241,9 @@ async def load_tool_from_url(
) )
try: try:
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.get( async with session.get(
url, headers={"Content-Type": "application/json"} url, headers={"Content-Type": "application/json"}
) as resp: ) as resp:
@@ -245,9 +270,16 @@ async def load_tool_from_url(
@router.get("/export", response_model=list[ToolModel]) @router.get("/export", response_model=list[ToolModel])
async def export_tools(request: Request, user=Depends(get_verified_user)): async def export_tools(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "workspace.tools_export", request.app.state.config.USER_PERMISSIONS user.id,
"workspace.tools_export",
request.app.state.config.USER_PERMISSIONS,
db=db,
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -255,9 +287,9 @@ async def export_tools(request: Request, user=Depends(get_verified_user)):
) )
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return Tools.get_tools() return Tools.get_tools(db=db)
else: else:
return Tools.get_tools_by_user_id(user.id, "read") return Tools.get_tools_by_user_id(user.id, "read", db=db)
############################ ############################
@@ -270,13 +302,17 @@ async def create_new_tools(
request: Request, request: Request,
form_data: ToolForm, form_data: ToolForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
if user.role != "admin" and not ( if user.role != "admin" and not (
has_permission( has_permission(
user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS, db=db
) )
or has_permission( or has_permission(
user.id, "workspace.tools_import", request.app.state.config.USER_PERMISSIONS user.id,
"workspace.tools_import",
request.app.state.config.USER_PERMISSIONS,
db=db,
) )
): ):
raise HTTPException( raise HTTPException(
@@ -292,7 +328,7 @@ async def create_new_tools(
form_data.id = form_data.id.lower() form_data.id = form_data.id.lower()
tools = Tools.get_tool_by_id(form_data.id) tools = Tools.get_tool_by_id(form_data.id, db=db)
if tools is None: if tools is None:
try: try:
form_data.content = replace_imports(form_data.content) form_data.content = replace_imports(form_data.content)
@@ -305,7 +341,7 @@ async def create_new_tools(
TOOLS[form_data.id] = tool_module TOOLS[form_data.id] = tool_module
specs = get_tool_specs(TOOLS[form_data.id]) specs = get_tool_specs(TOOLS[form_data.id])
tools = Tools.insert_new_tool(user.id, form_data, specs) tools = Tools.insert_new_tool(user.id, form_data, specs, db=db)
tool_cache_dir = CACHE_DIR / "tools" / form_data.id tool_cache_dir = CACHE_DIR / "tools" / form_data.id
tool_cache_dir.mkdir(parents=True, exist_ok=True) tool_cache_dir.mkdir(parents=True, exist_ok=True)
@@ -335,20 +371,34 @@ async def create_new_tools(
############################ ############################
@router.get("/id/{id}", response_model=Optional[ToolModel]) @router.get("/id/{id}", response_model=Optional[ToolAccessResponse])
async def get_tools_by_id(id: str, user=Depends(get_verified_user)): async def get_tools_by_id(
tools = Tools.get_tool_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
tools = Tools.get_tool_by_id(id, db=db)
if tools: if tools:
if ( if (
user.role == "admin" user.role == "admin"
or tools.user_id == user.id or tools.user_id == user.id
or has_access(user.id, "read", tools.access_control) or has_access(user.id, "read", tools.access_control, db=db)
): ):
return tools return ToolAccessResponse(
**tools.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tools.user_id
or has_access(user.id, "write", tools.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND,
) )
@@ -364,8 +414,9 @@ async def update_tools_by_id(
id: str, id: str,
form_data: ToolForm, form_data: ToolForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
tools = Tools.get_tool_by_id(id) tools = Tools.get_tool_by_id(id, db=db)
if not tools: if not tools:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -375,7 +426,7 @@ async def update_tools_by_id(
# Is the user the original creator, in a group with write access, or an admin # Is the user the original creator, in a group with write access, or an admin
if ( if (
tools.user_id != user.id tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control) and not has_access(user.id, "write", tools.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -399,7 +450,7 @@ async def update_tools_by_id(
} }
log.debug(updated) log.debug(updated)
tools = Tools.update_tool_by_id(id, updated) tools = Tools.update_tool_by_id(id, updated, db=db)
if tools: if tools:
return tools return tools
@@ -423,9 +474,12 @@ async def update_tools_by_id(
@router.delete("/id/{id}/delete", response_model=bool) @router.delete("/id/{id}/delete", response_model=bool)
async def delete_tools_by_id( async def delete_tools_by_id(
request: Request, id: str, user=Depends(get_verified_user) request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
tools = Tools.get_tool_by_id(id) tools = Tools.get_tool_by_id(id, db=db)
if not tools: if not tools:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -434,7 +488,7 @@ async def delete_tools_by_id(
if ( if (
tools.user_id != user.id tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control) and not has_access(user.id, "write", tools.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -442,7 +496,7 @@ async def delete_tools_by_id(
detail=ERROR_MESSAGES.UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED,
) )
result = Tools.delete_tool_by_id(id) result = Tools.delete_tool_by_id(id, db=db)
if result: if result:
TOOLS = request.app.state.TOOLS TOOLS = request.app.state.TOOLS
if id in TOOLS: if id in TOOLS:
@@ -457,11 +511,13 @@ async def delete_tools_by_id(
@router.get("/id/{id}/valves", response_model=Optional[dict]) @router.get("/id/{id}/valves", response_model=Optional[dict])
async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)): async def get_tools_valves_by_id(
tools = Tools.get_tool_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
tools = Tools.get_tool_by_id(id, db=db)
if tools: if tools:
try: try:
valves = Tools.get_tool_valves_by_id(id) valves = Tools.get_tool_valves_by_id(id, db=db)
return valves return valves
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -482,9 +538,12 @@ async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/id/{id}/valves/spec", response_model=Optional[dict]) @router.get("/id/{id}/valves/spec", response_model=Optional[dict])
async def get_tools_valves_spec_by_id( async def get_tools_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user) request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
tools = Tools.get_tool_by_id(id) tools = Tools.get_tool_by_id(id, db=db)
if tools: if tools:
if id in request.app.state.TOOLS: if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id] tools_module = request.app.state.TOOLS[id]
@@ -510,9 +569,13 @@ async def get_tools_valves_spec_by_id(
@router.post("/id/{id}/valves/update", response_model=Optional[dict]) @router.post("/id/{id}/valves/update", response_model=Optional[dict])
async def update_tools_valves_by_id( async def update_tools_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user) request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
tools = Tools.get_tool_by_id(id) tools = Tools.get_tool_by_id(id, db=db)
if not tools: if not tools:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -521,7 +584,7 @@ async def update_tools_valves_by_id(
if ( if (
tools.user_id != user.id tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control) and not has_access(user.id, "write", tools.access_control, db=db)
and user.role != "admin" and user.role != "admin"
): ):
raise HTTPException( raise HTTPException(
@@ -546,7 +609,7 @@ async def update_tools_valves_by_id(
form_data = {k: v for k, v in form_data.items() if v is not None} form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data) valves = Valves(**form_data)
valves_dict = valves.model_dump(exclude_unset=True) valves_dict = valves.model_dump(exclude_unset=True)
Tools.update_tool_valves_by_id(id, valves_dict) Tools.update_tool_valves_by_id(id, valves_dict, db=db)
return valves_dict return valves_dict
except Exception as e: except Exception as e:
log.exception(f"Failed to update tool valves by id {id}: {e}") log.exception(f"Failed to update tool valves by id {id}: {e}")
@@ -562,11 +625,13 @@ async def update_tools_valves_by_id(
@router.get("/id/{id}/valves/user", response_model=Optional[dict]) @router.get("/id/{id}/valves/user", response_model=Optional[dict])
async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)): async def get_tools_user_valves_by_id(
tools = Tools.get_tool_by_id(id) id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
tools = Tools.get_tool_by_id(id, db=db)
if tools: if tools:
try: try:
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id) user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db)
return user_valves return user_valves
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
@@ -582,9 +647,12 @@ async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user)):
@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict]) @router.get("/id/{id}/valves/user/spec", response_model=Optional[dict])
async def get_tools_user_valves_spec_by_id( async def get_tools_user_valves_spec_by_id(
request: Request, id: str, user=Depends(get_verified_user) request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
tools = Tools.get_tool_by_id(id) tools = Tools.get_tool_by_id(id, db=db)
if tools: if tools:
if id in request.app.state.TOOLS: if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id] tools_module = request.app.state.TOOLS[id]
@@ -605,9 +673,13 @@ async def get_tools_user_valves_spec_by_id(
@router.post("/id/{id}/valves/user/update", response_model=Optional[dict]) @router.post("/id/{id}/valves/user/update", response_model=Optional[dict])
async def update_tools_user_valves_by_id( async def update_tools_user_valves_by_id(
request: Request, id: str, form_data: dict, user=Depends(get_verified_user) request: Request,
id: str,
form_data: dict,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
tools = Tools.get_tool_by_id(id) tools = Tools.get_tool_by_id(id, db=db)
if tools: if tools:
if id in request.app.state.TOOLS: if id in request.app.state.TOOLS:
@@ -624,7 +696,7 @@ async def update_tools_user_valves_by_id(
user_valves = UserValves(**form_data) user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True) user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id( Tools.update_user_valves_by_id_and_user_id(
id, user.id, user_valves_dict id, user.id, user_valves_dict, db=db
) )
return user_valves_dict return user_valves_dict
except Exception as e: except Exception as e:
+114 -51
View File
@@ -1,5 +1,6 @@
import logging import logging
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session
import base64 import base64
import io import io
@@ -29,6 +30,7 @@ from open_webui.models.users import (
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import STATIC_DIR from open_webui.env import STATIC_DIR
from open_webui.internal.db import get_session
from open_webui.utils.auth import ( from open_webui.utils.auth import (
@@ -60,6 +62,7 @@ async def get_users(
direction: Optional[str] = None, direction: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_admin_user), user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
@@ -74,19 +77,23 @@ async def get_users(
if direction: if direction:
filter["direction"] = direction filter["direction"] = direction
result = Users.get_users(filter=filter, skip=skip, limit=limit) filter["direction"] = direction
result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
users = result["users"] users = result["users"]
total = result["total"] total = result["total"]
# Fetch groups for all users in a single query to avoid N+1
user_ids = [user.id for user in users]
user_groups = Groups.get_groups_by_member_ids(user_ids, db=db)
return { return {
"users": [ "users": [
UserGroupIdsModel( UserGroupIdsModel(
**{ **{
**user.model_dump(), **user.model_dump(),
"group_ids": [ "group_ids": [group.id for group in user_groups.get(user.id, [])],
group.id for group in Groups.get_groups_by_member_id(user.id)
],
} }
) )
for user in users for user in users
@@ -98,8 +105,9 @@ async def get_users(
@router.get("/all", response_model=UserInfoListResponse) @router.get("/all", response_model=UserInfoListResponse)
async def get_all_users( async def get_all_users(
user=Depends(get_admin_user), user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
return Users.get_users() return Users.get_users(db=db)
@router.get("/search", response_model=UserInfoListResponse) @router.get("/search", response_model=UserInfoListResponse)
@@ -109,16 +117,13 @@ async def search_users(
direction: Optional[str] = None, direction: Optional[str] = None,
page: Optional[int] = 1, page: Optional[int] = 1,
user=Depends(get_verified_user), user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
limit = PAGE_ITEM_COUNT limit = PAGE_ITEM_COUNT
page = max(1, page) page = max(1, page)
skip = (page - 1) * limit skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
filter = {} filter = {}
if query: if query:
filter["query"] = query filter["query"] = query
@@ -127,7 +132,7 @@ async def search_users(
if direction: if direction:
filter["direction"] = direction filter["direction"] = direction
return Users.get_users(filter=filter, skip=skip, limit=limit) return Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
############################ ############################
@@ -136,8 +141,10 @@ async def search_users(
@router.get("/groups") @router.get("/groups")
async def get_user_groups(user=Depends(get_verified_user)): async def get_user_groups(
return Groups.get_groups_by_member_id(user.id) user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return Groups.get_groups_by_member_id(user.id, db=db)
############################ ############################
@@ -146,9 +153,13 @@ async def get_user_groups(user=Depends(get_verified_user)):
@router.get("/permissions") @router.get("/permissions")
async def get_user_permissisions(request: Request, user=Depends(get_verified_user)): async def get_user_permissisions(
request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
user_permissions = get_permissions( user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS user.id, request.app.state.config.USER_PERMISSIONS, db=db
) )
return user_permissions return user_permissions
@@ -215,6 +226,11 @@ class FeaturesPermissions(BaseModel):
web_search: bool = True web_search: bool = True
image_generation: bool = True image_generation: bool = True
code_interpreter: bool = True code_interpreter: bool = True
memories: bool = True
class SettingsPermissions(BaseModel):
interface: bool = True
class UserPermissions(BaseModel): class UserPermissions(BaseModel):
@@ -222,6 +238,7 @@ class UserPermissions(BaseModel):
sharing: SharingPermissions sharing: SharingPermissions
chat: ChatPermissions chat: ChatPermissions
features: FeaturesPermissions features: FeaturesPermissions
settings: SettingsPermissions
@router.get("/default/permissions", response_model=UserPermissions) @router.get("/default/permissions", response_model=UserPermissions)
@@ -239,6 +256,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_
"features": FeaturesPermissions( "features": FeaturesPermissions(
**request.app.state.config.USER_PERMISSIONS.get("features", {}) **request.app.state.config.USER_PERMISSIONS.get("features", {})
), ),
"settings": SettingsPermissions(
**request.app.state.config.USER_PERMISSIONS.get("settings", {})
),
} }
@@ -256,8 +276,10 @@ async def update_default_user_permissions(
@router.get("/user/settings", response_model=Optional[UserSettings]) @router.get("/user/settings", response_model=Optional[UserSettings])
async def get_user_settings_by_session_user(user=Depends(get_verified_user)): async def get_user_settings_by_session_user(
user = Users.get_user_by_id(user.id) user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user.id, db=db)
if user: if user:
return user.settings return user.settings
else: else:
@@ -274,12 +296,17 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)):
@router.post("/user/settings/update", response_model=UserSettings) @router.post("/user/settings/update", response_model=UserSettings)
async def update_user_settings_by_session_user( async def update_user_settings_by_session_user(
request: Request, form_data: UserSettings, user=Depends(get_verified_user) request: Request,
form_data: UserSettings,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
updated_user_settings = form_data.model_dump() updated_user_settings = form_data.model_dump()
ui_settings = updated_user_settings.get("ui")
if ( if (
user.role != "admin" user.role != "admin"
and "toolServers" in updated_user_settings.get("ui").keys() and ui_settings is not None
and "toolServers" in ui_settings.keys()
and not has_permission( and not has_permission(
user.id, user.id,
"features.direct_tool_servers", "features.direct_tool_servers",
@@ -289,7 +316,7 @@ async def update_user_settings_by_session_user(
# If the user is not an admin and does not have permission to use tool servers, remove the key # If the user is not an admin and does not have permission to use tool servers, remove the key
updated_user_settings["ui"].pop("toolServers", None) updated_user_settings["ui"].pop("toolServers", None)
user = Users.update_user_settings_by_id(user.id, updated_user_settings) user = Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
if user: if user:
return user.settings return user.settings
else: else:
@@ -305,8 +332,17 @@ async def update_user_settings_by_session_user(
@router.get("/user/status") @router.get("/user/status")
async def get_user_status_by_session_user(user=Depends(get_verified_user)): async def get_user_status_by_session_user(
user = Users.get_user_by_id(user.id) request: Request,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = Users.get_user_by_id(user.id, db=db)
if user: if user:
return user return user
else: else:
@@ -323,11 +359,19 @@ async def get_user_status_by_session_user(user=Depends(get_verified_user)):
@router.post("/user/status/update") @router.post("/user/status/update")
async def update_user_status_by_session_user( async def update_user_status_by_session_user(
form_data: UserStatus, user=Depends(get_verified_user) request: Request,
form_data: UserStatus,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
): ):
user = Users.get_user_by_id(user.id) if not request.app.state.config.ENABLE_USER_STATUS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = Users.get_user_by_id(user.id, db=db)
if user: if user:
user = Users.update_user_status_by_id(user.id, form_data) user = Users.update_user_status_by_id(user.id, form_data, db=db)
return user return user
else: else:
raise HTTPException( raise HTTPException(
@@ -342,8 +386,10 @@ async def update_user_status_by_session_user(
@router.get("/user/info", response_model=Optional[dict]) @router.get("/user/info", response_model=Optional[dict])
async def get_user_info_by_session_user(user=Depends(get_verified_user)): async def get_user_info_by_session_user(
user = Users.get_user_by_id(user.id) user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user.id, db=db)
if user: if user:
return user.info return user.info
else: else:
@@ -360,14 +406,16 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user)):
@router.post("/user/info/update", response_model=Optional[dict]) @router.post("/user/info/update", response_model=Optional[dict])
async def update_user_info_by_session_user( async def update_user_info_by_session_user(
form_data: dict, user=Depends(get_verified_user) form_data: dict, user=Depends(get_verified_user), db: Session = Depends(get_session)
): ):
user = Users.get_user_by_id(user.id) user = Users.get_user_by_id(user.id, db=db)
if user: if user:
if user.info is None: if user.info is None:
user.info = {} user.info = {}
user = Users.update_user_by_id(user.id, {"info": {**user.info, **form_data}}) user = Users.update_user_by_id(
user.id, {"info": {**user.info, **form_data}}, db=db
)
if user: if user:
return user.info return user.info
else: else:
@@ -397,7 +445,9 @@ class UserActiveResponse(UserStatus):
@router.get("/{user_id}", response_model=UserActiveResponse) @router.get("/{user_id}", response_model=UserActiveResponse)
async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): async def get_user_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
# Check if user_id is a shared chat # Check if user_id is a shared chat
# If it is, get the user_id from the chat # If it is, get the user_id from the chat
if user_id.startswith("shared-"): if user_id.startswith("shared-"):
@@ -411,14 +461,14 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.USER_NOT_FOUND, detail=ERROR_MESSAGES.USER_NOT_FOUND,
) )
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if user: if user:
groups = Groups.get_groups_by_member_id(user_id) groups = Groups.get_groups_by_member_id(user_id, db=db)
return UserActiveResponse( return UserActiveResponse(
**{ **{
**user.model_dump(), **user.model_dump(),
"groups": [{"id": group.id, "name": group.name} for group in groups], "groups": [{"id": group.id, "name": group.name} for group in groups],
"is_active": Users.is_user_active(user_id), "is_active": Users.is_user_active(user_id, db=db),
} }
) )
else: else:
@@ -429,8 +479,10 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
@router.get("/{user_id}/oauth/sessions") @router.get("/{user_id}/oauth/sessions")
async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)): async def get_user_oauth_sessions_by_id(
sessions = OAuthSessions.get_sessions_by_user_id(user_id) user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
sessions = OAuthSessions.get_sessions_by_user_id(user_id, db=db)
if sessions and len(sessions) > 0: if sessions and len(sessions) > 0:
return sessions return sessions
else: else:
@@ -446,8 +498,10 @@ async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_use
@router.get("/{user_id}/profile/image") @router.get("/{user_id}/profile/image")
async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): async def get_user_profile_image_by_id(
user = Users.get_user_by_id(user_id) user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
user = Users.get_user_by_id(user_id, db=db)
if user: if user:
if user.profile_image_url: if user.profile_image_url:
# check if it's url or base64 # check if it's url or base64
@@ -461,11 +515,12 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
header, base64_data = user.profile_image_url.split(",", 1) header, base64_data = user.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data) image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data) image_buffer = io.BytesIO(image_data)
media_type = header.split(";")[0].lstrip("data:")
return StreamingResponse( return StreamingResponse(
image_buffer, image_buffer,
media_type="image/png", media_type=media_type,
headers={"Content-Disposition": "inline; filename=image.png"}, headers={"Content-Disposition": "inline"},
) )
except Exception as e: except Exception as e:
pass pass
@@ -483,9 +538,11 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
@router.get("/{user_id}/active", response_model=dict) @router.get("/{user_id}/active", response_model=dict)
async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)): async def get_user_active_status_by_id(
user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return { return {
"active": Users.is_user_active(user_id), "active": Users.is_user_active(user_id, db=db),
} }
@@ -499,10 +556,11 @@ async def update_user_by_id(
user_id: str, user_id: str,
form_data: UserUpdateForm, form_data: UserUpdateForm,
session_user=Depends(get_admin_user), session_user=Depends(get_admin_user),
db: Session = Depends(get_session),
): ):
# Prevent modification of the primary admin user by other admins # Prevent modification of the primary admin user by other admins
try: try:
first_user = Users.get_first_user() first_user = Users.get_first_user(db=db)
if first_user: if first_user:
if user_id == first_user.id: if user_id == first_user.id:
if session_user.id != user_id: if session_user.id != user_id:
@@ -526,11 +584,11 @@ async def update_user_by_id(
detail="Could not verify primary admin status.", detail="Could not verify primary admin status.",
) )
user = Users.get_user_by_id(user_id) user = Users.get_user_by_id(user_id, db=db)
if user: if user:
if form_data.email.lower() != user.email: if form_data.email.lower() != user.email:
email_user = Users.get_user_by_email(form_data.email.lower()) email_user = Users.get_user_by_email(form_data.email.lower(), db=db)
if email_user: if email_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -544,9 +602,9 @@ async def update_user_by_id(
raise HTTPException(400, detail=str(e)) raise HTTPException(400, detail=str(e))
hashed = get_password_hash(form_data.password) hashed = get_password_hash(form_data.password)
Auths.update_user_password_by_id(user_id, hashed) Auths.update_user_password_by_id(user_id, hashed, db=db)
Auths.update_email_by_id(user_id, form_data.email.lower()) Auths.update_email_by_id(user_id, form_data.email.lower(), db=db)
updated_user = Users.update_user_by_id( updated_user = Users.update_user_by_id(
user_id, user_id,
{ {
@@ -555,6 +613,7 @@ async def update_user_by_id(
"email": form_data.email.lower(), "email": form_data.email.lower(),
"profile_image_url": form_data.profile_image_url, "profile_image_url": form_data.profile_image_url,
}, },
db=db,
) )
if updated_user: if updated_user:
@@ -577,10 +636,12 @@ async def update_user_by_id(
@router.delete("/{user_id}", response_model=bool) @router.delete("/{user_id}", response_model=bool)
async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)): async def delete_user_by_id(
user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
# Prevent deletion of the primary admin user # Prevent deletion of the primary admin user
try: try:
first_user = Users.get_first_user() first_user = Users.get_first_user(db=db)
if first_user and user_id == first_user.id: if first_user and user_id == first_user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
@@ -594,7 +655,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
) )
if user.id != user_id: if user.id != user_id:
result = Auths.delete_auth_by_id(user_id) result = Auths.delete_auth_by_id(user_id, db=db)
if result: if result:
return True return True
@@ -617,5 +678,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
@router.get("/{user_id}/groups") @router.get("/{user_id}/groups")
async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user)): async def get_user_groups_by_id(
return Groups.get_groups_by_member_id(user_id) user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)
):
return Groups.get_groups_by_member_id(user_id, db=db)
+7 -1
View File
@@ -246,7 +246,13 @@ def get_user_ids_from_room(room):
active_session_ids = get_session_ids_from_room(room) active_session_ids = get_session_ids_from_room(room)
active_user_ids = list( active_user_ids = list(
set([SESSION_POOL.get(session_id)["id"] for session_id in active_session_ids]) set(
[
SESSION_POOL.get(session_id)["id"]
for session_id in active_session_ids
if SESSION_POOL.get(session_id) is not None
]
)
) )
return active_user_ids return active_user_ids
@@ -1,236 +0,0 @@
import uuid
from test.util.abstract_integration_test import AbstractPostgresTest
from test.util.mock_user import mock_webui_user
class TestChats(AbstractPostgresTest):
BASE_PATH = "/api/v1/chats"
def setup_class(cls):
super().setup_class()
def setup_method(self):
super().setup_method()
from open_webui.models.chats import ChatForm, Chats
self.chats = Chats
self.chats.insert_new_chat(
"2",
ChatForm(
**{
"chat": {
"name": "chat1",
"description": "chat1 description",
"tags": ["tag1", "tag2"],
"history": {"currentId": "1", "messages": []},
}
}
),
)
def test_get_session_user_chat_list(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
first_chat = response.json()[0]
assert first_chat["id"] is not None
assert first_chat["title"] == "New Chat"
assert first_chat["created_at"] is not None
assert first_chat["updated_at"] is not None
def test_delete_all_user_chats(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(self.create_url("/"))
assert response.status_code == 200
assert len(self.chats.get_chats()) == 0
def test_get_user_chat_list_by_user_id(self):
with mock_webui_user(id="3"):
response = self.fast_api_client.get(self.create_url("/list/user/2"))
assert response.status_code == 200
first_chat = response.json()[0]
assert first_chat["id"] is not None
assert first_chat["title"] == "New Chat"
assert first_chat["created_at"] is not None
assert first_chat["updated_at"] is not None
def test_create_new_chat(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url("/new"),
json={
"chat": {
"name": "chat2",
"description": "chat2 description",
"tags": ["tag1", "tag2"],
}
},
)
assert response.status_code == 200
data = response.json()
assert data["archived"] is False
assert data["chat"] == {
"name": "chat2",
"description": "chat2 description",
"tags": ["tag1", "tag2"],
}
assert data["user_id"] == "2"
assert data["id"] is not None
assert data["share_id"] is None
assert data["title"] == "New Chat"
assert data["updated_at"] is not None
assert data["created_at"] is not None
assert len(self.chats.get_chats()) == 2
def test_get_user_chats(self):
self.test_get_session_user_chat_list()
def test_get_user_archived_chats(self):
self.chats.archive_all_chats_by_user_id("2")
from open_webui.internal.db import Session
Session.commit()
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/all/archived"))
assert response.status_code == 200
first_chat = response.json()[0]
assert first_chat["id"] is not None
assert first_chat["title"] == "New Chat"
assert first_chat["created_at"] is not None
assert first_chat["updated_at"] is not None
def test_get_all_user_chats_in_db(self):
with mock_webui_user(id="4"):
response = self.fast_api_client.get(self.create_url("/all/db"))
assert response.status_code == 200
assert len(response.json()) == 1
def test_get_archived_session_user_chat_list(self):
self.test_get_user_archived_chats()
def test_archive_all_chats(self):
with mock_webui_user(id="2"):
response = self.fast_api_client.post(self.create_url("/archive/all"))
assert response.status_code == 200
assert len(self.chats.get_archived_chats_by_user_id("2")) == 1
def test_get_shared_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
self.chats.update_chat_share_id_by_id(chat_id, chat_id)
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/share/{chat_id}"))
assert response.status_code == 200
data = response.json()
assert data["id"] == chat_id
assert data["chat"] == {
"name": "chat1",
"description": "chat1 description",
"tags": ["tag1", "tag2"],
"history": {"currentId": "1", "messages": []},
}
assert data["id"] == chat_id
assert data["share_id"] == chat_id
assert data["title"] == "New Chat"
def test_get_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/{chat_id}"))
assert response.status_code == 200
data = response.json()
assert data["id"] == chat_id
assert data["chat"] == {
"name": "chat1",
"description": "chat1 description",
"tags": ["tag1", "tag2"],
"history": {"currentId": "1", "messages": []},
}
assert data["share_id"] is None
assert data["title"] == "New Chat"
assert data["user_id"] == "2"
def test_update_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url(f"/{chat_id}"),
json={
"chat": {
"name": "chat2",
"description": "chat2 description",
"tags": ["tag2", "tag4"],
"title": "Just another title",
}
},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == chat_id
assert data["chat"] == {
"name": "chat2",
"title": "Just another title",
"description": "chat2 description",
"tags": ["tag2", "tag4"],
"history": {"currentId": "1", "messages": []},
}
assert data["share_id"] is None
assert data["title"] == "Just another title"
assert data["user_id"] == "2"
def test_delete_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(self.create_url(f"/{chat_id}"))
assert response.status_code == 200
assert response.json() is True
def test_clone_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/{chat_id}/clone"))
assert response.status_code == 200
data = response.json()
assert data["id"] != chat_id
assert data["chat"] == {
"branchPointMessageId": "1",
"description": "chat1 description",
"history": {"currentId": "1", "messages": []},
"name": "chat1",
"originalChatId": chat_id,
"tags": ["tag1", "tag2"],
"title": "Clone of New Chat",
}
assert data["share_id"] is None
assert data["title"] == "Clone of New Chat"
assert data["user_id"] == "2"
def test_archive_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url(f"/{chat_id}/archive"))
assert response.status_code == 200
chat = self.chats.get_chat_by_id(chat_id)
assert chat.archived is True
def test_share_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
with mock_webui_user(id="2"):
response = self.fast_api_client.post(self.create_url(f"/{chat_id}/share"))
assert response.status_code == 200
chat = self.chats.get_chat_by_id(chat_id)
assert chat.share_id is not None
def test_delete_shared_chat_by_id(self):
chat_id = self.chats.get_chats()[0].id
share_id = str(uuid.uuid4())
self.chats.update_chat_share_id_by_id(chat_id, share_id)
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(self.create_url(f"/{chat_id}/share"))
assert response.status_code
chat = self.chats.get_chat_by_id(chat_id)
assert chat.share_id is None
@@ -1,161 +0,0 @@
import logging
import os
import time
import docker
import pytest
from docker import DockerClient
from pytest_docker.plugin import get_docker_ip
from fastapi.testclient import TestClient
from sqlalchemy import text, create_engine
log = logging.getLogger(__name__)
def get_fast_api_client():
from main import app
with TestClient(app) as c:
return c
class AbstractIntegrationTest:
BASE_PATH = None
def create_url(self, path="", query_params=None):
if self.BASE_PATH is None:
raise Exception("BASE_PATH is not set")
parts = self.BASE_PATH.split("/")
parts = [part.strip() for part in parts if part.strip() != ""]
path_parts = path.split("/")
path_parts = [part.strip() for part in path_parts if part.strip() != ""]
query_parts = ""
if query_params:
query_parts = "&".join(
[f"{key}={value}" for key, value in query_params.items()]
)
query_parts = f"?{query_parts}"
return "/".join(parts + path_parts) + query_parts
@classmethod
def setup_class(cls):
pass
def setup_method(self):
pass
@classmethod
def teardown_class(cls):
pass
def teardown_method(self):
pass
class AbstractPostgresTest(AbstractIntegrationTest):
DOCKER_CONTAINER_NAME = "postgres-test-container-will-get-deleted"
docker_client: DockerClient
@classmethod
def _create_db_url(cls, env_vars_postgres: dict) -> str:
host = get_docker_ip()
user = env_vars_postgres["POSTGRES_USER"]
pw = env_vars_postgres["POSTGRES_PASSWORD"]
port = 8081
db = env_vars_postgres["POSTGRES_DB"]
return f"postgresql://{user}:{pw}@{host}:{port}/{db}"
@classmethod
def setup_class(cls):
super().setup_class()
try:
env_vars_postgres = {
"POSTGRES_USER": "user",
"POSTGRES_PASSWORD": "example",
"POSTGRES_DB": "openwebui",
}
cls.docker_client = docker.from_env()
cls.docker_client.containers.run(
"postgres:16.2",
detach=True,
environment=env_vars_postgres,
name=cls.DOCKER_CONTAINER_NAME,
ports={5432: ("0.0.0.0", 8081)},
command="postgres -c log_statement=all",
)
time.sleep(0.5)
database_url = cls._create_db_url(env_vars_postgres)
os.environ["DATABASE_URL"] = database_url
retries = 10
db = None
while retries > 0:
try:
from open_webui.config import OPEN_WEBUI_DIR
db = create_engine(database_url, pool_pre_ping=True)
db = db.connect()
log.info("postgres is ready!")
break
except Exception as e:
log.warning(e)
time.sleep(3)
retries -= 1
if db:
# import must be after setting env!
cls.fast_api_client = get_fast_api_client()
db.close()
else:
raise Exception("Could not connect to Postgres")
except Exception as ex:
log.error(ex)
cls.teardown_class()
pytest.fail(f"Could not setup test environment: {ex}")
def _check_db_connection(self):
from open_webui.internal.db import Session
retries = 10
while retries > 0:
try:
Session.execute(text("SELECT 1"))
Session.commit()
break
except Exception as e:
Session.rollback()
log.warning(e)
time.sleep(3)
retries -= 1
def setup_method(self):
super().setup_method()
self._check_db_connection()
@classmethod
def teardown_class(cls) -> None:
super().teardown_class()
cls.docker_client.containers.get(cls.DOCKER_CONTAINER_NAME).remove(force=True)
def teardown_method(self):
from open_webui.internal.db import Session
# rollback everything not yet committed
Session.commit()
# truncate all tables
tables = [
"auth",
"chat",
"chatidtag",
"document",
"memory",
"model",
"prompt",
"tag",
'"user"',
]
for table in tables:
Session.execute(text(f"TRUNCATE TABLE {table}"))
Session.commit()
-45
View File
@@ -1,45 +0,0 @@
from contextlib import contextmanager
from fastapi import FastAPI
@contextmanager
def mock_webui_user(**kwargs):
from open_webui.routers.webui import app
with mock_user(app, **kwargs):
yield
@contextmanager
def mock_user(app: FastAPI, **kwargs):
from open_webui.utils.auth import (
get_current_user,
get_verified_user,
get_admin_user,
get_current_user_by_api_key,
)
from open_webui.models.users import User
def create_user():
user_parameters = {
"id": "1",
"name": "John Doe",
"email": "john.doe@openwebui.com",
"role": "user",
"profile_image_url": "/user.png",
"last_active_at": 1627351200,
"updated_at": 1627351200,
"created_at": 162735120,
**kwargs,
}
return User(**user_parameters)
app.dependency_overrides = {
get_current_user: create_user,
get_verified_user: create_user,
get_admin_user: create_user,
get_current_user_by_api_key: create_user,
}
yield
app.dependency_overrides = {}
+6
View File
@@ -0,0 +1,6 @@
"""
Open WebUI Tools Package.
This package contains built-in tools that are automatically available
when native function calling is enabled.
"""
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -28,6 +28,7 @@ def fill_missing_permissions(
def get_permissions( def get_permissions(
user_id: str, user_id: str,
default_permissions: Dict[str, Any], default_permissions: Dict[str, Any],
db: Optional[Any] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get all permissions for a user by combining the permissions of all groups the user is a member of. Get all permissions for a user by combining the permissions of all groups the user is a member of.
@@ -53,7 +54,7 @@ def get_permissions(
) # Use the most permissive value (True > False) ) # Use the most permissive value (True > False)
return permissions return permissions
user_groups = Groups.get_groups_by_member_id(user_id) user_groups = Groups.get_groups_by_member_id(user_id, db=db)
# Deep copy default permissions to avoid modifying the original dict # Deep copy default permissions to avoid modifying the original dict
permissions = json.loads(json.dumps(default_permissions)) permissions = json.loads(json.dumps(default_permissions))
@@ -72,6 +73,7 @@ def has_permission(
user_id: str, user_id: str,
permission_key: str, permission_key: str,
default_permissions: Dict[str, Any] = {}, default_permissions: Dict[str, Any] = {},
db: Optional[Any] = None,
) -> bool: ) -> bool:
""" """
Check if a user has a specific permission by checking the group permissions Check if a user has a specific permission by checking the group permissions
@@ -92,7 +94,7 @@ def has_permission(
permission_hierarchy = permission_key.split(".") permission_hierarchy = permission_key.split(".")
# Retrieve user group permissions # Retrieve user group permissions
user_groups = Groups.get_groups_by_member_id(user_id) user_groups = Groups.get_groups_by_member_id(user_id, db=db)
for group in user_groups: for group in user_groups:
if get_permission(group.permissions or {}, permission_hierarchy): if get_permission(group.permissions or {}, permission_hierarchy):
@@ -127,6 +129,7 @@ def has_access(
access_control: Optional[dict] = None, access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None, user_group_ids: Optional[Set[str]] = None,
strict: bool = True, strict: bool = True,
db: Optional[Any] = None,
) -> bool: ) -> bool:
if access_control is None: if access_control is None:
if strict: if strict:
@@ -135,7 +138,7 @@ def has_access(
return True return True
if user_group_ids is None: if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id) user_groups = Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = {group.id for group in user_groups} user_group_ids = {group.id for group in user_groups}
permitted_ids = get_permitted_group_and_user_ids(type, access_control) permitted_ids = get_permitted_group_and_user_ids(type, access_control)
@@ -152,10 +155,10 @@ def has_access(
# Get all users with access to a resource # Get all users with access to a resource
def get_users_with_access( def get_users_with_access(
type: str = "write", access_control: Optional[dict] = None type: str = "write", access_control: Optional[dict] = None, db: Optional[Any] = None
) -> list[UserModel]: ) -> list[UserModel]:
if access_control is None: if access_control is None:
result = Users.get_users(filter={"roles": ["!pending"]}) result = Users.get_users(filter={"roles": ["!pending"]}, db=db)
return result.get("users", []) return result.get("users", [])
permitted_ids = get_permitted_group_and_user_ids(type, access_control) permitted_ids = get_permitted_group_and_user_ids(type, access_control)
@@ -167,8 +170,8 @@ def get_users_with_access(
user_ids_with_access = set(permitted_user_ids) user_ids_with_access = set(permitted_user_ids)
group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids) group_user_ids_map = Groups.get_group_user_ids_by_ids(permitted_group_ids, db=db)
for user_ids in group_user_ids_map.values(): for user_ids in group_user_ids_map.values():
user_ids_with_access.update(user_ids) user_ids_with_access.update(user_ids)
return Users.get_users_by_user_ids(list(user_ids_with_access)) return Users.get_users_by_user_ids(list(user_ids_with_access), db=db)
+45
View File
@@ -24,6 +24,8 @@ from opentelemetry import trace
from open_webui.utils.access_control import has_permission from open_webui.utils.access_control import has_permission
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.models.auths import Auths
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
@@ -228,6 +230,10 @@ async def is_valid_token(request, decoded) -> bool:
async def invalidate_token(request, token): async def invalidate_token(request, token):
decoded = decode_token(token) decoded = decode_token(token)
# If token is invalid/expired, nothing to revoke
if not decoded:
return
# Require Redis to store revoked tokens # Require Redis to store revoked tokens
if request.app.state.redis: if request.app.state.redis:
jti = decoded.get("jti") jti = decoded.get("jti")
@@ -271,6 +277,10 @@ async def get_current_user(
response: Response, response: Response,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
auth_token: HTTPAuthorizationCredentials = Depends(bearer_security), auth_token: HTTPAuthorizationCredentials = Depends(bearer_security),
# NOTE: We intentionally do NOT use Depends(get_session) here.
# Sessions are managed internally with short-lived context managers.
# This ensures connections are released immediately after auth queries,
# not held for the entire request duration (e.g., during 30+ second LLM calls).
): ):
token = None token = None
@@ -365,6 +375,7 @@ async def get_current_user(
def get_current_user_by_api_key(request, api_key: str): def get_current_user_by_api_key(request, api_key: str):
# Each function call manages its own short-lived session internally
user = Users.get_user_by_api_key(api_key) user = Users.get_user_by_api_key(api_key)
if user is None: if user is None:
@@ -413,3 +424,37 @@ def get_admin_user(user=Depends(get_current_user)):
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
return user return user
def create_admin_user(email: str, password: str, name: str = "Admin"):
"""
Create an admin user from environment variables.
Used for headless/automated deployments.
Returns the created user or None if creation failed.
"""
if not email or not password:
return None
if Users.has_users():
log.debug("Users already exist, skipping admin creation")
return None
log.info(f"Creating admin account from environment variables: {email}")
try:
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
email=email.lower(),
password=hashed,
name=name,
role="admin",
)
if user:
log.info(f"Admin account created successfully: {email}")
return user
else:
log.error("Failed to create admin account from environment variables")
return None
except Exception as e:
log.error(f"Error creating admin account: {e}")
return None
+16 -5
View File
@@ -166,6 +166,7 @@ async def generate_chat_completion(
form_data: dict, form_data: dict,
user: Any, user: Any,
bypass_filter: bool = False, bypass_filter: bool = False,
bypass_system_prompt: bool = False,
): ):
log.debug(f"generate_chat_completion: {form_data}") log.debug(f"generate_chat_completion: {form_data}")
if BYPASS_MODEL_ACCESS_CONTROL: if BYPASS_MODEL_ACCESS_CONTROL:
@@ -237,7 +238,11 @@ async def generate_chat_completion(
yield chunk yield chunk
response = await generate_chat_completion( response = await generate_chat_completion(
request, form_data, user, bypass_filter=True request,
form_data,
user,
bypass_filter=True,
bypass_system_prompt=bypass_system_prompt,
) )
return StreamingResponse( return StreamingResponse(
stream_wrapper(response.body_iterator), stream_wrapper(response.body_iterator),
@@ -248,7 +253,11 @@ async def generate_chat_completion(
return { return {
**( **(
await generate_chat_completion( await generate_chat_completion(
request, form_data, user, bypass_filter=True request,
form_data,
user,
bypass_filter=True,
bypass_system_prompt=bypass_system_prompt,
) )
), ),
"selected_model_id": selected_model_id, "selected_model_id": selected_model_id,
@@ -267,6 +276,7 @@ async def generate_chat_completion(
form_data=form_data, form_data=form_data,
user=user, user=user,
bypass_filter=bypass_filter, bypass_filter=bypass_filter,
bypass_system_prompt=bypass_system_prompt,
) )
if form_data.get("stream"): if form_data.get("stream"):
response.headers["content-type"] = "text/event-stream" response.headers["content-type"] = "text/event-stream"
@@ -283,6 +293,7 @@ async def generate_chat_completion(
form_data=form_data, form_data=form_data,
user=user, user=user,
bypass_filter=bypass_filter, bypass_filter=bypass_filter,
bypass_system_prompt=bypass_system_prompt,
) )
@@ -310,7 +321,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
try: try:
data = await process_pipeline_outlet_filter(request, data, user, models) data = await process_pipeline_outlet_filter(request, data, user, models)
except Exception as e: except Exception as e:
return Exception(f"Error: {e}") raise Exception(f"Error: {e}")
metadata = { metadata = {
"chat_id": data["chat_id"], "chat_id": data["chat_id"],
@@ -346,7 +357,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
) )
return result return result
except Exception as e: except Exception as e:
return Exception(f"Error: {e}") raise Exception(f"Error: {e}")
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any): async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
@@ -442,6 +453,6 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
data = action(**params) data = action(**params)
except Exception as e: except Exception as e:
return Exception(f"Error: {e}") raise Exception(f"Error: {e}")
return data return data
@@ -28,9 +28,7 @@ def has_permission(db, DocumentModel, query, filter: dict, permission: str = "re
for gid in group_ids: for gid in group_ids:
if dialect_name == "sqlite": if dialect_name == "sqlite":
group_read_conditions.append( group_read_conditions.append(
DocumentModel.access_control["read"]["group_ids"].contains( DocumentModel.access_control["read"]["group_ids"].contains(gid)
[gid]
)
) )
elif dialect_name == "postgresql": elif dialect_name == "postgresql":
group_read_conditions.append( group_read_conditions.append(
@@ -63,9 +61,7 @@ def has_permission(db, DocumentModel, query, filter: dict, permission: str = "re
for gid in group_ids: for gid in group_ids:
if dialect_name == "sqlite": if dialect_name == "sqlite":
group_write_conditions.append( group_write_conditions.append(
DocumentModel.access_control["write"]["group_ids"].contains( DocumentModel.access_control["write"]["group_ids"].contains(gid)
[gid]
)
) )
elif dialect_name == "postgresql": elif dialect_name == "postgresql":
group_write_conditions.append( group_write_conditions.append(
@@ -111,9 +107,7 @@ def has_permission(db, DocumentModel, query, filter: dict, permission: str = "re
for gid in group_ids: for gid in group_ids:
if dialect_name == "sqlite": if dialect_name == "sqlite":
group_conditions.append( group_conditions.append(
DocumentModel.access_control[permission]["group_ids"].contains( DocumentModel.access_control[permission]["group_ids"].contains(gid)
[gid]
)
) )
elif dialect_name == "postgresql": elif dialect_name == "postgresql":
group_conditions.append( group_conditions.append(
+2 -1
View File
@@ -7,6 +7,7 @@ log = logging.getLogger(__name__)
def apply_default_group_assignment( def apply_default_group_assignment(
default_group_id: str, default_group_id: str,
user_id: str, user_id: str,
db=None,
) -> None: ) -> None:
""" """
Apply default group assignment to a user if default_group_id is provided. Apply default group assignment to a user if default_group_id is provided.
@@ -17,7 +18,7 @@ def apply_default_group_assignment(
""" """
if default_group_id: if default_group_id:
try: try:
Groups.add_users_to_group(default_group_id, [user_id]) Groups.add_users_to_group(default_group_id, [user_id], db=db)
except Exception as e: except Exception as e:
log.error( log.error(
f"Failed to add user {user_id} to default group {default_group_id}: {e}" f"Failed to add user {user_id} to default group {default_group_id}: {e}"
+8 -5
View File
@@ -64,8 +64,8 @@ def get_history(prompt_id, base_url, api_key):
return json.loads(response.read()) return json.loads(response.read())
def get_images(ws, prompt, client_id, base_url, api_key): def get_images(ws, workflow, client_id, base_url, api_key):
prompt_id = queue_prompt(prompt, client_id, base_url, api_key)["prompt_id"] prompt_id = queue_prompt(workflow, client_id, base_url, api_key)["prompt_id"]
output_images = [] output_images = []
while True: while True:
out = ws.recv() out = ws.recv()
@@ -79,9 +79,12 @@ def get_images(ws, prompt, client_id, base_url, api_key):
continue # previews are binary data continue # previews are binary data
history = get_history(prompt_id, base_url, api_key)[prompt_id] history = get_history(prompt_id, base_url, api_key)[prompt_id]
for o in history["outputs"]: for node_id in history["outputs"]:
for node_id in history["outputs"]: node_output = history["outputs"][node_id]
node_output = history["outputs"][node_id] if node_id in workflow and workflow[node_id].get("class_type") in [
"SaveImage",
"PreviewImage",
]:
if "images" in node_output: if "images" in node_output:
for image in node_output["images"]: for image in node_output["images"]:
url = get_image_url( url = get_image_url(
+8 -4
View File
@@ -6,11 +6,13 @@ from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from opentelemetry import trace from opentelemetry import trace
from open_webui.env import ( from open_webui.env import (
AUDIT_UVICORN_LOGGER_NAMES, ENABLE_AUDIT_STDOUT,
ENABLE_AUDIT_LOGS_FILE,
AUDIT_LOGS_FILE_PATH,
AUDIT_LOG_FILE_ROTATION_SIZE, AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL, AUDIT_LOG_LEVEL,
AUDIT_LOGS_FILE_PATH,
GLOBAL_LOG_LEVEL, GLOBAL_LOG_LEVEL,
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_OTEL, ENABLE_OTEL,
ENABLE_OTEL_LOGS, ENABLE_OTEL_LOGS,
) )
@@ -130,9 +132,11 @@ def start_logger():
sys.stdout, sys.stdout,
level=GLOBAL_LOG_LEVEL, level=GLOBAL_LOG_LEVEL,
format=stdout_format, format=stdout_format,
filter=lambda record: "auditable" not in record["extra"], filter=lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
),
) )
if AUDIT_LOG_LEVEL != "NONE": if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
try: try:
logger.add( logger.add(
AUDIT_LOGS_FILE_PATH, AUDIT_LOGS_FILE_PATH,
+423 -59
View File
@@ -44,6 +44,7 @@ from open_webui.routers.retrieval import (
process_web_search, process_web_search,
SearchForm, SearchForm,
) )
from open_webui.utils.tools import get_builtin_tools
from open_webui.routers.images import ( from open_webui.routers.images import (
image_generations, image_generations,
CreateImageForm, CreateImageForm,
@@ -92,7 +93,11 @@ from open_webui.utils.misc import (
convert_logit_bias_input_to_json, convert_logit_bias_input_to_json,
get_content_from_message, get_content_from_message,
) )
from open_webui.utils.tools import get_tools, get_updated_tool_function from open_webui.utils.tools import (
get_tools,
get_updated_tool_function,
has_tool_server_access,
)
from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.plugin import load_function_module_by_id
from open_webui.utils.filter import ( from open_webui.utils.filter import (
get_sorted_filter_ids, get_sorted_filter_ids,
@@ -118,6 +123,7 @@ from open_webui.env import (
BYPASS_MODEL_ACCESS_CONTROL, BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE, ENABLE_REALTIME_CHAT_SAVE,
ENABLE_QUERIES_CACHE, ENABLE_QUERIES_CACHE,
RAG_SYSTEM_CONTEXT,
) )
from open_webui.constants import TASKS from open_webui.constants import TASKS
@@ -140,6 +146,196 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")] DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
def get_citation_source_from_tool_result(
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ""
) -> list[dict]:
"""
Parse a tool's result and convert it to source dicts for citation display.
Follows the source format conventions from get_sources_from_items:
- source: file/item info object with id, name, type
- document: list of document contents
- metadata: list of metadata objects with source, file_id, name fields
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
"""
try:
if tool_name == "search_web":
# Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}]
results = json.loads(tool_result)
documents = []
metadata = []
for result in results:
title = result.get("title", "")
link = result.get("link", "")
snippet = result.get("snippet", "")
documents.append(f"{title}\n{snippet}")
metadata.append(
{
"source": link,
"name": title,
"url": link,
}
)
return [
{
"source": {"name": "search_web", "id": "search_web"},
"document": documents,
"metadata": metadata,
}
]
elif tool_name == "view_knowledge_file":
file_data = json.loads(tool_result)
filename = file_data.get("filename", "Unknown File")
file_id = file_data.get("id", "")
knowledge_name = file_data.get("knowledge_name", "")
return [
{
"source": {
"id": file_id,
"name": filename,
"type": "file",
},
"document": [file_data.get("content", "")],
"metadata": [
{
"file_id": file_id,
"name": filename,
"source": filename,
**(
{"knowledge_name": knowledge_name}
if knowledge_name
else {}
),
}
],
}
]
elif tool_name == "query_knowledge_files":
chunks = json.loads(tool_result)
# Group chunks by source for better citation display
# Each unique source becomes a separate source entry
sources_by_file = {}
for chunk in chunks:
source_name = chunk.get("source", "Unknown")
file_id = chunk.get("file_id", "")
note_id = chunk.get("note_id", "")
chunk_type = chunk.get("type", "file")
content = chunk.get("content", "")
# Use file_id or note_id as the key
key = file_id or note_id or source_name
if key not in sources_by_file:
sources_by_file[key] = {
"source": {
"id": file_id or note_id,
"name": source_name,
"type": chunk_type,
},
"document": [],
"metadata": [],
}
sources_by_file[key]["document"].append(content)
sources_by_file[key]["metadata"].append(
{
"file_id": file_id,
"name": source_name,
"source": source_name,
**({"note_id": note_id} if note_id else {}),
}
)
# Return all grouped sources as a list
if sources_by_file:
return list(sources_by_file.values())
# Empty result fallback
return []
else:
# Fallback for other tools
return [
{
"source": {
"name": tool_name,
"type": "tool",
"id": tool_id or tool_name,
},
"document": [str(tool_result)],
"metadata": [{"source": tool_name, "name": tool_name}],
}
]
except Exception as e:
log.exception(f"Error parsing tool result for {tool_name}: {e}")
return [
{
"source": {"name": tool_name, "type": "tool"},
"document": [str(tool_result)],
"metadata": [{"source": tool_name}],
}
]
def apply_source_context_to_messages(
request: Request,
messages: list,
sources: list,
user_message: str,
) -> list:
"""
Build source context from citation sources and apply to messages.
Uses RAG template to format context for model consumption.
"""
if not sources or not user_message:
return messages
context_string = ""
citation_idx = {}
for source in sources:
for doc, meta in zip(source.get("document", []), source.get("metadata", [])):
src_id = meta.get("source") or source.get("source", {}).get("id") or "N/A"
if src_id not in citation_idx:
citation_idx[src_id] = len(citation_idx) + 1
src_name = source.get("source", {}).get("name")
context_string += (
f'<source id="{citation_idx[src_id]}"'
+ (f' name="{src_name}"' if src_name else "")
+ f">{doc}</source>\n"
)
context_string = context_string.strip()
if not context_string:
return messages
if RAG_SYSTEM_CONTEXT:
return add_or_update_system_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, user_message
),
messages,
append=True,
)
else:
return add_or_update_user_message(
rag_template(
request.app.state.config.RAG_TEMPLATE, context_string, user_message
),
messages,
append=False,
)
def process_tool_result( def process_tool_result(
request, request,
tool_function_name, tool_function_name,
@@ -724,6 +920,8 @@ def get_images_from_messages(message_list):
for file in message.get("files", []): for file in message.get("files", []):
if file.get("type") == "image": if file.get("type") == "image":
message_images.append(file.get("url")) message_images.append(file.get("url"))
elif file.get("content_type", "").startswith("image/"):
message_images.append(file.get("url"))
if message_images: if message_images:
images.append(message_images) images.append(message_images)
@@ -752,6 +950,51 @@ def get_image_urls(delta_images, request, metadata, user) -> list[str]:
return image_urls return image_urls
def add_file_context(messages: list, chat_id: str, user) -> list:
"""
Add file URLs to messages for native function calling.
"""
if not chat_id or chat_id.startswith("local:"):
return messages
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
if not chat:
return messages
history = chat.chat.get("history", {})
stored_messages = get_message_list(
history.get("messages", {}), history.get("currentId")
)
def format_file_tag(file):
attrs = f'type="{file.get("type", "file")}" url="{file["url"]}"'
if file.get("content_type"):
attrs += f' content_type="{file["content_type"]}"'
if file.get("name"):
attrs += f' name="{file["name"]}"'
return f"<file {attrs}/>"
for message, stored_message in zip(messages, stored_messages):
files_with_urls = [
file for file in stored_message.get("files", []) if file.get("url")
]
if not files_with_urls:
continue
file_tags = [format_file_tag(file) for file in files_with_urls]
file_context = (
"<attached_files>\n" + "\n".join(file_tags) + "\n</attached_files>\n\n"
)
content = message.get("content", "")
if isinstance(content, list):
message["content"] = [{"type": "text", "text": file_context}] + content
else:
message["content"] = file_context + content
return messages
async def chat_image_generation_handler( async def chat_image_generation_handler(
request: Request, form_data: dict, extra_params: dict, user request: Request, form_data: dict, extra_params: dict, user
): ):
@@ -1196,6 +1439,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__oauth_token__": oauth_token, "__oauth_token__": oauth_token,
"__request__": request, "__request__": request,
"__model__": model, "__model__": model,
"__chat_id__": metadata.get("chat_id"),
"__message_id__": metadata.get("message_id"),
} }
# Initialize events to store additional event to be sent to the client # Initialize events to store additional event to be sent to the client
# Initialize contexts and citation # Initialize contexts and citation
@@ -1239,7 +1484,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
user_message = get_last_user_message(form_data["messages"]) user_message = get_last_user_message(form_data["messages"])
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False) model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
if model_knowledge: if (
model_knowledge
and metadata.get("params", {}).get("function_calling") != "native"
):
await event_emitter( await event_emitter(
{ {
"type": "status", "type": "status",
@@ -1305,7 +1553,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
except Exception as e: except Exception as e:
raise Exception(f"{e}") raise Exception(f"{e}")
features = form_data.pop("features", None) features = form_data.pop("features", None) or {}
extra_params["__features__"] = features
if features: if features:
if "voice" in features and features["voice"]: if "voice" in features and features["voice"]:
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None: if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None:
@@ -1320,19 +1569,25 @@ async def process_chat_payload(request, form_data, user, metadata, model):
) )
if "memory" in features and features["memory"]: if "memory" in features and features["memory"]:
form_data = await chat_memory_handler( # Skip forced memory injection when native FC is enabled - model can use memory tools
request, form_data, extra_params, user if metadata.get("params", {}).get("function_calling") != "native":
) form_data = await chat_memory_handler(
request, form_data, extra_params, user
)
if "web_search" in features and features["web_search"]: if "web_search" in features and features["web_search"]:
form_data = await chat_web_search_handler( # Skip forced RAG web search when native FC is enabled - model can use web_search tool
request, form_data, extra_params, user if metadata.get("params", {}).get("function_calling") != "native":
) form_data = await chat_web_search_handler(
request, form_data, extra_params, user
)
if "image_generation" in features and features["image_generation"]: if "image_generation" in features and features["image_generation"]:
form_data = await chat_image_generation_handler( # Skip forced image generation when native FC is enabled - model can use generate_image tool
request, form_data, extra_params, user if metadata.get("params", {}).get("function_calling") != "native":
) form_data = await chat_image_generation_handler(
request, form_data, extra_params, user
)
if "code_interpreter" in features and features["code_interpreter"]: if "code_interpreter" in features and features["code_interpreter"]:
form_data["messages"] = add_or_update_user_message( form_data["messages"] = add_or_update_user_message(
@@ -1412,6 +1667,13 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.error(f"MCP server with id {server_id} not found") log.error(f"MCP server with id {server_id} not found")
continue continue
# Check access control for MCP server
if not has_tool_server_access(user, mcp_server_connection):
log.warning(
f"Access denied to MCP server {server_id} for user {user.id}"
)
continue
auth_type = mcp_server_connection.get("auth_type", "") auth_type = mcp_server_connection.get("auth_type", "")
headers = {} headers = {}
if auth_type == "bearer": if auth_type == "bearer":
@@ -1543,6 +1805,36 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if mcp_clients: if mcp_clients:
metadata["mcp_clients"] = mcp_clients metadata["mcp_clients"] = mcp_clients
# Inject builtin tools for native function calling based on enabled features and model capability
# Check if builtin_tools capability is enabled for this model (defaults to True if not specified)
builtin_tools_enabled = (
model.get("info", {})
.get("meta", {})
.get("capabilities", {})
.get("builtin_tools", True)
)
if (
metadata.get("params", {}).get("function_calling") == "native"
and builtin_tools_enabled
):
# Add file context to user messages
chat_id = metadata.get("chat_id")
form_data["messages"] = add_file_context(
form_data.get("messages", []), chat_id, user
)
builtin_tools = get_builtin_tools(
request,
{
**extra_params,
"__event_emitter__": event_emitter,
},
features,
model,
)
for name, tool_dict in builtin_tools.items():
if name not in tools_dict:
tools_dict[name] = tool_dict
if tools_dict: if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native": if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler # If the function calling is native, then call the tools function calling handler
@@ -1551,6 +1843,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})} {"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values() for tool in tools_dict.values()
] ]
else: else:
# If the function calling is not native, then call the tools function calling handler # If the function calling is not native, then call the tools function calling handler
try: try:
@@ -1561,54 +1854,28 @@ async def process_chat_payload(request, form_data, user, metadata, model):
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
try: # Check if file context extraction is enabled for this model (default True)
form_data, flags = await chat_completion_files_handler( file_context_enabled = (
request, form_data, extra_params, user model.get("info", {})
) .get("meta", {})
sources.extend(flags.get("sources", [])) .get("capabilities", {})
except Exception as e: .get("file_context", True)
log.exception(e) )
if file_context_enabled:
try:
form_data, flags = await chat_completion_files_handler(
request, form_data, extra_params, user
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
# If context is not empty, insert it into the messages # If context is not empty, insert it into the messages
if len(sources) > 0: if sources and prompt:
context_string = "" form_data["messages"] = apply_source_context_to_messages(
citation_idx_map = {} request, form_data["messages"], sources, prompt
)
for source in sources:
if "document" in source:
for document_text, document_metadata in zip(
source["document"], source["metadata"]
):
source_name = source.get("source", {}).get("name", None)
source_id = (
document_metadata.get("source", None)
or source.get("source", {}).get("id", None)
or "N/A"
)
if source_id not in citation_idx_map:
citation_idx_map[source_id] = len(citation_idx_map) + 1
context_string += (
f'<source id="{citation_idx_map[source_id]}"'
+ (f' name="{source_name}"' if source_name else "")
+ f">{document_text}</source>\n"
)
context_string = context_string.strip()
if prompt is None:
raise Exception("No user message found")
if context_string != "":
form_data["messages"] = add_or_update_user_message(
rag_template(
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
append=False,
)
# If there are citations, add them to the data_items # If there are citations, add them to the data_items
sources = [ sources = [
@@ -2605,8 +2872,42 @@ async def process_chat_response(
continue continue
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
delta_tool_calls = delta.get("tool_calls", None)
# Handle delta annotations
annotations = delta.get("annotations")
if annotations:
for annotation in annotations:
if (
annotation.get("type") == "url_citation"
and "url_citation" in annotation
):
url_citation = annotation[
"url_citation"
]
url = url_citation.get("url", "")
title = url_citation.get("title", url)
await event_emitter(
{
"type": "source",
"data": {
"source": {
"name": title,
"url": url,
},
"document": [title],
"metadata": [
{
"source": url,
"name": title,
}
],
},
}
)
delta_tool_calls = delta.get("tool_calls", None)
if delta_tool_calls: if delta_tool_calls:
for delta_tool_call in delta_tool_calls: for delta_tool_call in delta_tool_calls:
tool_call_index = delta_tool_call.get( tool_call_index = delta_tool_call.get(
@@ -2665,6 +2966,29 @@ async def process_chat_response(
"arguments" "arguments"
] += delta_arguments ] += delta_arguments
# Emit pending tool calls in real-time
if response_tool_calls:
# Flush any pending text first
await flush_pending_delta_data()
pending_content_blocks = content_blocks + [
{
"type": "tool_calls",
"content": response_tool_calls,
"pending": True,
}
]
await event_emitter(
{
"type": "chat:completion",
"data": {
"content": serialize_content_blocks(
pending_content_blocks
),
},
}
)
image_urls = get_image_urls( image_urls = get_image_urls(
delta.get("images", []), request, metadata, user delta.get("images", []), request, metadata, user
) )
@@ -2878,6 +3202,7 @@ async def process_chat_response(
await stream_body_handler(response, form_data) await stream_body_handler(response, form_data)
tool_call_retries = 0 tool_call_retries = 0
tool_call_sources = [] # Track citation sources from tool results
while ( while (
len(tool_calls) > 0 len(tool_calls) > 0
@@ -3012,6 +3337,27 @@ async def process_chat_response(
) )
) )
# Extract citation sources from tool results
if (
tool_function_name
in [
"search_web",
"view_knowledge_file",
"query_knowledge_files",
]
and tool_result
):
try:
citation_sources = get_citation_source_from_tool_result(
tool_name=tool_function_name,
tool_params=tool_function_params,
tool_result=tool_result,
tool_id=tool.get("tool_id", "") if tool else "",
)
tool_call_sources.extend(citation_sources)
except Exception as e:
log.exception(f"Error extracting citation source: {e}")
results.append( results.append(
{ {
"tool_call_id": tool_call_id, "tool_call_id": tool_call_id,
@@ -3037,6 +3383,22 @@ async def process_chat_response(
} }
) )
# Emit citation sources for UI display
for source in tool_call_sources:
await event_emitter({"type": "source", "data": source})
# Apply source context to messages for model
if tool_call_sources:
user_msg = get_last_user_message(form_data["messages"])
if user_msg:
form_data["messages"] = apply_source_context_to_messages(
request,
form_data["messages"],
tool_call_sources,
user_msg,
)
tool_call_sources.clear()
await event_emitter( await event_emitter(
{ {
"type": "chat:completion", "type": "chat:completion",
@@ -3063,6 +3425,7 @@ async def process_chat_response(
request, request,
new_form_data, new_form_data,
user, user,
bypass_system_prompt=True,
) )
if isinstance(res, StreamingResponse): if isinstance(res, StreamingResponse):
@@ -3242,6 +3605,7 @@ async def process_chat_response(
request, request,
new_form_data, new_form_data,
user, user,
bypass_system_prompt=True,
) )
if isinstance(res, StreamingResponse): if isinstance(res, StreamingResponse):
+8 -5
View File
@@ -337,7 +337,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
return models return models
def check_model_access(user, model): def check_model_access(user, model, db=None):
if model.get("arena"): if model.get("arena"):
if not has_access( if not has_access(
user.id, user.id,
@@ -345,22 +345,23 @@ def check_model_access(user, model):
access_control=model.get("info", {}) access_control=model.get("info", {})
.get("meta", {}) .get("meta", {})
.get("access_control", {}), .get("access_control", {}),
db=db,
): ):
raise Exception("Model not found") raise Exception("Model not found")
else: else:
model_info = Models.get_model_by_id(model.get("id")) model_info = Models.get_model_by_id(model.get("id"), db=db)
if not model_info: if not model_info:
raise Exception("Model not found") raise Exception("Model not found")
elif not ( elif not (
user.id == model_info.user_id user.id == model_info.user_id
or has_access( or has_access(
user.id, type="read", access_control=model_info.access_control user.id, type="read", access_control=model_info.access_control, db=db
) )
): ):
raise Exception("Model not found") raise Exception("Model not found")
def get_filtered_models(models, user): def get_filtered_models(models, user, db=None):
# Filter out models that the user does not have access to # Filter out models that the user does not have access to
if ( if (
user.role == "user" user.role == "user"
@@ -373,7 +374,9 @@ def get_filtered_models(models, user):
} }
filtered_models = [] filtered_models = []
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
for model in models: for model in models:
if model.get("arena"): if model.get("arena"):
if has_access( if has_access(
+95 -30
View File
@@ -246,10 +246,66 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
return parsed, base_url return parsed, base_url
def get_discovery_urls(server_url) -> list[str]: async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
parsed, base_url = get_parsed_and_base_url(server_url) """
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
"""
urls = [] authorization_servers = []
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
server_url,
json={"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1},
headers={"Content-Type": "application/json"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
if response.status == 401:
match = re.search(
r'resource_metadata="([^"]+)"',
response.headers.get("WWW-Authenticate", ""),
)
if match:
resource_metadata_url = match.group(1)
log.debug(
f"Found resource_metadata URL: {resource_metadata_url}"
)
# Step 2: Fetch Protected Resource metadata
async with session.get(
resource_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resource_response:
if resource_response.status == 200:
resource_metadata = await resource_response.json()
# Step 3: Extract authorization_servers
servers = resource_metadata.get(
"authorization_servers", []
)
if servers:
authorization_servers = servers
log.debug(
f"Discovered authorization servers: {servers}"
)
except Exception as e:
log.debug(f"MCP Protected Resource discovery failed: {e}")
discovery_urls = []
for auth_server in authorization_servers:
auth_server = auth_server.rstrip("/")
discovery_urls.extend(
[
f"{auth_server}/.well-known/oauth-authorization-server",
f"{auth_server}/.well-known/openid-configuration",
]
)
return discovery_urls
async def get_discovery_urls(server_url) -> list[str]:
urls = await get_authorization_server_discovery_urls(server_url)
parsed, base_url = get_parsed_and_base_url(server_url)
if parsed.path and parsed.path != "/": if parsed.path and parsed.path != "/":
# Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery # Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery
@@ -303,7 +359,7 @@ async def get_oauth_client_info_with_dynamic_client_registration(
) )
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes # Attempt to fetch OAuth server metadata to get registration endpoint & scopes
discovery_urls = get_discovery_urls(oauth_server_url) discovery_urls = await get_discovery_urls(oauth_server_url)
for url in discovery_urls: for url in discovery_urls:
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get( async with session.get(
@@ -579,24 +635,27 @@ class OAuthClientManager:
def get_client(self, client_id): def get_client(self, client_id):
if client_id not in self.clients: if client_id not in self.clients:
self.ensure_client_from_config(client_id) self.ensure_client_from_config(client_id)
client = self.clients.get(client_id) client = self.clients.get(client_id)
return client["client"] if client else None return client["client"] if client else None
def get_client_info(self, client_id): def get_client_info(self, client_id):
if client_id not in self.clients: if client_id not in self.clients:
self.ensure_client_from_config(client_id) self.ensure_client_from_config(client_id)
client = self.clients.get(client_id) client = self.clients.get(client_id)
return client["client_info"] if client else None return client["client_info"] if client else None
def get_server_metadata_url(self, client_id): def get_server_metadata_url(self, client_id):
if client_id in self.clients: client = self.get_client(client_id)
client = self.clients[client_id] if not client:
return ( return None
client._server_metadata_url
if hasattr(client, "_server_metadata_url") return (
else None client._server_metadata_url
) if hasattr(client, "_server_metadata_url")
return None else None
)
async def get_oauth_token( async def get_oauth_token(
self, user_id: str, client_id: str, force_refresh: bool = False self, user_id: str, client_id: str, force_refresh: bool = False
@@ -1132,7 +1191,7 @@ class OAuthManager:
return role return role
def update_user_groups(self, user, user_data, default_permissions): def update_user_groups(self, user, user_data, default_permissions, db=None):
log.debug("Running OAUTH Group management") log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
@@ -1161,8 +1220,10 @@ class OAuthManager:
else: else:
user_oauth_groups = [] user_oauth_groups = []
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(
all_available_groups: list[GroupModel] = Groups.get_all_groups() user.id, db=db
)
all_available_groups: list[GroupModel] = Groups.get_all_groups(db=db)
# Create groups if they don't exist and creation is enabled # Create groups if they don't exist and creation is enabled
if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION:
@@ -1188,7 +1249,7 @@ class OAuthManager:
) )
# Use determined creator ID (admin or fallback to current user) # Use determined creator ID (admin or fallback to current user)
created_group = Groups.insert_new_group( created_group = Groups.insert_new_group(
creator_id, new_group_form creator_id, new_group_form, db=db
) )
if created_group: if created_group:
log.info( log.info(
@@ -1206,7 +1267,7 @@ class OAuthManager:
# Refresh the list of all available groups if any were created # Refresh the list of all available groups if any were created
if groups_created: if groups_created:
all_available_groups = Groups.get_all_groups() all_available_groups = Groups.get_all_groups(db=db)
log.debug("Refreshed list of all available groups after creation.") log.debug("Refreshed list of all available groups after creation.")
log.debug(f"Oauth Groups claim: {oauth_claim}") log.debug(f"Oauth Groups claim: {oauth_claim}")
@@ -1227,7 +1288,7 @@ class OAuthManager:
log.debug( log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups" f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
) )
Groups.remove_users_from_group(group_model.id, [user.id]) Groups.remove_users_from_group(group_model.id, [user.id], db=db)
# In case a group is created, but perms are never assigned to the group by hitting "save" # In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions group_permissions = group_model.permissions
@@ -1242,6 +1303,7 @@ class OAuthManager:
permissions=group_permissions, permissions=group_permissions,
), ),
overwrite=False, overwrite=False,
db=db,
) )
# Add user to new groups # Add user to new groups
@@ -1257,7 +1319,7 @@ class OAuthManager:
f"Adding user to group {group_model.name} as it was found in their oauth groups" f"Adding user to group {group_model.name} as it was found in their oauth groups"
) )
Groups.add_users_to_group(group_model.id, [user.id]) Groups.add_users_to_group(group_model.id, [user.id], db=db)
# In case a group is created, but perms are never assigned to the group by hitting "save" # In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions group_permissions = group_model.permissions
@@ -1272,6 +1334,7 @@ class OAuthManager:
permissions=group_permissions, permissions=group_permissions,
), ),
overwrite=False, overwrite=False,
db=db,
) )
async def _process_picture_url( async def _process_picture_url(
@@ -1336,7 +1399,7 @@ class OAuthManager:
return await client.authorize_redirect(request, redirect_uri, **kwargs) return await client.authorize_redirect(request, redirect_uri, **kwargs)
async def handle_callback(self, request, provider, response): async def handle_callback(self, request, provider, response, db=None):
if provider not in OAUTH_PROVIDERS: if provider not in OAUTH_PROVIDERS:
raise HTTPException(404) raise HTTPException(404)
@@ -1461,20 +1524,20 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists # Check if the user exists
user = Users.get_user_by_oauth_sub(provider, sub) user = Users.get_user_by_oauth_sub(provider, sub, db=db)
if not user: if not user:
# If the user does not exist, check if merging is enabled # If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL: if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email # Check if the user exists by email
user = Users.get_user_by_email(email) user = Users.get_user_by_email(email, db=db)
if user: if user:
# Update the user with the new oauth sub # Update the user with the new oauth sub
Users.update_user_oauth_by_id(user.id, provider, sub) Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
if user: if user:
determined_role = self.get_user_role(user, user_data) determined_role = self.get_user_role(user, user_data)
if user.role != determined_role: if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role) Users.update_user_role_by_id(user.id, determined_role, db=db)
# Update the user object in memory as well, # Update the user object in memory as well,
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
user.role = determined_role user.role = determined_role
@@ -1491,14 +1554,14 @@ class OAuthManager:
) )
if processed_picture_url != user.profile_image_url: if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id( Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url user.id, processed_picture_url, db=db
) )
log.debug(f"Updated profile picture for user {user.email}") log.debug(f"Updated profile picture for user {user.email}")
else: else:
# If the user does not exist, check if signups are enabled # If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP: if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists # Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email) existing_user = Users.get_user_by_email(email, db=db)
if existing_user: if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
@@ -1529,6 +1592,7 @@ class OAuthManager:
profile_image_url=picture_url, profile_image_url=picture_url,
role=self.get_user_role(None, user_data), role=self.get_user_role(None, user_data),
oauth=oauth_data, oauth=oauth_data,
db=db,
) )
if auth_manager_config.WEBHOOK_URL: if auth_manager_config.WEBHOOK_URL:
@@ -1544,8 +1608,7 @@ class OAuthManager:
) )
apply_default_group_assignment( apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID, request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db
user.id,
) )
else: else:
@@ -1566,6 +1629,7 @@ class OAuthManager:
user=user, user=user,
user_data=user_data, user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS, default_permissions=request.app.state.config.USER_PERMISSIONS,
db=db,
) )
except Exception as e: except Exception as e:
@@ -1616,15 +1680,16 @@ class OAuthManager:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"] token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first # Clean up any existing sessions for this user/provider first
sessions = OAuthSessions.get_sessions_by_user_id(user.id) sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db)
for session in sessions: for session in sessions:
if session.provider == provider: if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id) OAuthSessions.delete_session_by_id(session.id, db=db)
session = OAuthSessions.create_session( session = OAuthSessions.create_session(
user_id=user.id, user_id=user.id,
provider=provider, provider=provider,
token=token, token=token,
db=db,
) )
response.set_cookie( response.set_cookie(
+2
View File
@@ -6,6 +6,7 @@ from open_webui.utils.misc import (
) )
from typing import Callable, Optional from typing import Callable, Optional
import copy
import json import json
@@ -286,6 +287,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
Returns: Returns:
dict: A modified payload compatible with the Ollama API. dict: A modified payload compatible with the Ollama API.
""" """
openai_payload = copy.deepcopy(openai_payload)
ollama_payload = {} ollama_payload = {}
# Mapping basic model and message details # Mapping basic model and message details
+5 -1
View File
@@ -7,7 +7,7 @@ import types
import tempfile import tempfile
import logging import logging
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
from open_webui.models.functions import Functions from open_webui.models.functions import Functions
from open_webui.models.tools import Tools from open_webui.models.tools import Tools
@@ -264,6 +264,10 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
def install_frontmatter_requirements(requirements: str): def install_frontmatter_requirements(requirements: str):
if OFFLINE_MODE:
log.info("Offline mode enabled, skipping installation of requirements.")
return
if requirements: if requirements:
try: try:
req_list = [req.strip() for req in requirements.split(",")] req_list = [req.strip() for req in requirements.split(",")]
+32
View File
@@ -42,6 +42,38 @@ class SentinelRedisProxy:
return orig_attr return orig_attr
if self._async_mode: if self._async_mode:
if inspect.isasyncgenfunction(orig_attr):
def _wrapped_iter(*args, **kwargs):
async def _iter():
for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
try:
method = getattr(self._master(), item)
async for value in method(*args, **kwargs):
yield value
return
except (
redis.exceptions.ConnectionError,
redis.exceptions.ReadOnlyError,
) as e:
if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
log.debug(
"Redis sentinel fail-over (%s). Retry %s/%s",
type(e).__name__,
i + 1,
REDIS_SENTINEL_MAX_RETRY_COUNT,
)
continue
log.error(
"Redis operation failed after %s retries: %s",
REDIS_SENTINEL_MAX_RETRY_COUNT,
e,
)
raise e from e
return _iter()
return _wrapped_iter
async def _wrapped(*args, **kwargs): async def _wrapped(*args, **kwargs):
for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT): for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
@@ -141,9 +141,12 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
def observe_total_registered_users( def observe_total_registered_users(
options: metrics.CallbackOptions, options: metrics.CallbackOptions,
) -> Sequence[metrics.Observation]: ) -> Sequence[metrics.Observation]:
# IMPORTANT: Use get_num_users() for efficient COUNT(*) query.
# Do NOT use len(get_users()["users"]) - it loads ALL user records into memory,
# causing connection pool exhaustion on high-latency databases (e.g., Aurora).
return [ return [
metrics.Observation( metrics.Observation(
value=len(Users.get_users()["users"]), value=Users.get_num_users() or 0,
) )
] ]
+240 -67
View File
@@ -37,12 +37,42 @@ from langchain_core.utils.function_calling import (
from open_webui.utils.misc import is_string_allowed from open_webui.utils.misc import is_string_allowed
from open_webui.models.tools import Tools from open_webui.models.tools import Tools
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
from open_webui.models.groups import Groups
from open_webui.utils.plugin import load_tool_module_by_id from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.utils.access_control import has_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import ( from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT, AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) )
from open_webui.tools.builtin import (
search_web,
fetch_url,
generate_image,
edit_image,
search_memories,
add_memory,
replace_memory_content,
get_current_timestamp,
calculate_timestamp,
search_notes,
search_chats,
search_channels,
search_channel_messages,
view_note,
view_chat,
view_channel_message,
view_channel_thread,
replace_note_content,
write_note,
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
)
import copy import copy
@@ -104,15 +134,114 @@ def get_updated_tool_function(function: Callable, extra_params: dict):
return function return function
def has_tool_server_access(
user: UserModel, server_connection: dict, user_group_ids: set = None
) -> bool:
"""Check if user has access to a tool server (MCP or OpenAPI)."""
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
return True
if user_group_ids is None:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
access_control = server_connection.get("config", {}).get("access_control", None)
return has_access(user.id, "read", access_control, user_group_ids)
async def get_tools( async def get_tools(
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
) -> dict[str, dict]: ) -> dict[str, dict]:
"""Load tools for the given tool_ids, checking access control."""
tools_dict = {} tools_dict = {}
# Get user's group memberships for access control checks
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
for tool_id in tool_ids: for tool_id in tool_ids:
tool = Tools.get_tool_by_id(tool_id) tool = Tools.get_tool_by_id(tool_id)
if tool is None: if tool:
# Check access control for local tools
if (
not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
and tool.user_id != user.id
and not has_access(user.id, "read", tool.access_control, user_group_ids)
):
log.warning(f"Access denied to tool {tool_id} for user {user.id}")
continue
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_tool_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
__user__ = {
**extra_params["__user__"],
}
# Set valves for the tool
if hasattr(module, "valves") and hasattr(module, "Valves"):
valves = Tools.get_tool_valves_by_id(tool_id) or {}
module.valves = module.Valves(**valves)
if hasattr(module, "UserValves"):
__user__["valves"] = module.UserValves( # type: ignore
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id)
)
for spec in tool.specs:
# TODO: Fix hack for OpenAI API
# Some times breaks OpenAI but others don't. Leaving the comment
for val in spec.get("parameters", {}).get("properties", {}).values():
if val.get("type") == "str":
val["type"] = "string"
# Remove internal reserved parameters (e.g. __id__, __user__)
spec["parameters"]["properties"] = {
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
# convert to function that takes only model params and inserts custom params
function_name = spec["name"]
tool_function = getattr(module, function_name)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{
**extra_params,
"__id__": tool_id,
"__user__": __user__,
},
)
# TODO: Support Pydantic models as parameters
if callable.__doc__ and callable.__doc__.strip() != "":
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
else:
spec["description"] = function_name
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"metadata": {
"file_handler": hasattr(module, "file_handler")
and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
},
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend tool ID to function name
function_name = f"{tool_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
if tool_id.startswith("server:"): if tool_id.startswith("server:"):
splits = tool_id.split(":") splits = tool_id.split(":")
@@ -147,6 +276,15 @@ async def get_tools(
] ]
) )
# Check access control for tool server
if not has_tool_server_access(
user, tool_server_connection, user_group_ids
):
log.warning(
f"Access denied to tool server {server_id} for user {user.id}"
)
continue
specs = tool_server_data.get("specs", []) specs = tool_server_data.get("specs", [])
function_name_filter_list = tool_server_connection.get( function_name_filter_list = tool_server_connection.get(
"config", {} "config", {}
@@ -241,81 +379,116 @@ async def get_tools(
else: else:
continue continue
else: return tools_dict
continue
else:
module = request.app.state.TOOLS.get(tool_id, None)
if module is None:
module, _ = load_tool_module_by_id(tool_id)
request.app.state.TOOLS[tool_id] = module
__user__ = {
**extra_params["__user__"],
}
# Set valves for the tool def get_builtin_tools(
if hasattr(module, "valves") and hasattr(module, "Valves"): request: Request, extra_params: dict, features: dict = None, model: dict = None
valves = Tools.get_tool_valves_by_id(tool_id) or {} ) -> dict[str, dict]:
module.valves = module.Valves(**valves) """
if hasattr(module, "UserValves"): Get built-in tools for native function calling.
__user__["valves"] = module.UserValves( # type: ignore Only returns tools when BOTH the global config is enabled AND the model capability allows it.
**Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) """
) tools_dict = {}
builtin_functions = []
features = features or {}
model = model or {}
for spec in tool.specs: # Helper to get model capabilities (defaults to True if not specified)
# TODO: Fix hack for OpenAI API def get_model_capability(name: str, default: bool = True) -> bool:
# Some times breaks OpenAI but others don't. Leaving the comment return (
for val in spec.get("parameters", {}).get("properties", {}).values(): model.get("info", {})
if val.get("type") == "str": .get("meta", {})
val["type"] = "string" .get("capabilities", {})
.get(name, default)
)
# Remove internal reserved parameters (e.g. __id__, __user__) # Time utilities - always available for date calculations
spec["parameters"]["properties"] = { builtin_functions.extend([get_current_timestamp, calculate_timestamp])
key: val
for key, val in spec["parameters"]["properties"].items()
if not key.startswith("__")
}
# convert to function that takes only model params and inserts custom params # Knowledge base tools - conditional injection based on model knowledge
function_name = spec["name"] # If model has attached knowledge (any type), only provide query_knowledge_files
tool_function = getattr(module, function_name) # Otherwise, provide all KB browsing tools
callable = get_async_tool_function_and_apply_extra_params( model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", [])
tool_function, if model_knowledge:
{ # Model has attached knowledge - only allow semantic search within it
**extra_params, builtin_functions.append(query_knowledge_files)
"__id__": tool_id, else:
"__user__": __user__, # No model knowledge - allow full KB browsing
}, builtin_functions.extend(
) [
list_knowledge_bases,
search_knowledge_bases,
query_knowledge_bases,
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
]
)
# TODO: Support Pydantic models as parameters # Chats tools - search and fetch user's chat history
if callable.__doc__ and callable.__doc__.strip() != "": builtin_functions.extend([search_chats, view_chat])
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
else:
spec["description"] = function_name
tool_dict = { # Add memory tools if enabled for this chat
"tool_id": tool_id, if features.get("memory"):
"callable": callable, builtin_functions.extend([search_memories, add_memory, replace_memory_content])
"spec": spec,
# Misc info
"metadata": {
"file_handler": hasattr(module, "file_handler")
and module.file_handler,
"citation": hasattr(module, "citation") and module.citation,
},
}
# Handle function name collisions # Add web search tools if enabled globally AND model has web_search capability
while function_name in tools_dict: if getattr(
log.warning( request.app.state.config, "ENABLE_WEB_SEARCH", False
f"Tool {function_name} already exists in another tools!" ) and get_model_capability("web_search"):
) builtin_functions.extend([search_web, fetch_url])
# Prepend tool ID to function name
function_name = f"{tool_id}_{function_name}"
tools_dict[function_name] = tool_dict # Add image generation/edit tools if enabled globally AND model has image_generation capability
if getattr(
request.app.state.config, "ENABLE_IMAGE_GENERATION", False
) and get_model_capability("image_generation"):
builtin_functions.append(generate_image)
if getattr(
request.app.state.config, "ENABLE_IMAGE_EDIT", False
) and get_model_capability("image_generation"):
builtin_functions.append(edit_image)
# Notes tools - search, view, create, and update user's notes (if notes enabled globally)
if getattr(request.app.state.config, "ENABLE_NOTES", False):
builtin_functions.extend(
[search_notes, view_note, write_note, replace_note_content]
)
# Channels tools - search channels and messages (if channels enabled globally)
if getattr(request.app.state.config, "ENABLE_CHANNELS", False):
builtin_functions.extend(
[
search_channels,
search_channel_messages,
view_channel_thread,
view_channel_message,
]
)
for func in builtin_functions:
callable = get_async_tool_function_and_apply_extra_params(
func,
{
"__request__": request,
"__user__": extra_params.get("__user__", {}),
"__event_emitter__": extra_params.get("__event_emitter__"),
"__chat_id__": extra_params.get("__chat_id__"),
"__message_id__": extra_params.get("__message_id__"),
"__model_knowledge__": model_knowledge,
},
)
# Generate spec from function
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
tools_dict[func.__name__] = {
"tool_id": f"builtin:{func.__name__}",
"callable": callable,
"spec": spec,
"type": "builtin",
}
return tools_dict return tools_dict
+4 -2
View File
@@ -3,7 +3,7 @@ import logging
import aiohttp import aiohttp
from open_webui.config import WEBUI_FAVICON_URL from open_webui.config import WEBUI_FAVICON_URL
from open_webui.env import VERSION from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -50,7 +50,9 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
payload = {**event_data} payload = {**event_data}
log.debug(f"payload: {payload}") log.debug(f"payload: {payload}")
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
async with session.post(url, json=payload) as r: async with session.post(url, json=payload) as r:
r_text = await r.text() r_text = await r.text()
r.raise_for_status() r.raise_for_status()
+11 -8
View File
@@ -1,19 +1,19 @@
# Minimal requirements for backend to run # Minimal requirements for backend to run
# WIP: use this as a reference to build a minimal docker image # WIP: use this as a reference to build a minimal docker image
fastapi==0.126.0 fastapi==0.128.0
uvicorn[standard]==0.37.0 uvicorn[standard]==0.40.0
pydantic==2.12.5 pydantic==2.12.5
python-multipart==0.0.21 python-multipart==0.0.21
itsdangerous==2.2.0 itsdangerous==2.2.0
python-socketio==5.15.1 python-socketio==5.16.0
python-jose==3.5.0 python-jose==3.5.0
cryptography cryptography
bcrypt==5.0.0 bcrypt==5.0.0
argon2-cffi==25.1.0 argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1 PyJWT[crypto]==2.10.1
authlib==1.6.5 authlib==1.6.6
requests==2.32.5 requests==2.32.5
aiohttp==3.13.2 aiohttp==3.13.2
@@ -32,7 +32,7 @@ peewee-migrate==1.14.3
pycrdt==0.12.44 pycrdt==0.12.44
redis redis
APScheduler==3.11.1 APScheduler==3.11.2
RestrictedPython==8.1 RestrictedPython==8.1
loguru==0.7.3 loguru==0.7.3
@@ -41,11 +41,14 @@ asgiref==3.11.0
mcp==1.25.0 mcp==1.25.0
openai openai
langchain==0.3.27 langchain==1.2.0
langchain-community==0.3.29 langchain-community==0.4.1
langchain-classic==1.0.1
langchain-text-splitters==1.1.0
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==1.3.7 chromadb==1.4.0
black==25.12.0 black==25.12.0
pydub pydub
chardet==5.2.0 chardet==5.2.0
+13 -14
View File
@@ -1,10 +1,10 @@
fastapi==0.126.0 fastapi==0.128.0
uvicorn[standard]==0.37.0 uvicorn[standard]==0.40.0
pydantic==2.12.5 pydantic==2.12.5
python-multipart==0.0.21 python-multipart==0.0.21
itsdangerous==2.2.0 itsdangerous==2.2.0
python-socketio==5.15.1 python-socketio==5.16.0
python-jose==3.5.0 python-jose==3.5.0
cryptography cryptography
bcrypt==5.0.0 bcrypt==5.0.0
@@ -30,7 +30,7 @@ peewee-migrate==1.14.3
pycrdt==0.12.44 pycrdt==0.12.44
redis redis
APScheduler==3.11.1 APScheduler==3.11.2
RestrictedPython==8.1 RestrictedPython==8.1
loguru==0.7.3 loguru==0.7.3
@@ -43,16 +43,15 @@ mcp==1.25.0
openai openai
anthropic anthropic
google-genai==1.56.0 google-genai==1.56.0
google-generativeai==0.8.6
langchain==1.2.0 langchain==1.2.0
langchain-community==0.4.1 langchain-community==0.4.1
langchain-classic==1.0.0 langchain-classic==1.0.1
langchain-text-splitters==1.1.0 langchain-text-splitters==1.1.0
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==1.3.7 chromadb==1.4.0
weaviate-client==4.19.0 weaviate-client==4.19.2
opensearch-py==3.1.0 opensearch-py==3.1.0
transformers==4.57.3 transformers==4.57.3
@@ -65,10 +64,10 @@ ftfy==6.3.1
chardet==5.2.0 chardet==5.2.0
pypdf==6.5.0 pypdf==6.5.0
fpdf2==2.8.5 fpdf2==2.8.5
pymdown-extensions==10.19.1 pymdown-extensions==10.20
docx2txt==0.9 docx2txt==0.9
python-pptx==1.0.2 python-pptx==1.0.2
unstructured==0.18.21 unstructured==0.18.24
msoffcrypto-tool==5.4.2 msoffcrypto-tool==5.4.2
nltk==3.9.2 nltk==3.9.2
Markdown==3.10 Markdown==3.10
@@ -82,7 +81,7 @@ psutil
sentencepiece sentencepiece
soundfile==0.13.1 soundfile==0.13.1
pillow==12.0.0 pillow==12.1.0
opencv-python-headless==4.12.0.88 opencv-python-headless==4.12.0.88
rapidocr-onnxruntime==1.4.4 rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2 rank-bm25==0.2.2
@@ -116,12 +115,12 @@ psycopg2-binary==2.9.11
pgvector==0.4.2 pgvector==0.4.2
PyMySQL==1.1.2 PyMySQL==1.1.2
boto3==1.42.14 boto3==1.42.21
pymilvus==2.6.5 pymilvus==2.6.6
qdrant-client==1.16.2 qdrant-client==1.16.2
playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.2.0 elasticsearch==9.2.1
pinecone==6.0.2 pinecone==6.0.2
oracledb==3.4.1 oracledb==3.4.1
+48 -52
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.43", "version": "0.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.43", "version": "0.7.2",
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.5.0", "@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
@@ -62,7 +62,7 @@
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"jspdf": "^3.0.0", "jspdf": "^4.0.0",
"katex": "^0.16.22", "katex": "^0.16.22",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -99,6 +99,7 @@
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"y-prosemirror": "^1.3.7", "y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yaml": "^2.7.1", "yaml": "^2.7.1",
"yjs": "^13.6.27" "yjs": "^13.6.27"
}, },
@@ -128,7 +129,7 @@
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.14", "vite": "^5.4.21",
"vitest": "^1.6.1" "vitest": "^1.6.1"
}, },
"engines": { "engines": {
@@ -217,13 +218,10 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.27.0", "version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -4218,6 +4216,12 @@
"undici-types": "~5.26.4" "undici-types": "~5.26.4"
} }
}, },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -4900,18 +4904,6 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/aws-sign2": { "node_modules/aws-sign2": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@@ -5183,18 +5175,6 @@
"node": "10.* || >= 12.*" "node": "10.* || >= 12.*"
} }
}, },
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/buffer": { "node_modules/buffer": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
@@ -7649,6 +7629,23 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true "dev": true
}, },
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fast-png/node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/fastq": { "node_modules/fastq": {
"version": "1.17.1", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@@ -8649,6 +8646,12 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
@@ -9014,14 +9017,13 @@
} }
}, },
"node_modules/jspdf": { "node_modules/jspdf": {
"version": "3.0.1", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz",
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==", "integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.26.7", "@babel/runtime": "^7.28.4",
"atob": "^2.1.2", "fast-png": "^6.2.0",
"btoa": "^1.2.1",
"fflate": "^0.8.1" "fflate": "^0.8.1"
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -11382,11 +11384,6 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/remove-trailing-separator": { "node_modules/remove-trailing-separator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
@@ -13761,9 +13758,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.19", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
@@ -14865,11 +14862,10 @@
} }
}, },
"node_modules/y-protocols": { "node_modules/y-protocols": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lib0": "^0.2.85" "lib0": "^0.2.85"
}, },
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.43", "version": "0.7.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@@ -47,7 +47,7 @@
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.14", "vite": "^5.4.21",
"vitest": "^1.6.1" "vitest": "^1.6.1"
}, },
"type": "module", "type": "module",
@@ -106,7 +106,7 @@
"i18next-resources-to-backend": "^1.2.0", "i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1", "idb": "^7.1.1",
"js-sha256": "^0.10.1", "js-sha256": "^0.10.1",
"jspdf": "^3.0.0", "jspdf": "^4.0.0",
"katex": "^0.16.22", "katex": "^0.16.22",
"kokoro-js": "^1.1.1", "kokoro-js": "^1.1.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -143,6 +143,7 @@
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"y-prosemirror": "^1.3.7", "y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yaml": "^2.7.1", "yaml": "^2.7.1",
"yjs": "^13.6.27" "yjs": "^13.6.27"
}, },
+16 -17
View File
@@ -6,13 +6,13 @@ authors = [
] ]
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastapi==0.126.0", "fastapi==0.128.0",
"uvicorn[standard]==0.37.0", "uvicorn[standard]==0.40.0",
"pydantic==2.12.5", "pydantic==2.12.5",
"python-multipart==0.0.21", "python-multipart==0.0.21",
"itsdangerous==2.2.0", "itsdangerous==2.2.0",
"python-socketio==5.15.1", "python-socketio==5.16.0",
"python-jose==3.5.0", "python-jose==3.5.0",
"cryptography", "cryptography",
"bcrypt==5.0.0", "bcrypt==5.0.0",
@@ -38,7 +38,7 @@ dependencies = [
"pycrdt==0.12.44", "pycrdt==0.12.44",
"redis", "redis",
"APScheduler==3.11.1", "APScheduler==3.11.2",
"RestrictedPython==8.1", "RestrictedPython==8.1",
"loguru==0.7.3", "loguru==0.7.3",
@@ -50,18 +50,17 @@ dependencies = [
"openai", "openai",
"anthropic", "anthropic",
"google-genai==1.56.0", "google-genai==1.56.0",
"google-generativeai==0.8.6",
"langchain==1.2.0", "langchain==1.2.0",
"langchain-community==0.4.1", "langchain-community==0.4.1",
"langchain-classic==1.0.0", "langchain-classic==1.0.1",
"langchain-text-splitters==1.1.0", "langchain-text-splitters==1.1.0",
"fake-useragent==2.2.0", "fake-useragent==2.2.0",
"chromadb==1.3.7", "chromadb==1.4.0",
"opensearch-py==3.1.0", "opensearch-py==3.1.0",
"PyMySQL==1.1.2", "PyMySQL==1.1.2",
"boto3==1.42.14", "boto3==1.42.21",
"transformers==4.57.3", "transformers==4.57.3",
"sentence-transformers==5.2.0", "sentence-transformers==5.2.0",
@@ -73,10 +72,10 @@ dependencies = [
"chardet==5.2.0", "chardet==5.2.0",
"pypdf==6.5.0", "pypdf==6.5.0",
"fpdf2==2.8.5", "fpdf2==2.8.5",
"pymdown-extensions==10.19.1", "pymdown-extensions==10.20",
"docx2txt==0.9", "docx2txt==0.9",
"python-pptx==1.0.2", "python-pptx==1.0.2",
"unstructured==0.18.21", "unstructured==0.18.24",
"msoffcrypto-tool==5.4.2", "msoffcrypto-tool==5.4.2",
"nltk==3.9.2", "nltk==3.9.2",
"Markdown==3.10", "Markdown==3.10",
@@ -91,7 +90,7 @@ dependencies = [
"soundfile==0.13.1", "soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2", "azure-ai-documentintelligence==1.0.2",
"pillow==12.0.0", "pillow==12.1.0",
"opencv-python-headless==4.12.0.88", "opencv-python-headless==4.12.0.88",
"rapidocr-onnxruntime==1.4.4", "rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2", "rank-bm25==0.2.2",
@@ -139,20 +138,20 @@ postgres = [
all = [ all = [
"pymongo", "pymongo",
"psycopg2-binary==2.9.9", "psycopg2-binary==2.9.11",
"pgvector==0.4.0", "pgvector==0.4.2",
"moto[s3]>=5.0.26", "moto[s3]>=5.0.26",
"gcp-storage-emulator>=2024.8.3", "gcp-storage-emulator>=2024.8.3",
"docker~=7.1.0", "docker~=7.1.0",
"pytest~=8.3.2", "pytest~=8.3.2",
"pytest-docker~=3.2.5", "pytest-docker~=3.2.5",
"playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary "playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"elasticsearch==9.2.0", "elasticsearch==9.2.1",
"qdrant-client==1.16.2", "qdrant-client==1.16.2",
"pymilvus==2.6.4",
"weaviate-client==4.19.0", "weaviate-client==4.19.2",
"pymilvus==2.6.5", "pymilvus==2.6.6",
"pinecone==6.0.2", "pinecone==6.0.2",
"oracledb==3.4.1", "oracledb==3.4.1",
"colbert-ai==0.2.22", "colbert-ai==0.2.22",
+2 -2
View File
@@ -628,7 +628,7 @@ input[type='number'] {
/* Table styling for tiptap editors */ /* Table styling for tiptap editors */
.tiptap table { .tiptap table {
@apply w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full; @apply w-full text-sm text-start text-gray-500 dark:text-gray-400 max-w-full;
} }
.tiptap thead { .tiptap thead {
@@ -641,7 +641,7 @@ input[type='number'] {
} }
.tiptap th { .tiptap th {
@apply cursor-pointer text-left text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850; @apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
} }
.tiptap td { .tiptap td {
+13
View File
@@ -423,6 +423,19 @@ export const updateUserProfile = async (token: string, profile: object) => {
return res; return res;
}; };
export const updateUserTimezone = async (token: string, timezone: string) => {
await fetch(`${WEBUI_API_BASE_URL}/auths/update/timezone`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({ timezone })
}).catch((err) => {
console.error('Failed to update timezone:', err);
});
};
export const updateUserPassword = async (token: string, password: string, newPassword: string) => { export const updateUserPassword = async (token: string, password: string, newPassword: string) => {
let error = null; let error = null;
+152
View File
@@ -763,3 +763,155 @@ export const deleteMessage = async (token: string = '', channel_id: string, mess
return res; return res;
}; };
// Webhook API functions
type WebhookForm = {
name: string;
profile_image_url?: string;
};
export const getChannelWebhooks = async (token: string = '', channel_id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createChannelWebhook = async (
token: string = '',
channel_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string,
formData: WebhookForm
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/update`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChannelWebhook = async (
token: string = '',
channel_id: string,
webhook_id: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/webhooks/${webhook_id}/delete`,
{
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
+104
View File
@@ -142,6 +142,10 @@ export const getChatList = async (
throw error; throw error;
} }
if (!res) {
return [];
}
return res.map((chat) => ({ return res.map((chat) => ({
...chat, ...chat,
time_range: getTimeRange(chat.updated_at) time_range: getTimeRange(chat.updated_at)
@@ -1166,3 +1170,103 @@ export const archiveAllChats = async (token: string) => {
return res; return res;
}; };
export const exportChatStats = async (token: string, page: number = 1, params: object = {}) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (params) {
for (const [key, value] of Object.entries(params)) {
searchParams.append(key, `${value}`);
}
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const exportSingleChatStats = async (token: string, chatId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/stats/export/${chatId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadChatStats = async (
token: string = '',
updated_at: number | null = null
): Promise<[Response | null, AbortController]> => {
const controller = new AbortController();
let error = null;
let url = `${WEBUI_API_BASE_URL}/chats/stats/export?stream=true`;
if (updated_at) url += `&updated_at=${updated_at}`;
const res = await fetch(url, {
signal: controller.signal,
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
}).catch((err) => {
console.error(err);
error = err;
return null;
});
if (error) {
throw error;
}
return [res, controller];
};

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