Compare commits

...
130 Commits
Author SHA1 Message Date
Tim Baek 1ac3dd4a89 Merge pull request #21773 from open-webui/dev
0.8.5
2026-02-23 13:26:21 +04:00
Timothy Jaeryang Baek 55c489146c doc: changelog 2026-02-23 03:25:17 -06:00
Timothy Jaeryang Baek ffcf97e3e1 chore: bump 2026-02-23 03:24:33 -06:00
Timothy Jaeryang Baek 95bde946ba refac 2026-02-23 03:22:19 -06:00
Timothy Jaeryang Baek 895c805e62 feat: dictation toggle 2026-02-23 02:54:53 -06:00
Tim Baek 2ed3055c42 Merge pull request #21618 from open-webui/dev
0.8.4
2026-02-23 11:58:08 +04:00
Timothy Jaeryang Baek 1792f668f2 refac 2026-02-23 01:53:58 -06:00
Timothy Jaeryang Baek 1d3d3b2d94 refac 2026-02-23 01:46:08 -06:00
Timothy Jaeryang Baek 9044abf3bb chore: format 2026-02-23 01:40:53 -06:00
Timothy Jaeryang Baek 424dba443c refac 2026-02-23 01:37:06 -06:00
Classic298 aa649bec6b Chore: Changelog updates (#21754)
* changelog: add prompt enable/disable toggle entry

* changelog: fix PostgreSQL workspace cloning

* changelog: MCP SSL verification fix

* changelog: mcp ssl, general improvements, french translations

* changelog: add memory deletion and listing tools for agents

* changelog: add embeddings and proxy timeout fix for PR #21558

* changelog: pip requirements toggle

* changelog: fix commit references for memory and MCP entries

* changelog: scim, parameter handling, rfc7644

* changelog: update iframe sandbox entry for clarity

* changelog: shared chat optimization, translation updates

* changelog: file access control respect fix

* changelog: chat title query optimization, shared chat loading

* changelog: hybrid search fix, Finnish translations

* changelog: message list performance optimization

* changelog: archived chats, pinned chats, loading optimization

* changelog: knowledge-base-import, overwrite-flag, API-enhancement

* changelog: message upsert and tag filtering optimizations

* changelog: batch access grants, notes payload optimization

* changelog: skill import, json support

* changelog: add fix for imported items display issue

* changelog: add Anthropic Messages API proxy support

* changelog: WebSocket race condition fix for collaborative editing

* 📝

* changelog: drag-drop, firefox, overlay fix

* changelog: add multi-device OAuth sessions feature

* changelog: cyclic chat history deadlock fix

* changelog: group search visibility fix

* changelog: model default feature permissions

* changelog: admin groups sorting, notes optimization

* changelog: model selector, virtual scroll, UI fix

* changelog: user menu drag and click fixes

* changelog: rich-ui, auto-scroll, ux

* changelog: enhance Anthropic Messages API proxy with tool call support

* changelog: embedding concurrency, knowledge import

* changelog: add You.com web search provider (#21599)

* changelog: admin analytics toggle

* changelog: console log spam fix

* changelog: fetch URL citation sources

* changelog: message send optimization

* changelog: oauth, group sharing, settings

* changelog: admin nav drag fix (PR #21701)

* changelog: signup race condition, security fix

* changelog: playground, nav, drag

* changelog: group description, sort dropdown

* changelog: add model selector accessibility improvements

* changelog: consolidate accessibility entries for PRs #21705 and #21706

* changelog: tools list performance optimization

* changelog: accessibility, components, wcag

* changelog: button accessibility labels, wcag compliance

* changelog: Firefox avatar overflow fix

* changelog: disabled model cloning prevention fix

* changelog: dark mode select background fix

* changelog: update date to 2026-02-22, consolidate accessibility entries

* changelog: new chat message handling fix

* changelog: accessibility, aria-labels, settings components

* changelog: admin settings, tab navigation

* changelog: scroll, messages, deletion

* changelog: scroll, chat, message fixes

* changelog: model fallback routing and default model selection fixes

* changelog: remove duplicate scroll jumping fix from 0.8.3

* changelog: model visibility badges

* changelog: prompt import fix

* changelog: dropdown menu drag fix

* changelog: add workspace accessibility improvements to UI accessibility entry

* changelog: docker hub integration

* changelog: global model defaults, admin settings

* changelog: text file type detection fix

* changelog: update date to 2026-02-23

* changelog: ollama reasoning effort fix

* changelog: emoji deduplication in Fixed section

* changelog: sql, warning-fix

* changelog: add plaintext tool output display entry

* changelog: json, logging, format

* changelog: RAG template mutation fix for sequential tool calls

* changelog: analytics sorting, ldap authentication

* changelog: API tools, LDAP fields, SQLAlchemy fixes

* changelog: add folder menu fix, event call input masking, analytics sorting, LDAP fix, SQL warning fix

* changelog: add prompt suggestions and banners moved entries

* changelog: add prompt suggestions and banners moved to current version

* changelog: improve prompt suggestions and banners moved entries

* changelog: add hybrid search deduplication fix
2026-02-23 01:29:14 -06:00
G30 a8a3098782 fix(ui): apply select-none to remaining dropdown menus globally to prevent text highlighting (#21763) 2026-02-23 01:22:02 -06:00
G30 238e9da209 fix(ui): prevent highlighting and dragging of text in admin settings menu link (#21761) 2026-02-23 01:21:55 -06:00
G30 49a1b37e5d fix(ui): prevent highlighting of text in chat integrations menu (#21758) 2026-02-23 01:21:46 -06:00
Timothy Jaeryang Baek c035ff7d14 refac 2026-02-22 19:18:25 -06:00
Timothy Jaeryang Baek 2558fe1a3b refac 2026-02-22 19:16:35 -06:00
G30 e7848ec712 feat(ui): update admin users and evaluations sidebars to leverage native anchor tags for robust new-tab link capabilities (#21723) 2026-02-22 19:08:18 -06:00
Timothy Jaeryang Baek 39e5422d93 refac 2026-02-22 18:58:47 -06:00
Timothy Jaeryang Baek f6bd54fb1f refac 2026-02-22 18:55:24 -06:00
Timothy Jaeryang Baek d9fd2a3f30 refac 2026-02-22 18:42:25 -06:00
Timothy Jaeryang Baek 824eeba56c refac 2026-02-22 18:23:53 -06:00
Timothy Jaeryang Baek e61406c825 refac 2026-02-22 18:19:49 -06:00
Timothy Jaeryang Baek 4853ededca refac 2026-02-22 18:05:25 -06:00
G30 8c127a4814 fix(ui): make folder menu text non-highlightable (#21753) 2026-02-22 18:04:22 -06:00
Timothy Jaeryang Baek 6eba27ee9c refac 2026-02-22 18:00:16 -06:00
Timothy Jaeryang Baek 8f0658e64f fix: payload tools handling 2026-02-22 17:58:59 -06:00
Johann Frei 4b3543d3c0 fix(ui): allow empty LDAP Application DN value and password in General setting… (#21742)
* Allow empty LDAP Application DN value and password in General settings form

* fix(ui): use LDAP app_dn, app_dn_password with empty string instead of enforcing non-empty values
2026-02-22 17:58:12 -06:00
Classic298 d1b39da911 changelog: yeah (#21575)
* changelog: add prompt enable/disable toggle entry

* changelog: fix PostgreSQL workspace cloning

* changelog: MCP SSL verification fix

* changelog: mcp ssl, general improvements, french translations

* changelog: add memory deletion and listing tools for agents

* changelog: add embeddings and proxy timeout fix for PR #21558

* changelog: pip requirements toggle

* changelog: fix commit references for memory and MCP entries

* changelog: scim, parameter handling, rfc7644

* changelog: update iframe sandbox entry for clarity

* changelog: shared chat optimization, translation updates

* changelog: file access control respect fix

* changelog: chat title query optimization, shared chat loading

* changelog: hybrid search fix, Finnish translations

* changelog: message list performance optimization

* changelog: archived chats, pinned chats, loading optimization

* changelog: knowledge-base-import, overwrite-flag, API-enhancement

* changelog: message upsert and tag filtering optimizations

* changelog: batch access grants, notes payload optimization

* changelog: skill import, json support

* changelog: add fix for imported items display issue

* changelog: add Anthropic Messages API proxy support

* changelog: WebSocket race condition fix for collaborative editing

* 📝

* changelog: drag-drop, firefox, overlay fix

* changelog: add multi-device OAuth sessions feature

* changelog: cyclic chat history deadlock fix

* changelog: group search visibility fix

* changelog: model default feature permissions

* changelog: admin groups sorting, notes optimization

* changelog: model selector, virtual scroll, UI fix

* changelog: user menu drag and click fixes

* changelog: rich-ui, auto-scroll, ux

* changelog: enhance Anthropic Messages API proxy with tool call support

* changelog: embedding concurrency, knowledge import

* changelog: add You.com web search provider (#21599)

* changelog: admin analytics toggle

* changelog: console log spam fix

* changelog: fetch URL citation sources

* changelog: message send optimization

* changelog: oauth, group sharing, settings

* changelog: admin nav drag fix (PR #21701)

* changelog: signup race condition, security fix

* changelog: playground, nav, drag

* changelog: group description, sort dropdown

* changelog: add model selector accessibility improvements

* changelog: consolidate accessibility entries for PRs #21705 and #21706

* changelog: tools list performance optimization

* changelog: accessibility, components, wcag

* changelog: button accessibility labels, wcag compliance

* changelog: Firefox avatar overflow fix

* changelog: disabled model cloning prevention fix

* changelog: dark mode select background fix

* changelog: update date to 2026-02-22, consolidate accessibility entries

* changelog: new chat message handling fix

* changelog: accessibility, aria-labels, settings components

* changelog: admin settings, tab navigation

* changelog: scroll, messages, deletion

* changelog: scroll, chat, message fixes

* changelog: model fallback routing and default model selection fixes

* changelog: remove duplicate scroll jumping fix from 0.8.3

* changelog: model visibility badges

* changelog: prompt import fix

* changelog: dropdown menu drag fix

* changelog: add workspace accessibility improvements to UI accessibility entry

* changelog: docker hub integration

* changelog: global model defaults, admin settings

* changelog: text file type detection fix

* changelog: update date to 2026-02-23

* changelog: ollama reasoning effort fix

* changelog: emoji deduplication in Fixed section

* changelog: sql, warning-fix

* changelog: add plaintext tool output display entry
2026-02-22 17:56:25 -06:00
Timothy Jaeryang Baek 053a33631f refac 2026-02-22 17:55:08 -06:00
Timothy Jaeryang Baek becac2b2b7 refac 2026-02-22 17:51:08 -06:00
Timothy Jaeryang Baek 342aa84bbe refac 2026-02-22 17:51:03 -06:00
Andrei Efanov 9e81e1dda1 feat: add LOG_FORMAT=json for structured JSON logging (#21747)
* feat: add LOG_FORMAT env var with JSON formatter for early logging

Introduce LOG_FORMAT environment variable (set to "json" to enable).
When active, logging.basicConfig() uses a JSONFormatter that outputs
single-line JSON objects with fields: ts, level, msg, caller, error,
stacktrace. This covers all log messages emitted during module imports
before Loguru's start_logger() takes over.

* feat: add JSON sink for Loguru when LOG_FORMAT=json

Add _json_sink() as a Loguru sink function that writes single-line JSON
to stdout. In start_logger(), conditionally use the JSON sink instead of
the plain-text stdout_format when LOG_FORMAT is set to "json".

* feat: suppress ASCII banner and fix alembic logging in JSON mode

- Wrap the ASCII art banner print in main.py with a LOG_FORMAT != "json"
  guard so JSON output stays machine-parseable.
- Skip alembic's fileConfig() call in migrations/env.py when
  LOG_FORMAT=json to prevent it from replacing the JSON log handlers
  installed during early startup.
2026-02-22 17:40:17 -06:00
Timothy Jaeryang Baek 3ad2ea6f28 refac 2026-02-22 17:38:22 -06:00
Timothy Jaeryang Baek bab64c9d52 refac 2026-02-22 17:38:14 -06:00
Timothy Jaeryang Baek 0185f3340d refac 2026-02-22 17:28:01 -06:00
Timothy Jaeryang Baek 1cd26372fb refac 2026-02-22 17:26:59 -06:00
Timothy Jaeryang Baek 0ca2e46ade refac 2026-02-22 17:17:44 -06:00
Timothy Jaeryang Baek 30a13b9b2f refac: ollama str think support 2026-02-22 17:11:50 -06:00
Timothy Jaeryang Baek f651809001 refac 2026-02-22 17:05:39 -06:00
Timothy Jaeryang Baek c341f97cfe feat: default model metadata & params 2026-02-22 16:54:34 -06:00
Timothy Jaeryang Baek 32aabe6bae refac 2026-02-22 16:18:32 -06:00
Timothy Jaeryang Baek 3c54863414 refac 2026-02-22 15:41:10 -06:00
Timothy Jaeryang Baek ad9fbfc1af refac 2026-02-22 15:35:16 -06:00
Timothy Jaeryang Baek 29217cb430 refac 2026-02-22 15:34:28 -06:00
G30 c0096b2a53 fix: explicitly disable dragging and text selection inside dropdown menus (#21713)
* fix(ui): remove select-none from move and pdf menu items to allow highlighting

* fix(ui): explicitly disable dragging and text selection inside dropdown menus globally
2026-02-22 15:30:43 -06:00
Classic298 5b9efeef4d fix(a11y): add aria-labels and structural elements to workspace components (#21719) 2026-02-22 15:24:33 -06:00
Classic298 e0087acfb4 fix: model fallback routing for all model types and default model selection (#21736)
fix: model fallback routing for all model types and default model selection

Backend: When ENABLE_CUSTOM_MODEL_FALLBACK is active and a custom model's
base model is unavailable, the fallback now swaps the model and form data
to the configured default model directly. This ensures routing uses the
fallback model's type (pipe, Ollama, or OpenAI) instead of the original
model's type, which previously caused "Model not found" errors when the
fallback was a different backend type.

Frontend: Fixed default model selection in new chat initialization where
the admin-configured default models were always overwritten by the first
available model. The first-available fallback now only triggers when the
configured defaults don't resolve to valid available models.
2026-02-22 15:24:14 -06:00
Timothy Jaeryang BaekandClassic298 1f474187a7 refac
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2026-02-22 15:22:53 -06:00
Classic298 2beeeb90c2 fix(a11y): add aria-labels to chat message components (#21708)
Add aria-labels, aria-expanded, and semantic improvements to:
- RateComment: close button, rating scale, feedback textarea
- Citations: toggle button with count, source item buttons
- Source/SourceToken: contextual aria-labels for citation buttons
- StatusHistory: toggle button with expanded state
- WebSearchResults: descriptive favicon alt text
- FollowUps: convert div to button element
- RegenerateMenu: submit suggestion button
- FloatingButtons: action buttons, input field, submit button
- CitationModal: close button

WCAG: 4.1.2 (Name, Role, Value), 2.1.1 (Keyboard), 1.1.1 (Non-text Content)
2026-02-22 14:36:42 -06:00
G30 d016cc5771 feat: convert admin settings menu tabs to native anchor tags for new-tab support (#21721)
* docs: generate PR template for navigation drag glitch fixes

* feat(ui): convert admin settings menu tabs to native anchor tags for new-tab support
2026-02-22 14:34:06 -06:00
Classic298 16e567df57 fix(a11y): enhance accessibility for chat settings components (#21715)
This commit adds aria-labels to the text inputs and textareas that previously lacked them, applies role=switch to inputs, and adds accessible titles to floating quick actions.
2026-02-22 14:33:07 -06:00
Classic298 1542dad51a fix(a11y): enhance accessibility for admin user components (#21717)
This commit adds aria-labels to the search inputs, select fields, action buttons, and close buttons on modals across the admin users layout and the site changelog modal for improved screen reader support.
2026-02-22 14:32:49 -06:00
Classic298 2ef55972ff fix: reset taskIds and messageQueue on new chat (#21731)
fix: reset taskIds and messageQueue on new chat

Fixes a bug where clicking "New Chat" after sending a message would
silently drop subsequent messages. The initNewChat function reset most
chat state but did not clear taskIds or messageQueue, causing
submitPrompt to queue messages indefinitely instead of sending them.
2026-02-22 14:30:44 -06:00
G30 75c5d9b179 fix(ui): hide clone button in model menu for models disabled by admins (#21724) 2026-02-22 14:26:33 -06:00
G30 713fe1afa7 fix(ui): prevent avatar alt-text overlap on failed image loads (#21730) 2026-02-22 14:24:16 -06:00
G30 f95cff0895 fix(ui): replace static dropdown backgrounds with transparent mapping (#21728) 2026-02-22 14:23:34 -06:00
Classic298 a0dbd41551 fix(a11y): improve accessibility of top-level auth and onboarding components (#21710)
Adds critical accessibility fixes across various app components:
- auth/+page: provide alt text for logo, turn on screenReader support for password input, add aria-required, hide decorative SVGs from AT
- AppSidebar: wrap navigation icons in a <nav> structure, provide ARIA labels for Home and Chat icons
- s/[id]/+page: convert structural divs into semantically accurate h1 heading and time element, wrap message display in main region
- OnBoarding: replace flawed aria-labelledby with direct aria-label on start button
- NotificationToast: provide role='status' and aria-live='polite' for proper screen reader broadcasting
- ChangelogModal: add required heading semantics for structure
- AddFilesPlaceholder: provide heading element role for standalone text content
- ImportModal: provide aria-label for close button

Addresses WCAG 4.1.3, 1.1.1, 3.3.2, and 1.3.1.
2026-02-22 14:18:53 -06:00
Classic298 bf0fb1c449 fix(a11y): add aria-labels to chat core components (#21709)
Add aria-labels to close, back, and action buttons across:
- Controls/Controls.svelte: close chat controls button
- ChatControls/Embeds.svelte: close embed button
- Overview/Node.svelte: favorite toggle button
- Overview/View.svelte: back and close overview buttons
- ShortcutsModal.svelte: close button
- ShareChatModal.svelte: close button
- ToolServersModal.svelte: close button
- Placeholder/FolderTitle.svelte: folder icon picker, folder options menu

WCAG: 4.1.2 (Name, Role, Value)
2026-02-22 14:18:19 -06:00
Shirasawa 7043751ca4 I18n: improve Chinese translation (#21741)
* i18n: improve zh-CN translation

* i18n: improve zh-TW translation
2026-02-22 14:15:08 -06:00
Classic298 2d5ebf962a Update README.md (#21735) 2026-02-22 14:14:56 -06:00
Timothy Jaeryang Baek b48594a166 refac 2026-02-21 16:27:25 -06:00
Classic298 74e771fec6 fix(a11y): add aria-hidden to all decorative SVG icon components (#21705)
Add aria-hidden='true' to 112 SVG icon components in src/lib/components/icons/ that were missing this attribute. Decorative icons that convey no semantic meaning should be hidden from the accessibility tree to prevent screen readers from attempting to read meaningless SVG markup (WCAG 1.1.1 Non-text Content, WCAG 4.1.2 Name, Role, Value).

The remaining 60 icon files already had aria-hidden='true' set. All 172 icon components now consistently declare aria-hidden='true' on their root svg element.
2026-02-21 16:14:27 -06:00
Classic298 08f1c823ad fix(a11y): improve model selector accessibility with proper listbox/option pattern (#21706)
- Replace incorrect aria-roledescription='model-item' with role='option' and aria-selected on ModelItem.svelte. The previous attribute was not a valid ARIA role description and provided no useful information to screen readers.

- Add contextual aria-label to each model item button (e.g. 'Select GPT-4 model') instead of just the raw model name, making the action clear to screen reader users.

- Add role='listbox' and aria-label='Available models' to the scrollable model list container in Selector.svelte so screen readers announce the container's purpose and navigate items correctly.

- Make the model selector trigger button's aria-label dynamic: it now announces 'Selected model: GPT-4' when a model is selected, falling back to 'Select a model' when nothing is selected.

- Add aria-label to the eject (unload) button in ModelItem.svelte so screen readers announce its purpose.

- Add aria-label to the cancel download button in Selector.svelte with the specific model name being canceled.

- Improve model profile image alt text from generic 'Model' to contextual '{{modelName}} profile image'.
2026-02-21 16:14:09 -06:00
Timothy Jaeryang Baek b559606387 refac 2026-02-21 16:02:45 -06:00
Timothy Jaeryang Baek 914c7ba876 refac: groups ui 2026-02-21 16:01:48 -06:00
G30 96ca47ac9f fix(ui): prevent text-selection ghost dragging on playground navigation tabs (#21704) 2026-02-21 15:45:58 -06:00
theeggorchicken a0c82c8e4c fix: race condition in signup allows multiple admin accounts (#21631)
The signup_handler function checks has_users() before inserting a new user
and assigns the admin role based on that check. With multiple uvicorn workers,
concurrent signup requests during first-user registration can all observe an
empty user table before any insert completes, causing multiple accounts to
receive the admin role.

Fix: insert with the default role first, then check user count after the
insert. Only promote to admin if this is the only user in the database.
This eliminates the TOCTOU window between the check and the insert.
2026-02-21 15:37:08 -06:00
Timothy Jaeryang Baek 631e30e22d refac 2026-02-21 15:35:34 -06:00
Timothy Jaeryang Baek c114fd6876 refac 2026-02-21 15:33:21 -06:00
G30 c2172e43eb fix(ui): prevent drag-and-drop ghost cursors and text highlighting on admin and workspace navigation tabs (#21701) 2026-02-21 15:23:58 -06:00
Timothy Jaeryang Baek 1ad3656872 refac 2026-02-21 15:22:50 -06:00
Timothy Jaeryang Baek ff7f38d343 refac 2026-02-21 15:20:31 -06:00
Timothy Jaeryang Baek bc482b9cce refac 2026-02-21 15:17:36 -06:00
Timothy Jaeryang Baek 4c94f5d434 refac 2026-02-21 15:16:22 -06:00
Timothy Jaeryang Baek 4b9f821b58 enh: OAUTH_GROUP_DEFAULT_SHARE 2026-02-21 15:08:06 -06:00
Timothy Jaeryang Baek 35598b8017 enh: ENABLE_ADMIN_ANALYTICS 2026-02-21 14:56:19 -06:00
Classic298 45e23c3ad0 perf: eliminate 2 redundant full chat deserialization on every message send (#21596)
* perf: eliminate 2 redundant full chat deserialization on every message send (#162)

Problem:
Every message send triggered get_chat_by_id_and_user_id which loads the
entire Chat row — including the potentially massive JSON blob containing
the full conversation history — even when the caller only needed a
simple yes/no ownership check or a single column value.

Two call sites in the message-send hot path were doing this:

1. main.py ownership verification: loaded the entire chat object including
   all message history JSON, then checked `if chat is None`. The JSON blob
   was immediately discarded — only the existence of the row mattered.

2. middleware.py folder check: loaded the entire chat object including all
   message history JSON, then read only `chat.folder_id` — a plain column
   on the chat table that requires zero JSON parsing.

Fix:
- Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which
  returns a boolean without loading any row data. The database can satisfy
  this from the primary key index alone.

- Added `get_chat_folder_id()`: queries only the `folder_id` column via
  `db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that
  single column instead of the entire row.

Both new methods preserve the same error handling semantics (return
False/None on exception) and user_id filtering (ownership check) as
the original get_chat_by_id_and_user_id.

Impact:
- Best case (typical): eliminates deserializing 2 full chat JSON blobs per
  message send. For long conversations (hundreds of messages with tool
  calls, images, file attachments), this blob can be multiple megabytes.
- Worst case: no regression — the new queries are strictly cheaper than
  the old ones (less data transferred, less Python object construction,
  no Pydantic model_validate overhead).
- The 3 remaining full chat loads in process_chat_payload (load_messages_from_db,
  add_file_context, chat_image_generation_handler) are left untouched as
  they genuinely need the full history and require separate analysis.

* Address maintainer feedback: rename method and inline call (#166)

- Rename chat_exists_by_id_and_user_id -> is_chat_owner
- Remove intermediate chat_owned variable; call is_chat_owner directly in if condition
2026-02-21 14:53:31 -06:00
lazariv 5759917f54 feat: Adding You.com as a web search provider (#21599)
* Add ydc.py provider implementation

* Add PersistentConfig entry for you.com

* Add Youcom search function import

* Update you.com configuration

* Add you.com as a web search engine option in frontend

* Add YOUCOM_API_KEY to main.py
2026-02-21 14:51:56 -06:00
Classic298 d247adb60c feat: add citation sources for fetch_url tool results (#21669)
feat: add citation sources for fetch_url tool results

URL fetches now produce clickable citation sources in the UI, matching
the existing behavior of search_web and knowledge file tools. When a
model calls fetch_url during native tool calling, the fetched URL
appears as a citable source with a content preview, giving users full
transparency into what pages the model referenced.
2026-02-21 14:49:19 -06:00
G30 8c713a171d fix(backend): catch 404 http exceptions before generalized exception block in files router (#21687) 2026-02-21 14:48:51 -06:00
Timothy Jaeryang Baek 7e42d727e8 refac 2026-02-21 14:39:28 -06:00
Classic298 9f7dd31e12 feat: scroll to rich ui once rendered (#21698)
* Update Chat.svelte

* Update Chat.svelte
2026-02-21 14:35:32 -06:00
Timothy Jaeryang Baek 5d4547f934 enh: RAG_EMBEDDING_CONCURRENT_REQUESTS 2026-02-21 14:33:48 -06:00
G30 5522b91c32 fix(ui): align profile dropdown items and prevent phantom synthetic drag clicks (#21699) 2026-02-21 14:31:35 -06:00
Timothy Jaeryang Baek 3242dad8ae refac 2026-02-21 14:29:40 -06:00
G30 6d8a6e6d8b fix(model-selector): resolve virtual scroll bug when typing quickly (#21659) 2026-02-21 14:22:50 -06:00
Timothy Jaeryang Baek 8265422ba0 refac 2026-02-21 14:22:20 -06:00
Timothy Jaeryang Baek 10c13b686c refac 2026-02-21 14:19:28 -06:00
Classic298 b1dc58ddb7 feat: add sortable columns to groups admin panel (#21692)
* feat: add sortable columns to groups admin panel

Make the Group and Users column headers in the admin groups list clickable to sort groups alphabetically by name or numerically by member count. Clicking a column toggles ascending/descending order, indicated by a chevron icon. When no sort is active, the default API order (by updated_at) is preserved.

* Update Groups.svelte

* Update Groups.svelte
2026-02-21 14:18:37 -06:00
Timothy Jaeryang Baek a9312d2537 refac 2026-02-21 14:15:32 -06:00
Classic298 4228bf71c4 fix: gate model default features on global config and user permissions (#21690)
fix: gate model default features on global config and user permissions

If you disabled code interpreter globally and in user permissions but
enabled it as a default feature on a model, the code interpreter pill
still appeared in the chat input. Same issue for web search and image
generation.

The setDefaults function in Chat.svelte activated model default features
based solely on the model's capability flag, ignoring whether the feature
was globally enabled or allowed by user permissions. Added the same
global config and user permission checks already used by the integrations
menu visibility and the features object sent to the backend.
2026-02-21 13:54:47 -06:00
Classic298 ac620118c1 fix group search (#21691) 2026-02-21 13:53:47 -06:00
G30 d650c987ec fix: resolve backend execution deadlock when syncing stats with cyclic chat history (#21681) 2026-02-20 23:04:36 -05:00
Timothy Jaeryang Baek 092a358b3c refac 2026-02-20 16:55:06 -06:00
Timothy Jaeryang Baek ae05586fda refac: oauth session management 2026-02-20 16:49:43 -06:00
G30 f5e5632afc fix(chat): prevent stuck drop overlay when dragging outside window in firefox (#21664) 2026-02-20 14:01:24 -06:00
Timothy Jaeryang BaekandLeandro Ygor Loli 2a804541e0 refac
Co-Authored-By: Leandro Ygor Loli <77518998+leandroyloli@users.noreply.github.com>
2026-02-19 16:57:32 -06:00
Timothy Jaeryang Baek 8c485b260f refac 2026-02-19 16:53:21 -06:00
Classic298andTim Baek d664922feb Avoid loading full chat JSON blob for pinned/archived/shared list endpoints (#21591)
Co-authored-by: Tim Baek <tim@openwebui.com>
2026-02-19 16:48:23 -06:00
fchevallieratecna 9950cc8c28 fix(i18n): correct French typo "Analtique" → "Analytique" (#21512) 2026-02-19 16:42:57 -06:00
Classic298 3db6d49e57 Query title column directly in get_chat_title_by_id instead of loading full chat (#157) (#21590)
Previously loaded the entire ChatModel (including the full conversation JSON
blob) just to extract the title string. Now queries only the Chat.title
column directly, which is already a top-level DB column.
2026-02-19 16:41:46 -06:00
VasilyLebedev123andVasily Lebedev 6d67ac371d fix: correct unpacking order of distances, documents, and metadatas in hybrid search query (#21562)
Co-authored-by: Vasily Lebedev <Vasily.Lebedev@sapowernetworks.com.au>
2026-02-19 16:38:40 -06:00
Classic298andJordan 326599b8db Fix O(n²) performance in get_message_list by replacing insert(0) with append+reverse (#21588)
Co-authored-by: Jordan <CenteredAxis@users.noreply.github.com>
2026-02-19 16:38:01 -06:00
Classic298 c5c31ab769 fix: respect BYPASS_ADMIN_ACCESS_CONTROL in file list/search endpoints (#21595) 2026-02-19 16:36:48 -06:00
Kylapaallikko 43eb2351d2 Update fi-FI translation.json (#21538)
Added missing translations.
2026-02-19 16:36:32 -06:00
Timothy Jaeryang Baek 0a700aafe4 refac 2026-02-19 16:32:41 -06:00
Timothy Jaeryang Baek 91a0301c9e refac 2026-02-19 16:29:19 -06:00
Timothy Jaeryang Baek 6ac593209c refac 2026-02-19 16:09:54 -06:00
Shirasawa 12bea8cd88 i18n: improve Chinese translation (#21530) 2026-02-19 16:06:23 -06:00
joaoback 1dfe546b6b i18n: pt-BR - add translations for newly added UI items + consistency pass (#21527)
Translate all remaining untranslated strings to Brazilian Portuguese (pt-BR)

Translated ~100 previously untranslated entries (empty "" values) across the Open WebUI i18n JSON file. Changes include:

Translated UI labels, form fields, tooltips, and error messages
Kept brand/product names unchanged where appropriate (e.g., Bing, Brave, Gemini, OpenAI, YouTube)
Translated technical terms with context-appropriate Brazilian Portuguese equivalents (e.g., "Timeout" → "Tempo limite", "Config" → "Configuração", "Endpoint URL" → "URL do Endpoint")
Added translations for search engine integrations (Kagi, Perplexity, SerpApi, Tavily, Yacy, Yandex, etc.)
Translated plural forms for source retrieval messages (sources_one, sources_many, sources_other)
No untranslated entries remain in the file.
2026-02-19 16:06:08 -06:00
Classic298and:o ff837031e4 Update iframe sandbox attributes based on settings (#21529)
Co-authored-by: :o <52920416+gg0h@users.noreply.github.com>
2026-02-19 16:05:47 -06:00
Timothy Jaeryang Baek 139f02a9d9 refac 2026-02-19 16:04:41 -06:00
Timothy Jaeryang Baek 4bef69cc63 refac 2026-02-19 16:03:03 -06:00
Timothy Jaeryang Baek 723185c22f refac 2026-02-19 15:59:58 -06:00
Classic298 35763a352c Optimize shared chats list to use column projection (#163) (#21614)
The GET /chats/shared endpoint was loading full Chat rows including
the entire conversation history JSON blob, only to discard it and
return SharedChatResponse (id, title, share_id, timestamps). Now
uses with_entities() to select only the 5 needed columns, avoiding
deserialization of potentially large chat JSON for every shared chat.
2026-02-19 15:50:03 -06:00
Patrick MonteithandClaude Sonnet 4.6 27c76c677a fix: clamp SCIM pagination args instead of rejecting them (#21577)
RFC 7644 §3.4.2.4 specifies that out-of-range pagination values MUST be
clamped, not rejected. The previous implementation used FastAPI Query
constraints (ge=1, le=100) which caused a 422 response for values like
startIndex=0 or count=9999 — violating the spec.

For both /Users and /Groups:
- startIndex < 1 is now treated as 1 (spec: "SHALL be interpreted as 1")
- count < 0 is now treated as 0 (spec: "SHALL be interpreted as 0")
- count > 100 is clamped to the server maximum of 100

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 15:08:42 -06:00
2f1344d619 Update translation.json (#21602)
Typography issue correction

Co-authored-by: Tim Baek <tim@openwebui.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
2026-02-19 14:15:31 -06:00
Timothy Jaeryang Baek 8bfab327ec refac 2026-02-19 14:14:36 -06:00
Minwoo 'Charlie' Choi 56246324b2 fix: apply AIOHTTP_CLIENT_TIMEOUT to embeddings endpoint (#21558) 2026-02-19 14:13:50 -06:00
Classic298 af5661c2c8 Merge pull request #21485 from Classic298/claude/fix-mcp-ssl-check-0janH
fix: mcp ssl check
2026-02-19 14:08:15 -06:00
Timothy Jaeryang Baek f872a178bc refac 2026-02-19 14:06:24 -06:00
Timothy Jaeryang Baek 3dd44c4f19 refac 2026-02-18 15:23:50 -06:00
Timothy Jaeryang Baek 094ed0b48c fix: prompts delete 2026-02-18 14:58:39 -06:00
Timothy Jaeryang Baek 9b55343509 refac 2026-02-18 14:43:07 -06:00
Timothy Jaeryang Baek 8a7f698e9d refac 2026-02-18 14:42:00 -06:00
Timothy Jaeryang Baek 990c638f6c refac 2026-02-18 14:40:40 -06:00
Timothy Jaeryang Baek a0195cd5ae refac 2026-02-18 14:33:18 -06:00
Timothy Jaeryang Baek e9d852545c refac 2026-02-18 14:24:42 -06:00
Timothy Jaeryang Baek 49c36238d0 refac 2026-02-18 13:54:59 -06:00
Timothy Jaeryang Baek 74988189b8 refac 2026-02-18 13:06:50 -06:00
352 changed files with 8347 additions and 2447 deletions
+106
View File
@@ -804,3 +804,109 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
copy-to-dockerhub:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
continue-on-error: true
strategy:
fail-fast: false
matrix:
include:
- variant: main
suffix: ""
- variant: cuda
suffix: "-cuda"
- variant: cuda126
suffix: "-cuda126"
- variant: ollama
suffix: "-ollama"
- variant: slim
suffix: "-slim"
steps:
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine source and destination tags
id: tags
run: |
DOCKERHUB_IMAGE="openwebui/open-webui"
SUFFIX="${{ matrix.suffix }}"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# For version tags: copy version tag and major.minor tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
MAJOR_MINOR="${VERSION%.*}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
# For main branch
if [ -z "$SUFFIX" ]; then
echo "tags=latest" >> $GITHUB_OUTPUT
else
# e.g. latest-cuda -> also tag as just "cuda"
VARIANT_NAME="${SUFFIX#-}"
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
fi
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
- name: Copy images from GHCR to Docker Hub
run: |
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
SUFFIX="${{ matrix.suffix }}"
# Determine the source tag on GHCR
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
SOURCE_TAG="${VERSION}${SUFFIX}"
else
if [ -z "$SUFFIX" ]; then
SOURCE_TAG="latest"
else
SOURCE_TAG="latest${SUFFIX}"
fi
fi
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
echo "Copying from ${SOURCE} to Docker Hub..."
# Copy each destination tag
while IFS= read -r TAG; do
[ -z "$TAG" ] && continue
DEST="${DOCKERHUB_IMAGE}:${TAG}"
echo " -> ${DEST}"
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
done <<< "${{ steps.tags.outputs.tags }}"
+103
View File
@@ -5,6 +5,109 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.5] - 2026-02-23
### Added
- ⌨️ **Voice dictation shortcut.** Users can now toggle voice dictation using Cmd+Shift+L (or Ctrl+Shift+L on Windows/Linux), making it faster to start and stop dictation without clicking the microphone button.
### Fixed
- 🚫 **Model access KeyError fix.** The /api/models endpoint no longer crashes with a 500 error when models have incomplete info metadata missing the user_id field (e.g. models using global default metadata).
- 🔄 **Frontend initialization resilience.** The app layout now gracefully handles individual API failures during initialization (getModels, getBanners, getTools, getUserSettings, setToolServers) instead of blocking the entire page load when any single call fails.
- 🛡️ **Backend config null safety.** Language detection during app initialization no longer crashes when the backend config fetch fails, preventing a secondary cause of infinite loading.
## [0.8.4] - 2026-02-23
### Added
- 🛜 **Provider URL suggestions.** The connection form now displays a dropdown with suggested URLs for popular AI providers, making it easier to configure connections. [Commit](https://github.com/open-webui/open-webui/commit/49c36238d01aaff5466344ecd316a6dd3edd74a3)
- ☁️ **Anthropic model fetching.** The system now properly fetches available models from the Anthropic API, ensuring all Anthropic models are accessible. [Commit](https://github.com/open-webui/open-webui/commit/e9d852545cc17f0eeb8bdcfa77575a80fed8706d)
- 💡 **No models prompt.** When no models are available, a helpful prompt now guides users to manage their provider connections. [Commit](https://github.com/open-webui/open-webui/commit/a0195cd5ae9b9915295839cd0a5fbac5a1b0bfa2)
- ⚙️ **Connection enable/disable toggles.** Individual provider connections can now be enabled or disabled from both admin and user settings. [Commit](https://github.com/open-webui/open-webui/commit/990c638f6cf91507b61898f454c26f9516114c36)
- ⏸️ **Prompt enable/disable toggle.** Users can now enable or disable prompts directly from the prompts list using a toggle switch, without needing to delete and recreate them. Inactive prompts display an "Inactive" badge and are still visible in the list. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 🗑️ **Memory deletion.** Agents can now delete specific memories that are no longer relevant, duplicated, or incorrect, giving better control over stored memory content. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 📋 **Memory listing.** Agents can now list all stored memories, enabling them to identify which memories to manage or delete based on the complete memory inventory. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
- 📦 **Auto pip install toggle.** Administrators can now disable automatic pip package installation from function frontmatter requirements using the ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS environment variable, providing more control over function dependency management. [Commit](https://github.com/open-webui/open-webui/commit/8bfab327ec5f635f9fe93c26efd198712ff7116d)
- 🔗 **Anthropic Messages API proxy.** A new API endpoint now supports the Anthropic Messages API format, allowing tools like Claude Code to authenticate through Open WebUI and access configured models. Tool calls are now properly supported in streaming responses with correct multi-block indexing, and error status from tools is propagated correctly. The endpoint converts requests to OpenAI format internally, routes them through the existing chat pipeline, and returns responses in Anthropic format. [#21390](https://github.com/open-webui/open-webui/discussions/21390), [Commit](https://github.com/open-webui/open-webui/commit/91a0301c9e22e93295a7c471d83592a802560795), [Commit](https://github.com/open-webui/open-webui/commit/a9312d25373d3aa161788598f87180b8db11c5b6)
- 👥 **Multi-device OAuth sessions.** Users can now stay logged in on multiple devices simultaneously with OAuth, as re-logging in no longer terminates existing sessions. The oldest sessions are automatically pruned when the session limit is exceeded. [#21647](https://github.com/open-webui/open-webui/issues/21647), [Commit](https://github.com/open-webui/open-webui/commit/ae05586fdabf318d551b53ede41575355d3b9e2b)
- 🔐 **OAuth group default share setting.** Administrators can now configure the default sharing setting for OAuth-created groups using the OAUTH_GROUP_DEFAULT_SHARE environment variable, allowing control over whether new groups default to private or shared with members. [#21679](https://github.com/open-webui/open-webui/pull/21679), [Commit](https://github.com/open-webui/open-webui/commit/4b9f821b58007d4efa4aa16a4995b23126e08a88)
- 🔧 **Knowledge base import behavior.** The web content import endpoint now supports a configurable overwrite flag, allowing users to add multiple URLs to the same knowledge base instead of replacing existing content. [#21613](https://github.com/open-webui/open-webui/pull/21613), [#21336](https://github.com/open-webui/open-webui/issues/21336), [Commit](https://github.com/open-webui/open-webui/commit/4bef69cc6344ff809090441aa6bced573a2aa838)
- 🧩 **Skill JSON import support.** Skills can now be imported from both JSON and Markdown files. [#21511](https://github.com/open-webui/open-webui/issues/21511)
- 🔍 **You.com web search provider.** A new web search provider option for You.com is now available, giving users another search engine choice for web-enabled models. The You.com provider enriches search results by including both descriptions and snippets for better context. [#21599](https://github.com/open-webui/open-webui/pull/21599)
- 🚀 **Message list performance.** Loading conversation history when sending messages is now significantly faster, improving response latency before the model starts generating. This also speeds up chat search and RAG context building. [#21588](https://github.com/open-webui/open-webui/pull/21588)
- 🎯 **Concurrent embedding request control.** Administrators can now control the maximum number of concurrent embedding API requests using the RAG_EMBEDDING_CONCURRENT_REQUESTS environment variable, helping manage API rate limits while maintaining embedding performance. [#21662](https://github.com/open-webui/open-webui/pull/21662), [Commit](https://github.com/open-webui/open-webui/commit/5d4547f934b6fbe751bb2041f9597fe11ddf8e43)
- ⚡ **Message upsert optimization.** Loading chat data during message saving is now significantly faster by eliminating a redundant database call that occurred on every message upsert, which happens many times during streaming responses. [#21592](https://github.com/open-webui/open-webui/pull/21592)
- ⚡ **Message send optimization.** Loading chat data during message sending is now significantly faster by eliminating unnecessary full conversation history loads. The system now uses targeted queries that fetch only the needed data instead of loading entire chat objects with all message history. [#21596](https://github.com/open-webui/open-webui/pull/21596)
- 🚀 **Tag filtering optimization.** Chat search with tag filtering now uses more efficient database queries, making filtered searches significantly faster. [Commit](https://github.com/open-webui/open-webui/commit/139f02a9d9fa2ffffcc96aa0de8af8ef51b6bcf2)
- ⚡ **Shared chat loading optimization.** The shared chats endpoint now loads only the needed columns instead of the full conversation history, making shared chat listings significantly faster. [#21614](https://github.com/open-webui/open-webui/pull/21614)
- 🗂️ **Archived and pinned chat loading.** Loading archived and pinned chat lists is now significantly faster by loading only the needed columns instead of full conversation data. [#21591](https://github.com/open-webui/open-webui/pull/21591)
- 💨 **Chat title query optimization.** Retrieving chat titles now queries only the title column instead of the entire conversation history, making title lookups significantly faster and reducing database load. [#21590](https://github.com/open-webui/open-webui/pull/21590)
- 🗄️ **Batch access grants for multiple resources.** Loading channels, knowledge bases, models, notes, prompts, skills, and tools now uses batch database queries for access grants instead of individual queries per item, significantly reducing database load. For 30 items, this reduces approximately 31 queries to just 3. [#21616](https://github.com/open-webui/open-webui/pull/21616)
- 📋 **Notes list payload optimization.** Notes list and search endpoints now return only a 200-character preview instead of the full note content, reducing response payload from ~167 MB to ~10 KB for 60 notes and eliminating N+1 queries for access grants. The Notes tab now loads in seconds instead of tens of seconds. [#21549](https://github.com/open-webui/open-webui/pull/21549)
- ⚡ **Tools list performance.** Loading the tools list is now significantly faster by deferring content and specs fields from database queries, and using cached tool modules instead of reloading them for each request. [Commit](https://github.com/open-webui/open-webui/commit/b48594a16680cc77921a4ed1a11ffa07df7edc60)
- 📝 **Group description display.** The admin groups list now shows each group's description, making it easier for administrators to identify groups at a glance.
- 🏷️ **Sort by dropdown.** Administrators can now sort groups using a dropdown menu with options for Name or Members, replacing the previous clickable column headers.
- 📶 **Admin groups list sorting.** The Group and Users columns in the admin groups list are now clickable for sorting, allowing administrators to sort groups alphabetically by name or numerically by member count. [#21692](https://github.com/open-webui/open-webui/pull/21692)
- 🔽 **Rich UI auto-scroll.** The view now automatically scrolls to action-generated Rich UI content once it renders, ensuring users can see the results without manually scrolling. [#21698](https://github.com/open-webui/open-webui/pull/21698), [#21482](https://github.com/open-webui/open-webui/discussions/21482)
- 📊 **Admin analytics toggle.** Administrators can now enable or disable the analytics feature using the ENABLE_ADMIN_ANALYTICS environment variable, giving more control over available admin features. [#21651](https://github.com/open-webui/open-webui/pull/21651), [Commit](https://github.com/open-webui/open-webui/commit/35598b8017557258b8c9ee3469d320adb0140751)
- 📊 **Analytics sorting enhancement.** The Analytics dashboard now supports sorting by Tokens column for both Model Usage and User Usage tables, and the Share/Percentage columns are now clickable for sorting. Administrators can more easily identify the most token-consuming models and users. [Commit](https://github.com/open-webui/open-webui/commit/053a33631f575ae1ad3123190a9e820b4057f62d)
- 📑 **Fetch URL citation sources.** When models fetch URLs during tool calling, the fetched URLs now appear as clickable citation sources in the UI with content previews, matching the existing behavior of web search and knowledge file tools. [#21669](https://github.com/open-webui/open-webui/pull/21669)
- 🔗 **Admin settings tab navigation.** The admin settings sidebar now supports native browser tab opening, allowing users to middle-click or right-click to open settings pages in new tabs. The navigation was converted from button-based to anchor-based elements. [#21721](https://github.com/open-webui/open-webui/pull/21721)
- 🏷️ **Model visibility badges.** The Admin Settings Models page now displays Public or Private badges directly on each model, making it easy to identify model access levels at a glance without opening the edit screen. [#21732](https://github.com/open-webui/open-webui/issues/21732), [Commit](https://github.com/open-webui/open-webui/commit/29217cb430bd47827ebb20782b264ae7b0f233bb)
- 🛠️ **Global model defaults.** Administrators can now configure default metadata and parameters that automatically apply to all models, reducing manual configuration for newly discovered models. Default capabilities (like vision, web search, code interpreter) and parameters (like temperature, max_tokens) can be set globally in Admin Settings, with per-model overrides still available. [#20658](https://github.com/open-webui/open-webui/issues/20658), [Commit](https://github.com/open-webui/open-webui/commit/c341f97cfe15510b7d128bd84f1e607b5289b957)
- 💬 **Plaintext tool output display.** Tool outputs that are plain strings now display naturally in a monospace block instead of quoted/escaped format, making multi-line string outputs easier to read. [#21553](https://github.com/open-webui/open-webui/issues/21553), [Commit](https://github.com/open-webui/open-webui/commit/3ad2ea6f2839e97e53f00fd797a9e083ff78d88e)
- 🔐 **Event call input masking.** Functions can now request masked password input in confirmation dialogs, allowing sensitive data entry to be hidden from view. This extends the existing masking feature from user valves to event calls. [#21540](https://github.com/open-webui/open-webui/issues/21540), [Commit](https://github.com/open-webui/open-webui/commit/4853ededcabcd76d9bd2036181486cd3a41458a1)
- 🗂️ **JSON logging support.** Administrators can now enable JSON-formatted logging by setting the LOG_FORMAT environment variable to "json", making logs suitable for log aggregators like Loki, Fluentd, CloudWatch, and Datadog. [#21747](https://github.com/open-webui/open-webui/pull/21747)
- ♿ **UI accessibility improvements.** Screen reader users can now navigate the interface more easily with improved keyboard navigation in dialogs and proper ARIA labels on all interactive elements. Added aria-labels to close, back, and action buttons across various components, and improved semantic HTML and screen reader support across auth, sidebar, chat, and notification components, addressing WCAG compliance. Added aria-labels to search inputs, select fields, and modals in admin and user settings, and improved accessibility for text inputs, rating components, citations, and web search results. Added aria-labels to workspace components including Knowledge, Models, Prompts, Skills, and Tools pages for improved screen reader support. [#21706](https://github.com/open-webui/open-webui/pull/21706), [#21705](https://github.com/open-webui/open-webui/pull/21705), [#21710](https://github.com/open-webui/open-webui/pull/21710), [#21709](https://github.com/open-webui/open-webui/pull/21709), [#21717](https://github.com/open-webui/open-webui/pull/21717), [#21715](https://github.com/open-webui/open-webui/pull/21715), [#21708](https://github.com/open-webui/open-webui/pull/21708), [#21719](https://github.com/open-webui/open-webui/pull/21719)
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 Translations for Finnish, French, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
### Fixed
- 💥 **Admin functions page crash fix.** The admin Functions tab no longer crashes when clicked, fixing a null reference error that occurred while the functions list was loading. [#21661](https://github.com/open-webui/open-webui/pull/21661), [Commit](https://github.com/open-webui/open-webui/commit/8265422ba0660e7ba2192eb19efd70f8be652748)
- 💀 **Cyclic chat history deadlock fix.** Chat histories with circular parent-child message references no longer cause the backend to freeze when syncing usage stats. The system now detects and safely aborts when encountering cyclic message references. [#21681](https://github.com/open-webui/open-webui/pull/21681)
- 🔀 **Model fallback routing fix.** Custom model fallback now works correctly across all model types, preventing "Model not found" errors when the fallback model uses a different backend (pipe, Ollama, or OpenAI). [#21736](https://github.com/open-webui/open-webui/pull/21736)
- 🐛 **Default model selection fix.** Admin-configured default models are now properly respected when starting new chats instead of being overwritten by the first available model. [#21736](https://github.com/open-webui/open-webui/pull/21736)
- 👁️ **Scroll jumping fix.** Deleting a message pair after stopping generation no longer causes the chat to visually jump around, making message deletion smoother. [#21743](https://github.com/open-webui/open-webui/pull/21743), [Commit](https://github.com/open-webui/open-webui/commit/1f474187a77d2c8a392f00d86f48eb3cb3a18b88)
- 💬 **New chat message handling fix.** Fixed a bug where clicking "New Chat" after sending a message would silently drop subsequent messages. The system now properly clears pending message queues when starting a new conversation. [#21731](https://github.com/open-webui/open-webui/pull/21731)
- 🔍 **RAG template mutation fix.** Fixed a bug where RAG template text was recursively injected into user messages during multiple sequential tool calls, causing message content to grow exponentially and potentially confuse the model. The system now preserves the original user message before tool-calling loops and correctly accumulates citation sources. [#21663](https://github.com/open-webui/open-webui/issues/21663), [#21668](https://github.com/open-webui/open-webui/pull/21668), [Commit](https://github.com/open-webui/open-webui/commit/becac2b2b7af8aacadbfc9b7cee2024cf7ed6acc)
- 🔒 **Iframe sandbox security.** Embedded tools can no longer submit forms or access same-origin content by default, improving security for users. [#21529](https://github.com/open-webui/open-webui/pull/21529)
- 🔐 **Signup race condition fix.** Fixed a security vulnerability where multiple admin accounts could be created on fresh deployments when running multiple uvicorn workers. The signup handler now properly handles concurrent requests during first-user registration, preventing unauthorized admin privilege escalation. [#21631](https://github.com/open-webui/open-webui/pull/21631)
- 🔐 **LDAP optional fields fix.** LDAP configuration now properly accepts empty Application DN and password values, allowing LDAP authentication to work without these optional fields. Previously, empty values caused authentication failures. [Commit](https://github.com/open-webui/open-webui/commit/e1fa42d48a15c8b496a887ecfa32fc01cfd74b36)
- 🛠️ **API tools fix.** The /api/v1/chat/completions endpoint now properly respects caller-provided tools instead of overriding them with server-side tools, fixing issues where external agents like Claude Code or Cursor would receive unexpected tool advertisements. [#21557](https://github.com/open-webui/open-webui/issues/21557), [#21555](https://github.com/open-webui/open-webui/pull/21555)
- ⏱️ **Embeddings and proxy timeout fix.** The embeddings and OpenAI proxy endpoints now properly honor the AIOHTTP_CLIENT_TIMEOUT environment variable, instead of using default timeouts that could cause requests to hang. [#21558](https://github.com/open-webui/open-webui/pull/21558)
- 📄 **Text file type detection fix.** TypeScript and other text files that were mis-detected as video files based on their extension are now correctly identified and processed as text files, fixing upload rejections for .ts files. [#21454](https://github.com/open-webui/open-webui/issues/21454), [Commit](https://github.com/open-webui/open-webui/commit/f651809001ba8e40ba5f416773c1aa6f082a6c46)
- 🗄️ **File access control respect.** The files list and search endpoints now properly respect the BYPASS_ADMIN_ACCESS_CONTROL setting, ensuring admins only see their own files when the setting is disabled, consistent with other endpoints. [#21595](https://github.com/open-webui/open-webui/pull/21595), [#21589](https://github.com/open-webui/open-webui/issues/21589)
- 🗄️ **PostgreSQL workspace cloning.** Cloning workspace models now works correctly on PostgreSQL databases by generating proper unique IDs for access grants instead of using potentially duplicate or invalid IDs. [Commit](https://github.com/open-webui/open-webui/commit/3dd44c4f1931d13bfd46062291c6f23b33dde003)
- 🔓 **MCP SSL verification fix.** MCP tool connections now properly respect the AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL environment variable to disable SSL verification, instead of always verifying SSL certificates. [Commit](https://github.com/open-webui/open-webui/commit/af5661c2c807465f5600899e8c1a421f96cd7a8c), [#21481](https://github.com/open-webui/open-webui/issues/21481)
- 🔒 **Model default feature permissions.** Model default features like code interpreter, web search, and image generation now respect global configuration and user permission settings, preventing disabled features from appearing in the chat input. [#21690](https://github.com/open-webui/open-webui/pull/21690)
- 🔍 **Model selector typing fix.** The model selector list no longer disappears or becomes grayed out when typing quickly in the search field, thanks to improved virtual scroll handling. [#21659](https://github.com/open-webui/open-webui/pull/21659)
- ⛔ **Disabled model cloning prevention.** Disabled models can no longer be cloned as workspace models, preventing invalid empty configurations from being created. The Clone option is now hidden for inactive models. [#21724](https://github.com/open-webui/open-webui/pull/21724)
- 🔧 **SCIM parameter handling.** The SCIM Users and Groups endpoints now accept out-of-range startIndex and count values by clamping them to valid ranges instead of returning errors, in compliance with RFC 7644. [#21577](https://github.com/open-webui/open-webui/pull/21577)
- 🔍 **Hybrid search result fix.** Hybrid search now returns correct results after fixing a bug where query result unpacking order was mismatched, causing search results to appear empty. [#21562](https://github.com/open-webui/open-webui/pull/21562)
- 🛠️ **Imported items display.** Imported functions and tools now appear immediately in the list after import, without requiring a page reload. [#21593](https://github.com/open-webui/open-webui/issues/21593)
- 🔄 **WebSocket race condition fix.** Collaborative note saves no longer crash with errors when users disconnect before pending saves complete, preventing AttributeError exceptions and excessive logging. [#21601](https://github.com/open-webui/open-webui/issues/21601), [Commit](https://github.com/open-webui/open-webui/commit/0a700aafe46dfea2cf9721bb81725d2582b0d781)
- ✋ **Drag-and-drop overlay fix.** The "Add Files" overlay no longer remains stuck on screen when dragging files back out of the chat window in Mozilla Firefox. [#21664](https://github.com/open-webui/open-webui/pull/21664)
- 👁️ **Group search visibility fix.** Groups now appear correctly in access control search results, even when the search doesn't match any users. [#21691](https://github.com/open-webui/open-webui/pull/21691)
- 🖱️ **User menu drag and click fixes.** Fixed draggable ghost images when dragging menu items and eliminated phantom link clicks that occurred when dragging outside dropdown menus. [#21699](https://github.com/open-webui/open-webui/pull/21699)
- 🧭 **Admin and workspace nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Admin and Workspace panels by adding proper drag constraints and text selection prevention. [#21701](https://github.com/open-webui/open-webui/pull/21701)
- 🎮 **Playground nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Playground panel by adding proper drag constraints and text selection prevention. [#21704](https://github.com/open-webui/open-webui/pull/21704)
- ✋ **Dropdown menu drag fix.** Dropdown menu items can no longer be accidentally dragged as ghost images when highlighting text, making menu interactions smoother. [#21713](https://github.com/open-webui/open-webui/pull/21713)
- 🗂️ **Folder menu drag fix.** Folder dropdown menu items can no longer be accidentally highlighted or dragged as ghost images, making folder options behave like standard menus. [#21753](https://github.com/open-webui/open-webui/pull/21753)
- 📝 **Console log spam fix.** Requesting deleted or missing files no longer floods the backend console with Python traceback logs, thanks to proper exception handling for expected 404 errors. [#21687](https://github.com/open-webui/open-webui/pull/21687)
- 🐛 **Firefox avatar overflow fix.** Fixed a visual bug in Firefox where broken model or user avatar images would display overflowing alt text that overlapped adjacent labels on the Analytics and Leaderboard pages. Failed avatar images now properly show fallback icons instead. [#21730](https://github.com/open-webui/open-webui/pull/21730)
- 🎨 **Dark mode select background fix.** Fixed an issue where select inputs and dropdown menus had inconsistent lighter background colors in dark mode by removing conflicting dark theme overrides, ensuring a cohesive transparent look. [#21728](https://github.com/open-webui/open-webui/pull/21728)
- 💾 **Prompt import fix.** Importing prompts that were previously exported no longer fails with a "[object Object]" error toast, making prompt backup and restore work correctly. [#21594](https://github.com/open-webui/open-webui/issues/21594)
- 🔧 **Ollama reasoning effort fix.** Reasoning effort now works correctly with Ollama models that require string values ("low", "medium", "high") instead of boolean, fixing "invalid option provided" errors when using models like GPT-OSS. [#20921](https://github.com/open-webui/open-webui/issues/20921), [#20928](https://github.com/open-webui/open-webui/pull/20928), [Commit](https://github.com/open-webui/open-webui/commit/30a13b9b2fb2c6da7e1ddbf52edb93a58d09cc56)
- 🔍 **Hybrid search deduplication fix.** Hybrid search now correctly deduplicates results using content hashes, preventing duplicate chunks from appearing when using enriched text for BM25 search. [Commit](https://github.com/open-webui/open-webui/commit/d9fd2a3f30481efa24cc54193bf2f67fd0299b52)
- 📋 **SQLAlchemy warning fix.** Fixed a SQLAlchemy warning that appeared in logs when deleting shared chats, improving log clarity. [Commit](https://github.com/open-webui/open-webui/commit/0185f3340d2778f3b75a8036b0e81a0aec78037f)
### Changed
- 🎯 **Prompt suggestions relocated.** Prompt suggestions have been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - Models, where they can now be configured per-model or globally via the new model defaults.
- 📢 **Banners relocated.** Banners configuration has been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - General.
## [0.8.3] - 2026-02-17
### Added
+2 -2
View File
@@ -12,7 +12,7 @@
![Open WebUI Banner](./banner.png)
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
**Open WebUI is an [extensible](https://docs.openwebui.com/features/extensibility/plugin), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
@@ -172,7 +172,7 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/advanced-topics/development) for instructions on setting up a local development environment.
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
### Troubleshooting
+47
View File
@@ -573,6 +573,20 @@ ENABLE_OAUTH_GROUP_CREATION = PersistentConfig(
)
oauth_group_default_share = (
os.environ.get("OAUTH_GROUP_DEFAULT_SHARE", "true").strip().lower()
)
OAUTH_GROUP_DEFAULT_SHARE = PersistentConfig(
"OAUTH_GROUP_DEFAULT_SHARE",
"oauth.group_default_share",
(
"members"
if oauth_group_default_share == "members"
else oauth_group_default_share == "true"
),
)
OAUTH_BLOCKED_GROUPS = PersistentConfig(
"OAUTH_BLOCKED_GROUPS",
"oauth.blocked_groups",
@@ -1249,6 +1263,18 @@ MODEL_ORDER_LIST = PersistentConfig(
[],
)
DEFAULT_MODEL_METADATA = PersistentConfig(
"DEFAULT_MODEL_METADATA",
"models.default_metadata",
{},
)
DEFAULT_MODEL_PARAMS = PersistentConfig(
"DEFAULT_MODEL_PARAMS",
"models.default_params",
{},
)
DEFAULT_USER_ROLE = PersistentConfig(
"DEFAULT_USER_ROLE",
"ui.default_user_role",
@@ -1428,6 +1454,10 @@ USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_WEB_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_WEB_UPLOAD", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
@@ -1566,6 +1596,7 @@ DEFAULT_USER_PERMISSIONS = {
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
"params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"web_upload": USER_PERMISSIONS_CHAT_WEB_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
@@ -1678,6 +1709,10 @@ ENABLE_ADMIN_CHAT_ACCESS = (
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
)
ENABLE_ADMIN_ANALYTICS = (
os.environ.get("ENABLE_ADMIN_ANALYTICS", "True").lower() == "true"
)
ENABLE_COMMUNITY_SHARING = PersistentConfig(
"ENABLE_COMMUNITY_SHARING",
"ui.enable_community_sharing",
@@ -2903,6 +2938,12 @@ ENABLE_ASYNC_EMBEDDING = PersistentConfig(
os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true",
)
RAG_EMBEDDING_CONCURRENT_REQUESTS = PersistentConfig(
"RAG_EMBEDDING_CONCURRENT_REQUESTS",
"rag.embedding_concurrent_requests",
int(os.getenv("RAG_EMBEDDING_CONCURRENT_REQUESTS", "0")),
)
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
@@ -3485,6 +3526,12 @@ YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
)
YOUCOM_API_KEY = PersistentConfig(
"YOUCOM_API_KEY",
"rag.web.search.youcom_api_key",
os.environ.get("YOUCOM_API_KEY", ""),
)
####################################
# Images
####################################
+55 -1
View File
@@ -5,6 +5,9 @@ import os
import pkgutil
import sys
import shutil
import traceback
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from pathlib import Path
from cryptography.hazmat.primitives import serialization
@@ -72,9 +75,51 @@ except Exception:
# LOGGING
####################################
_LEVEL_MAP = {
"DEBUG": "debug",
"INFO": "info",
"WARNING": "warn",
"ERROR": "error",
"CRITICAL": "fatal",
}
class JSONFormatter(logging.Formatter):
"""Format log records as single-line JSON objects for structured logging."""
def format(self, record: logging.LogRecord) -> str:
log_entry: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
timespec="milliseconds"
),
"level": _LEVEL_MAP.get(record.levelname, record.levelname.lower()),
"msg": record.getMessage(),
"caller": record.name,
}
if record.exc_info and record.exc_info[0] is not None:
log_entry["error"] = "".join(
traceback.format_exception(*record.exc_info)
).rstrip()
elif record.exc_text:
log_entry["error"] = record.exc_text
if record.stack_info:
log_entry["stacktrace"] = record.stack_info
return json.dumps(log_entry, ensure_ascii=False, default=str)
LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower()
GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper()
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
if LOG_FORMAT == "json":
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(JSONFormatter())
logging.basicConfig(handlers=[_handler], level=GLOBAL_LOG_LEVEL, force=True)
else:
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
else:
GLOBAL_LOG_LEVEL = "INFO"
@@ -557,6 +602,10 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
# Maximum number of concurrent OAuth sessions per user per provider
# This prevents unbounded session growth while allowing multi-device usage
OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get("OAUTH_MAX_SESSIONS_PER_USER", "10"))
# Token Exchange Configuration
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
ENABLE_OAUTH_TOKEN_EXCHANGE = (
@@ -978,6 +1027,11 @@ OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
# TOOLS/FUNCTIONS PIP OPTIONS
####################################
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
os.environ.get("ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS", "True").lower()
== "true"
)
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
+112 -9
View File
@@ -240,6 +240,7 @@ from open_webui.config import (
RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_BATCH_SIZE,
ENABLE_ASYNC_EMBEDDING,
RAG_EMBEDDING_CONCURRENT_REQUESTS,
RAG_TOP_K,
RAG_TOP_K_RERANKER,
RAG_RELEVANCE_THRESHOLD,
@@ -359,6 +360,7 @@ from open_webui.config import (
YANDEX_WEB_SEARCH_URL,
YANDEX_WEB_SEARCH_API_KEY,
YANDEX_WEB_SEARCH_CONFIG,
YOUCOM_API_KEY,
# WebUI
WEBUI_AUTH,
WEBUI_NAME,
@@ -392,6 +394,8 @@ from open_webui.config import (
DEFAULT_PINNED_MODELS,
DEFAULT_ARENA_MODEL,
MODEL_ORDER_LIST,
DEFAULT_MODEL_METADATA,
DEFAULT_MODEL_PARAMS,
EVALUATION_ARENA_MODELS,
# WebUI (OAuth)
ENABLE_OAUTH_ROLE_MANAGEMENT,
@@ -432,6 +436,7 @@ from open_webui.config import (
RESPONSE_WATERMARK,
# Admin
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_ANALYTICS,
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_EXPORT,
# Tasks
@@ -498,6 +503,7 @@ from open_webui.env import (
WEBUI_ADMIN_PASSWORD,
WEBUI_ADMIN_NAME,
ENABLE_EASTER_EGGS,
LOG_FORMAT,
)
@@ -576,7 +582,8 @@ class SPAStaticFiles(StaticFiles):
raise ex
print(rf"""
if LOG_FORMAT != "json":
print(rf"""
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
@@ -819,6 +826,8 @@ app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS
app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.DEFAULT_MODEL_METADATA = DEFAULT_MODEL_METADATA
app.state.config.DEFAULT_MODEL_PARAMS = DEFAULT_MODEL_PARAMS
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
@@ -980,6 +989,7 @@ app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
app.state.config.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING
app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = RAG_EMBEDDING_CONCURRENT_REQUESTS
app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
@@ -1065,6 +1075,7 @@ app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
app.state.config.YOUCOM_API_KEY = YOUCOM_API_KEY
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
@@ -1133,6 +1144,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
else None
),
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
concurrent_requests=app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
)
app.state.RERANKING_FUNCTION = get_reranking_function(
@@ -1442,6 +1454,16 @@ async def check_url(request: Request, call_next):
scheme="Bearer", credentials=request.cookies.get("token")
)
# Fallback to x-api-key header for Anthropic Messages API routes
if request.state.token is None and request.headers.get("x-api-key"):
request_path = request.url.path
if request_path in ("/api/message", "/api/v1/messages"):
from fastapi.security import HTTPAuthorizationCredentials
request.state.token = HTTPAuthorizationCredentials(
scheme="Bearer", credentials=request.headers.get("x-api-key")
)
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
response = await call_next(request)
process_time = int(time.time()) - start_time
@@ -1515,7 +1537,8 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
app.include_router(
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
)
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
if ENABLE_ADMIN_ANALYTICS:
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
# SCIM 2.0 API for identity management
@@ -1671,9 +1694,18 @@ async def chat_completion(
request.state.direct = True
request.state.model = model
model_info_params = (
model_info.params.model_dump() if model_info and model_info.params else {}
# Model params: global defaults as base, per-model overrides win
default_model_params = (
getattr(request.app.state.config, "DEFAULT_MODEL_PARAMS", None) or {}
)
model_info_params = {
**default_model_params,
**(
model_info.params.model_dump()
if model_info and model_info.params
else {}
),
}
# Check base model existence for custom models
if model_info_params.get("base_model_id"):
@@ -1688,8 +1720,13 @@ async def chat_completion(
default_models[0].strip() if default_models[0] else None
)
if fallback_model_id:
request.base_model_id = fallback_model_id
if (
fallback_model_id
and fallback_model_id in request.app.state.MODELS
):
# Update model and form_data so routing uses the fallback model's type
model = request.app.state.MODELS[fallback_model_id]
form_data["model"] = fallback_model_id
else:
raise Exception("Model not found")
else:
@@ -1745,9 +1782,12 @@ async def chat_completion(
"local:"
): # temporary chats are not stored
# Verify chat ownership
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None and user.role != "admin": # admins can access any chat
# Verify chat ownership — lightweight EXISTS check avoids
# deserializing the full chat JSON blob just to confirm the row exists
if (
not Chats.is_chat_owner(metadata["chat_id"], user.id)
and user.role != "admin"
): # admins can access any chat
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT(),
@@ -1893,6 +1933,68 @@ generate_chat_completions = chat_completion
generate_chat_completion = chat_completion
##################################
#
# Anthropic Messages API Compatible Endpoint
#
##################################
from open_webui.utils.anthropic import (
convert_anthropic_to_openai_payload,
convert_openai_to_anthropic_response,
openai_stream_to_anthropic_stream,
)
@app.post("/api/message")
@app.post("/api/v1/messages") # Anthropic Messages API compatible endpoint
async def generate_messages(
request: Request,
form_data: dict,
user=Depends(get_verified_user),
):
"""
Anthropic Messages API compatible endpoint.
Accepts the Anthropic Messages API format, converts internally to OpenAI
Chat Completions format, routes through the existing chat completion
pipeline, then converts the response back to Anthropic Messages format.
Supports both streaming and non-streaming requests.
All models configured in Open WebUI are accessible via this endpoint.
Authentication: Supports both standard Authorization header and
Anthropic's x-api-key header (via middleware translation).
"""
# Convert Anthropic payload to OpenAI format
requested_model = form_data.get("model", "")
openai_payload = convert_anthropic_to_openai_payload(form_data)
# Route through the existing chat_completion handler
response = await chat_completion(request, openai_payload, user)
# Convert response back to Anthropic format
if isinstance(response, StreamingResponse):
# Streaming response: wrap the generator to convert SSE format
return StreamingResponse(
openai_stream_to_anthropic_stream(
response.body_iterator, model=requested_model
),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
)
elif isinstance(response, dict):
return convert_openai_to_anthropic_response(response, model=requested_model)
else:
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
return response
@app.post("/api/chat/completed")
async def chat_completed(
request: Request, form_data: dict, user=Depends(get_verified_user)
@@ -2042,6 +2144,7 @@ async def get_app_config(request: Request):
"enable_user_status": app.state.config.ENABLE_USER_STATUS,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_admin_analytics": ENABLE_ADMIN_ANALYTICS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"enable_memories": app.state.config.ENABLE_MEMORIES,
+9 -1
View File
@@ -1,8 +1,9 @@
import logging
from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
@@ -14,6 +15,13 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name, disable_existing_loggers=False)
# Re-apply JSON formatter after fileConfig replaces handlers.
if LOG_FORMAT == "json":
from open_webui.env import JSONFormatter
for handler in logging.root.handlers:
handler.setFormatter(JSONFormatter())
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
+26 -1
View File
@@ -402,7 +402,7 @@ class AccessGrantsTable:
results = []
for grant_dict in normalized_grants:
grant = AccessGrant(
id=grant_dict["id"],
id=str(uuid.uuid4()),
resource_type=resource_type,
resource_id=resource_id,
principal_type=grant_dict["principal_type"],
@@ -456,6 +456,31 @@ class AccessGrantsTable:
)
return [AccessGrantModel.model_validate(g) for g in grants]
def get_grants_by_resources(
self,
resource_type: str,
resource_ids: list[str],
db: Optional[Session] = None,
) -> dict[str, list[AccessGrantModel]]:
"""Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}."""
if not resource_ids:
return {}
with get_db_context(db) as db:
grants = (
db.query(AccessGrant)
.filter(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id.in_(resource_ids),
)
.all()
)
result: dict[str, list[AccessGrantModel]] = {
rid: [] for rid in resource_ids
}
for g in grants:
result[g.resource_id].append(AccessGrantModel.model_validate(g))
return result
def has_access(
self,
user_id: str,
+40 -6
View File
@@ -261,13 +261,19 @@ class ChannelTable:
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
def _to_channel_model(
self, channel: Channel, db: Optional[Session] = None
self,
channel: Channel,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> ChannelModel:
channel_data = ChannelModel.model_validate(channel).model_dump(
exclude={"access_grants"}
)
access_grants = self._get_access_grants(channel_data["id"], db=db)
channel_data["access_grants"] = access_grants
channel_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(channel_data["id"], db=db)
)
return ChannelModel.model_validate(channel_data)
def _collect_unique_user_ids(
@@ -368,7 +374,18 @@ class ChannelTable:
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
with get_db_context(db) as db:
channels = db.query(Channel).all()
return [self._to_channel_model(channel, db=db) for channel in channels]
channel_ids = [channel.id for channel in channels]
grants_map = AccessGrants.get_grants_by_resources(
"channel", channel_ids, db=db
)
return [
self._to_channel_model(
channel,
access_grants=grants_map.get(channel.id, []),
db=db,
)
for channel in channels
]
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
return AccessGrants.has_permission_filter(
@@ -417,7 +434,14 @@ class ChannelTable:
standard_channels = query.all()
all_channels = membership_channels + standard_channels
return [self._to_channel_model(c, db=db) for c in all_channels]
channel_ids = [c.id for c in all_channels]
grants_map = AccessGrants.get_grants_by_resources(
"channel", channel_ids, db=db
)
return [
self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db)
for c in all_channels
]
def get_dm_channel_by_user_ids(
self, user_ids: list[str], db: Optional[Session] = None
@@ -724,7 +748,17 @@ class ChannelTable:
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [self._to_channel_model(channel, db=db) for channel in channels]
grants_map = AccessGrants.get_grants_by_resources(
"channel", channel_ids, db=db
)
return [
self._to_channel_model(
channel,
access_grants=grants_map.get(channel.id, []),
db=db,
)
for channel in channels
]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str, db: Optional[Session] = None
+93 -13
View File
@@ -456,11 +456,11 @@ class ChatTable:
return ChatModel.model_validate(chat)
def get_chat_title_by_id(self, id: str) -> Optional[str]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("title", "New Chat")
with get_db_context() as db:
result = db.query(Chat.title).filter_by(id=id).first()
if result is None:
return None
return result[0] or "New Chat"
def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
chat = self.get_chat_by_id(id)
@@ -489,6 +489,7 @@ class ChatTable:
if isinstance(message.get("content"), str):
message["content"] = sanitize_text_for_db(message["content"])
user_id = chat.user_id
chat = chat.chat
history = chat.get("history", {})
@@ -509,7 +510,7 @@ class ChatTable:
ChatMessages.upsert_message(
message_id=message_id,
chat_id=id,
user_id=self.get_chat_by_id(id).user_id,
user_id=user_id,
data=history["messages"][message_id],
)
except Exception as e:
@@ -630,7 +631,9 @@ class ChatTable:
with get_db_context(db) as db:
# Use subquery to delete chat_messages for shared chats
shared_chat_id_subquery = (
db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery()
db.query(Chat.id)
.filter_by(user_id=f"shared-{chat_id}")
.scalar_subquery()
)
db.query(ChatMessage).filter(
ChatMessage.chat_id.in_(shared_chat_id_subquery)
@@ -713,7 +716,7 @@ class ChatTable:
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
@@ -739,13 +742,27 @@ class ChatTable:
else:
query = query.order_by(Chat.updated_at.desc())
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at
)
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
"id": chat[0],
"title": chat[1],
"updated_at": chat[2],
"created_at": chat[3],
}
)
for chat in all_chats
]
def get_shared_chat_list_by_user_id(
self,
@@ -754,7 +771,7 @@ class ChatTable:
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[SharedChatResponse]:
with get_db_context(db) as db:
query = (
@@ -784,13 +801,34 @@ class ChatTable:
else:
query = query.order_by(Chat.updated_at.desc())
# Select only the columns needed for SharedChatResponse
# to avoid loading the heavy chat JSON blob
query = query.with_entities(
Chat.id,
Chat.title,
Chat.share_id,
Chat.updated_at,
Chat.created_at,
)
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
SharedChatResponse.model_validate(
{
"id": chat[0],
"title": chat[1],
"share_id": chat[2],
"updated_at": chat[3],
"created_at": chat[4],
}
)
for chat in all_chats
]
def get_chat_list_by_user_id(
self,
@@ -938,6 +976,37 @@ class ChatTable:
except Exception:
return None
def is_chat_owner(
self, id: str, user_id: str, db: Optional[Session] = None
) -> bool:
"""
Lightweight ownership check — uses EXISTS subquery instead of loading
the full Chat row (which includes the potentially large JSON blob).
"""
try:
with get_db_context(db) as db:
return db.query(
exists().where(and_(Chat.id == id, Chat.user_id == user_id))
).scalar()
except Exception:
return False
def get_chat_folder_id(
self, id: str, user_id: str, db: Optional[Session] = None
) -> Optional[str]:
"""
Fetch only the folder_id column for a chat, without loading the full
JSON blob. Returns None if chat doesn't exist or doesn't belong to user.
"""
try:
with get_db_context(db) as db:
result = (
db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first()
)
return result[0] if result else None
except Exception:
return None
def get_chats(
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
) -> list[ChatModel]:
@@ -997,14 +1066,25 @@ class ChatTable:
def get_pinned_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
all_chats = (
db.query(Chat)
.filter_by(user_id=user_id, pinned=True, archived=False)
.order_by(Chat.updated_at.desc())
.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at)
)
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
"id": chat[0],
"title": chat[1],
"updated_at": chat[2],
"created_at": chat[3],
}
)
for chat in all_chats
]
def get_archived_chats_by_user_id(
self, user_id: str, db: Optional[Session] = None
+34 -6
View File
@@ -144,13 +144,18 @@ class KnowledgeTable:
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
def _to_knowledge_model(
self, knowledge: Knowledge, db: Optional[Session] = None
self,
knowledge: Knowledge,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> KnowledgeModel:
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
exclude={"access_grants"}
)
knowledge_data["access_grants"] = self._get_access_grants(
knowledge_data["id"], db=db
knowledge_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(knowledge_data["id"], db=db)
)
return KnowledgeModel.model_validate(knowledge_data)
@@ -192,9 +197,13 @@ class KnowledgeTable:
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
knowledge_ids = [knowledge.id for knowledge in all_knowledge]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
knowledge_bases = []
for knowledge in all_knowledge:
@@ -202,7 +211,11 @@ class KnowledgeTable:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(knowledge, db=db).model_dump(),
**self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -261,13 +274,20 @@ class KnowledgeTable:
items = query.all()
knowledge_ids = [kb.id for kb, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**self._to_knowledge_model(
knowledge_base, db=db
knowledge_base,
access_grants=grants_map.get(knowledge_base.id, []),
db=db,
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
@@ -440,8 +460,16 @@ class KnowledgeTable:
.filter(KnowledgeFile.file_id == file_id)
.all()
)
knowledge_ids = [k.id for k in knowledges]
grants_map = AccessGrants.get_grants_by_resources(
"knowledge", knowledge_ids, db=db
)
return [
self._to_knowledge_model(knowledge, db=db)
self._to_knowledge_model(
knowledge,
access_grants=grants_map.get(knowledge.id, []),
db=db,
)
for knowledge in knowledges
]
except Exception:
+63 -10
View File
@@ -144,11 +144,20 @@ class ModelsTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
def _to_model_model(
self,
model: Model,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> ModelModel:
model_data = ModelModel.model_validate(model).model_dump(
exclude={"access_grants"}
)
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
model_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(model_data["id"], db=db)
)
return ModelModel.model_validate(model_data)
def insert_new_model(
@@ -181,8 +190,14 @@ class ModelsTable:
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
all_models = db.query(Model).all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
return [
self._to_model_model(model, db=db) for model in db.query(Model).all()
self._to_model_model(
model, access_grants=grants_map.get(model.id, []), db=db
)
for model in all_models
]
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
@@ -190,9 +205,11 @@ class ModelsTable:
all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models))
model_ids = [model.id for model in all_models]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
models = []
for model in all_models:
@@ -200,7 +217,11 @@ class ModelsTable:
models.append(
ModelUserResponse.model_validate(
{
**self._to_model_model(model, db=db).model_dump(),
**self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -209,9 +230,14 @@ class ModelsTable:
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
with get_db_context(db) as db:
all_models = db.query(Model).filter(Model.base_model_id == None).all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
return [
self._to_model_model(model, db=db)
for model in db.query(Model).filter(Model.base_model_id == None).all()
self._to_model_model(
model, access_grants=grants_map.get(model.id, []), db=db
)
for model in all_models
]
def get_models_by_user_id(
@@ -325,11 +351,18 @@ class ModelsTable:
items = query.all()
model_ids = [model.id for model, _ in items]
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
models = []
for model, user in items:
models.append(
ModelUserResponse(
**self._to_model_model(model, db=db).model_dump(),
**self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -356,7 +389,18 @@ class ModelsTable:
try:
with get_db_context(db) as db:
models = db.query(Model).filter(Model.id.in_(ids)).all()
return [self._to_model_model(model, db=db) for model in models]
model_ids = [model.id for model in models]
grants_map = AccessGrants.get_grants_by_resources(
"model", model_ids, db=db
)
return [
self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
)
for model in models
]
except Exception:
return []
@@ -465,9 +509,18 @@ class ModelsTable:
db.commit()
all_models = db.query(Model).all()
model_ids = [model.id for model in all_models]
grants_map = AccessGrants.get_grants_by_resources(
"model", model_ids, db=db
)
return [
self._to_model_model(model, db=db)
for model in db.query(Model).all()
self._to_model_model(
model,
access_grants=grants_map.get(model.id, []),
db=db,
)
for model in all_models
]
except Exception as e:
log.exception(f"Error syncing models for user {user_id}: {e}")
+35 -5
View File
@@ -93,9 +93,18 @@ class NoteTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
def _to_note_model(
self,
note: Note,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> NoteModel:
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
note_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(note_data["id"], db=db)
)
return NoteModel.model_validate(note_data)
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
@@ -142,7 +151,14 @@ class NoteTable:
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [self._to_note_model(note, db=db) for note in notes]
note_ids = [note.id for note in notes]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
return [
self._to_note_model(
note, access_grants=grants_map.get(note.id, []), db=db
)
for note in notes
]
def search_notes(
self,
@@ -227,11 +243,18 @@ class NoteTable:
items = query.all()
note_ids = [note.id for note, _ in items]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**self._to_note_model(note, db=db).model_dump(),
**self._to_note_model(
note,
access_grants=grants_map.get(note.id, []),
db=db,
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -266,7 +289,14 @@ class NoteTable:
query = query.limit(limit)
notes = query.all()
return [self._to_note_model(note, db=db) for note in notes]
note_ids = [note.id for note in notes]
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
return [
self._to_note_model(
note, access_grants=grants_map.get(note.id, []), db=db
)
for note in notes
]
def get_note_by_id(
self, id: str, db: Optional[Session] = None
@@ -188,6 +188,7 @@ class OAuthSessionTable:
session = (
db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
.order_by(OAuthSession.created_at.desc())
.first()
)
if session:
+55 -36
View File
@@ -97,12 +97,19 @@ class PromptsTable:
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
def _to_prompt_model(
self, prompt: Prompt, db: Optional[Session] = None
self,
prompt: Prompt,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> PromptModel:
prompt_data = PromptModel.model_validate(prompt).model_dump(
exclude={"access_grants"}
)
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
prompt_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(prompt_data["id"], db=db)
)
return PromptModel.model_validate(prompt_data)
def insert_new_prompt(
@@ -206,9 +213,13 @@ class PromptsTable:
)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
prompt_ids = [prompt.id for prompt in all_prompts]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources(
"prompt", prompt_ids, db=db
)
prompts = []
for prompt in all_prompts:
@@ -216,7 +227,11 @@ class PromptsTable:
prompts.append(
PromptUserResponse.model_validate(
{
**self._to_prompt_model(prompt, db=db).model_dump(),
**self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -259,7 +274,6 @@ class PromptsTable:
# Join with User table for user filtering and sorting
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = query.filter(Prompt.is_active == True)
if filter:
query_key = filter.get("query")
@@ -330,11 +344,20 @@ class PromptsTable:
items = query.all()
prompt_ids = [prompt.id for prompt, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"prompt", prompt_ids, db=db
)
prompts = []
for prompt, user in items:
prompts.append(
PromptUserResponse(
**self._to_prompt_model(prompt, db=db).model_dump(),
**self._to_prompt_model(
prompt,
access_grants=grants_map.get(prompt.id, []),
db=db,
).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
@@ -562,43 +585,24 @@ class PromptsTable:
except Exception:
return None
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Soft delete a prompt by setting is_active to False."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(command=command).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.updated_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Soft delete a prompt by setting is_active to False."""
def toggle_prompt_active(
self, prompt_id: str, db: Optional[Session] = None
) -> Optional[PromptModel]:
"""Toggle the is_active flag on a prompt."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
prompt.is_active = False
prompt.is_active = not prompt.is_active
prompt.updated_at = int(time.time())
db.commit()
return True
return False
db.refresh(prompt)
return self._to_prompt_model(prompt, db=db)
return None
except Exception:
return False
return None
def hard_delete_prompt_by_command(
def delete_prompt_by_command(
self, command: str, db: Optional[Session] = None
) -> bool:
"""Permanently delete a prompt and its history."""
@@ -609,8 +613,23 @@ class PromptsTable:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
# Delete prompt
db.query(Prompt).filter_by(command=command).delete()
db.delete(prompt)
db.commit()
return True
return False
except Exception:
return False
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
"""Permanently delete a prompt and its history."""
try:
with get_db_context(db) as db:
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
if prompt:
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
db.delete(prompt)
db.commit()
return True
return False
+28 -4
View File
@@ -110,11 +110,20 @@ class SkillsTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
def _to_skill_model(
self,
skill: Skill,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> SkillModel:
skill_data = SkillModel.model_validate(skill).model_dump(
exclude={"access_grants"}
)
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
skill_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(skill_data["id"], db=db)
)
return SkillModel.model_validate(skill_data)
def insert_new_skill(
@@ -172,9 +181,11 @@ class SkillsTable:
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
user_ids = list(set(skill.user_id for skill in all_skills))
skill_ids = [skill.id for skill in all_skills]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("skill", skill_ids, db=db)
skills = []
for skill in all_skills:
@@ -182,7 +193,11 @@ class SkillsTable:
skills.append(
SkillUserModel.model_validate(
{
**self._to_skill_model(skill, db=db).model_dump(),
**self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -267,11 +282,20 @@ class SkillsTable:
items = query.all()
skill_ids = [skill.id for skill, _ in items]
grants_map = AccessGrants.get_grants_by_resources(
"skill", skill_ids, db=db
)
skills = []
for skill, user in items:
skills.append(
SkillUserResponse(
**self._to_skill_model(skill, db=db).model_dump(),
**self._to_skill_model(
skill,
access_grants=grants_map.get(skill.id, []),
db=db,
).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
+32 -8
View File
@@ -2,7 +2,7 @@ import logging
import time
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, defer
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
@@ -100,9 +100,18 @@ class ToolsTable:
) -> list[AccessGrantModel]:
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
def _to_tool_model(
self,
tool: Tool,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[Session] = None,
) -> ToolModel:
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
tool_data["access_grants"] = (
access_grants
if access_grants is not None
else self._get_access_grants(tool_data["id"], db=db)
)
return ToolModel.model_validate(tool_data)
def insert_new_tool(
@@ -147,14 +156,21 @@ class ToolsTable:
except Exception:
return None
def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
def get_tools(
self, defer_content: bool = False, db: Optional[Session] = None
) -> list[ToolUserModel]:
with get_db_context(db) as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
query = db.query(Tool).order_by(Tool.updated_at.desc())
if defer_content:
query = query.options(defer(Tool.content), defer(Tool.specs))
all_tools = query.all()
user_ids = list(set(tool.user_id for tool in all_tools))
tool_ids = [tool.id for tool in all_tools]
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {user.id: user for user in users}
grants_map = AccessGrants.get_grants_by_resources("tool", tool_ids, db=db)
tools = []
for tool in all_tools:
@@ -162,7 +178,11 @@ class ToolsTable:
tools.append(
ToolUserModel.model_validate(
{
**self._to_tool_model(tool, db=db).model_dump(),
**self._to_tool_model(
tool,
access_grants=grants_map.get(tool.id, []),
db=db,
).model_dump(),
"user": user.model_dump() if user else None,
}
)
@@ -170,9 +190,13 @@ class ToolsTable:
return tools
def get_tools_by_user_id(
self, user_id: str, permission: str = "write", db: Optional[Session] = None
self,
user_id: str,
permission: str = "write",
defer_content: bool = False,
db: Optional[Session] = None,
) -> list[ToolUserModel]:
tools = self.get_tools(db=db)
tools = self.get_tools(defer_content=defer_content, db=db)
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
}
+48 -11
View File
@@ -88,6 +88,14 @@ def get_content_from_url(request, url: str) -> str:
return content, docs
CHUNK_HASH_KEY = "_chunk_hash"
def _content_hash(text: str) -> str:
"""SHA-256 hash of text, used as a stable chunk identifier for RRF dedup."""
return hashlib.sha256(text.encode()).hexdigest()
class VectorSearchRetriever(BaseRetriever):
collection_name: Any
embedding_function: Any
@@ -126,9 +134,11 @@ class VectorSearchRetriever(BaseRetriever):
results = []
for idx in range(len(ids)):
metadata = metadatas[idx]
metadata[CHUNK_HASH_KEY] = _content_hash(documents[idx])
results.append(
Document(
metadata=metadatas[idx],
metadata=metadata,
page_content=documents[idx],
)
)
@@ -240,15 +250,21 @@ async def query_doc_with_hybrid_search(
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
original_texts = collection_result.documents[0]
bm25_metadatas = [
{**meta, CHUNK_HASH_KEY: _content_hash(original_texts[idx])}
for idx, meta in enumerate(collection_result.metadatas[0])
]
bm25_texts = (
get_enriched_texts(collection_result)
if enable_enriched_texts
else collection_result.documents[0]
else original_texts
)
bm25_retriever = BM25Retriever.from_texts(
texts=bm25_texts,
metadatas=collection_result.metadatas[0],
metadatas=bm25_metadatas,
)
bm25_retriever.k = k
@@ -258,18 +274,24 @@ async def query_doc_with_hybrid_search(
top_k=k,
)
# Use CHUNK_HASH_KEY for dedup so enriched BM25 texts don't defeat RRF
if hybrid_bm25_weight <= 0:
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_search_retriever], weights=[1.0]
retrievers=[vector_search_retriever],
weights=[1.0],
id_key=CHUNK_HASH_KEY,
)
elif hybrid_bm25_weight >= 1:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever], weights=[1.0]
retrievers=[bm25_retriever],
weights=[1.0],
id_key=CHUNK_HASH_KEY,
)
else:
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_search_retriever],
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
id_key=CHUNK_HASH_KEY,
)
compressor = RerankCompressor(
@@ -292,7 +314,7 @@ async def query_doc_with_hybrid_search(
# retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker
if k < k_reranker:
sorted_items = sorted(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True
)
sorted_items = sorted_items[:k]
@@ -803,6 +825,7 @@ def get_embedding_function(
embedding_batch_size,
azure_api_version=None,
enable_async=True,
concurrent_requests=0,
) -> Awaitable:
if embedding_engine == "":
# Sentence transformers: CPU-bound sync operation
@@ -844,11 +867,25 @@ def get_embedding_function(
log.debug(
f"generate_multiple_async: Processing {len(batches)} batches in parallel"
)
# Execute all batches in parallel
tasks = [
embedding_function(batch, prefix=prefix, user=user)
for batch in batches
]
# Use semaphore to limit concurrent embedding API requests
# 0 = unlimited (no semaphore)
if concurrent_requests:
semaphore = asyncio.Semaphore(concurrent_requests)
async def generate_batch_with_semaphore(batch):
async with semaphore:
return await embedding_function(
batch, prefix=prefix, user=user
)
tasks = [
generate_batch_with_semaphore(batch) for batch in batches
]
else:
tasks = [
embedding_function(batch, prefix=prefix, user=user)
for batch in batches
]
batch_results = await asyncio.gather(*tasks)
else:
log.debug(
+73
View File
@@ -0,0 +1,73 @@
import logging
from typing import Optional, List
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
log = logging.getLogger(__name__)
def search_youcom(
api_key: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
language: str = "EN",
) -> List[SearchResult]:
"""Search using You.com's YDC Index API and return the results as a list of SearchResult objects.
Args:
api_key (str): A You.com API key
query (str): The query to search for
count (int): Maximum number of results to return
filter_list (list[str], optional): Domain filter list
language (str): Language code for search results (default: "EN")
"""
url = "https://ydc-index.io/v1/search"
headers = {
"Accept": "application/json",
"X-API-KEY": api_key,
}
params = {
"query": query,
"count": count,
"language": language,
}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()
results = json_response.get("results", {}).get("web", [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["url"],
title=result.get("title"),
snippet=_build_snippet(result),
)
for result in results[:count]
]
def _build_snippet(result: dict) -> str:
"""Combine the description and snippets list into a single string.
The You.com API returns a short ``description`` plus a ``snippets``
list with richer passages. Merging them gives downstream retrieval
(embedding, BM25, bypass-loader context) the most content to work with.
"""
parts: list[str] = []
description = result.get("description")
if description:
parts.append(description)
snippets = result.get("snippets")
if snippets and isinstance(snippets, list):
parts.extend(snippets)
return "\n\n".join(parts)
+13 -11
View File
@@ -700,8 +700,9 @@ async def signup_handler(
Returns the newly created UserModel.
Raises HTTPException on failure.
"""
has_users = Users.has_users(db=db)
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
# Insert with default role first to avoid TOCTOU race on first signup.
# If has_users() is checked before insert, concurrent requests during
# first-user registration can all see an empty table and each get admin.
hashed = get_password_hash(password)
user = Auths.insert_new_auth(
@@ -709,12 +710,19 @@ async def signup_handler(
password=hashed,
name=name,
profile_image_url=profile_image_url,
role=role,
role=request.app.state.config.DEFAULT_USER_ROLE,
db=db,
)
if not user:
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
# Atomically check if this is the only user *after* the insert.
# Only the single user present at this point should become admin.
if Users.get_num_users(db=db) == 1:
Users.update_user_role_by_id(user.id, "admin", db=db)
user = Users.get_user_by_id(user.id, db=db)
request.app.state.config.ENABLE_SIGNUP = False
if request.app.state.config.WEBHOOK_URL:
await post_webhook(
request.app.state.WEBUI_NAME,
@@ -727,10 +735,6 @@ async def signup_handler(
},
)
if not has_users:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
apply_default_group_assignment(
request.app.state.config.DEFAULT_GROUP_ID,
user.id,
@@ -1153,8 +1157,6 @@ async def update_ldap_server(
"host",
"attribute_for_mail",
"attribute_for_username",
"app_dn",
"app_dn_password",
"search_base",
]
for key in required_fields:
@@ -1169,8 +1171,8 @@ async def update_ldap_server(
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username
)
request.app.state.config.LDAP_APP_DN = form_data.app_dn
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ""
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ""
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
+16 -29
View File
@@ -723,10 +723,7 @@ async def get_chat_list_by_folder_id(
async def get_user_pinned_chats(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
return [
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_pinned_chats_by_user_id(user.id, db=db)
]
return Chats.get_pinned_chats_by_user_id(user.id, db=db)
############################
@@ -821,18 +818,13 @@ async def get_archived_session_user_chat_list(
if direction:
filter["direction"] = direction
chat_list = [
ChatTitleIdResponse(**chat.model_dump())
for chat in Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
return Chats.get_archived_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
############################
@@ -887,18 +879,13 @@ async def get_shared_session_user_chat_list(
if direction:
filter["direction"] = direction
chat_list = [
SharedChatResponse(**chat.model_dump())
for chat in Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
return Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
############################
@@ -1147,7 +1134,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.get_chat_by_id(id, db=db)
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+8
View File
@@ -467,6 +467,8 @@ class ModelsConfigForm(BaseModel):
DEFAULT_MODELS: Optional[str]
DEFAULT_PINNED_MODELS: Optional[str]
MODEL_ORDER_LIST: Optional[list[str]]
DEFAULT_MODEL_METADATA: Optional[dict] = None
DEFAULT_MODEL_PARAMS: Optional[dict] = None
@router.get("/models", response_model=ModelsConfigForm)
@@ -475,6 +477,8 @@ async def get_models_config(request: Request, user=Depends(get_admin_user)):
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
}
@@ -485,10 +489,14 @@ async def set_models_config(
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA
request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS
return {
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
}
+42 -9
View File
@@ -47,6 +47,7 @@ from open_webui.routers.audio import transcribe
from open_webui.storage.provider import Storage
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel
@@ -120,6 +121,27 @@ def has_access_to_file(
############################
def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool:
"""Check if a file is likely a text file by reading a chunk and validating UTF-8.
This catches files whose extensions are mis-mapped by mimetypes/browsers
(e.g. TypeScript .ts → video/mp2t) without maintaining an extension whitelist.
"""
try:
resolved = Storage.get_file(file_path)
with open(resolved, "rb") as f:
chunk = f.read(chunk_size)
if not chunk:
return False
# Null bytes are a strong indicator of binary content
if b"\x00" in chunk:
return False
chunk.decode("utf-8")
return True
except (UnicodeDecodeError, Exception):
return False
def process_uploaded_file(
request,
file,
@@ -131,14 +153,19 @@ def process_uploaded_file(
):
def _process_handler(db_session):
try:
if file.content_type:
content_type = file.content_type
# Detect mis-labeled text files (e.g. .ts → video/mp2t)
if content_type and content_type.startswith(("image/", "video/")):
if _is_text_file(file_path):
content_type = "text/plain"
if 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(stt_supported_content_types, content_type):
file_path_processed = Storage.get_file(file_path)
result = transcribe(
request, file_path_processed, file_metadata, user
@@ -152,7 +179,7 @@ def process_uploaded_file(
user=user,
db=db_session,
)
elif (not file.content_type.startswith(("image/", "video/"))) or (
elif (not content_type.startswith(("image/", "video/"))) or (
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
):
process_file(
@@ -163,7 +190,7 @@ def process_uploaded_file(
)
else:
raise Exception(
f"File type {file.content_type} is not supported for processing"
f"File type {content_type} is not supported for processing"
)
else:
log.info(
@@ -362,7 +389,7 @@ async def list_files(
content: bool = Query(True),
db: Session = Depends(get_session),
):
if user.role == "admin":
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
files = Files.get_files(db=db)
else:
files = Files.get_files_by_user_id(user.id, db=db)
@@ -398,8 +425,10 @@ async def search_files(
Search for files by filename with support for wildcard patterns.
Uses SQL-based filtering with pagination for better performance.
"""
# Determine user_id: null for admin (search all), user.id for regular users
user_id = None if user.role == "admin" else user.id
# Determine user_id: null for admin with bypass (search all), user.id otherwise
user_id = (
None if (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
)
# Use optimized database query with pagination
files = Files.search_files(
@@ -689,6 +718,8 @@ async def get_file_content_by_id(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
log.error("Error getting file content")
@@ -740,6 +771,8 @@ async def get_html_file_content_by_id(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
except HTTPException as e:
raise e
except Exception as e:
log.exception(e)
log.error("Error getting file content")
+8 -2
View File
@@ -641,7 +641,10 @@ async def image_generations(
for image in res["data"]:
if image_url := image.get("url", None):
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = get_image_data(
image_url,
{k: v for k, v in headers.items() if k != "Content-Type"},
)
else:
image_data, content_type = get_image_data(image["b64_json"])
@@ -993,7 +996,10 @@ async def image_edits(
images = []
for image in res["data"]:
if image_url := image.get("url", None):
image_data, content_type = get_image_data(image_url, headers)
image_data, content_type = get_image_data(
image_url,
{k: v for k, v in headers.items() if k != "Content-Type"},
)
else:
image_data, content_type = get_image_data(image["b64_json"])
+2 -1
View File
@@ -512,6 +512,7 @@ async def update_model_by_id(
class ModelAccessGrantsForm(BaseModel):
id: str
name: Optional[str] = None
access_grants: list[dict]
@@ -535,7 +536,7 @@ async def update_model_access_by_id(
model = Models.insert_new_model(
ModelForm(
id=form_data.id,
name=form_data.id,
name=form_data.name or form_data.id,
meta=ModelMeta(),
params=ModelParams(),
),
+13 -1
View File
@@ -36,6 +36,14 @@ log = logging.getLogger(__name__)
router = APIRouter()
def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optional[dict]:
if not data:
return data
md = (data.get("content") or {}).get("md") or ""
return {"content": {"md": md[:max_length]}}
############################
# GetNotes
############################
@@ -82,6 +90,7 @@ async def get_notes(
NoteUserResponse(
**{
**note.model_dump(),
"data": _truncate_note_data(note.data),
"user": UserResponse(**users[note.user_id].model_dump()),
}
)
@@ -135,7 +144,10 @@ async def search_notes(
filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
result = Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
for note in result.items:
note.data = _truncate_note_data(note.data)
return result
############################
+30 -17
View File
@@ -57,6 +57,7 @@ from open_webui.utils.misc import (
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
log = logging.getLogger(__name__)
@@ -91,6 +92,12 @@ async def send_get_request(url, key=None, user: UserModel = None):
return None
async def get_models_request(url, key=None, user: UserModel = None):
if is_anthropic_url(url):
return await get_anthropic_models(url, key, user=user)
return await send_get_request(f"{url}/models", key, user=user)
def openai_reasoning_model_handler(payload):
"""
Handle reasoning model specific parameters
@@ -365,13 +372,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
request_tasks = []
for idx, url in enumerate(api_base_urls):
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
request_tasks.append(
send_get_request(
f"{url}/models",
api_keys[idx],
user=user,
)
)
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
else:
api_config = api_configs.get(
str(idx),
@@ -384,11 +385,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
if enable:
if len(model_ids) == 0:
request_tasks.append(
send_get_request(
f"{url}/models",
api_keys[idx],
user=user,
)
get_models_request(url, api_keys[idx], user=user)
)
else:
model_list = {
@@ -594,6 +591,10 @@ async def get_models(
"data": api_config.get("model_ids", []) or [],
"object": "list",
}
elif is_anthropic_url(url):
models = await get_anthropic_models(url, key, user=user)
if models is None:
raise Exception("Failed to connect to Anthropic API")
else:
async with session.get(
f"{url}/models",
@@ -602,7 +603,6 @@ async def get_models(
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
error_detail = f"HTTP Error: {r.status}"
try:
res = await r.json()
@@ -614,9 +614,7 @@ async def get_models(
response_data = await r.json()
# Check if we're calling OpenAI API based on the URL
if "api.openai.com" in url:
# Filter models according to the specified conditions
response_data["data"] = [
model
for model in response_data.get("data", [])
@@ -707,6 +705,15 @@ async def verify_connection(
)
return response_data
elif is_anthropic_url(url):
result = await get_anthropic_models(url, key)
if result is None:
raise HTTPException(
status_code=500, detail="Failed to connect to Anthropic API"
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
else:
async with session.get(
f"{url}/models",
@@ -1181,7 +1188,10 @@ async def embeddings(request: Request, form_data: dict, user):
request, url, key, api_config, user=user
)
try:
session = aiohttp.ClientSession(trust_env=True)
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
r = await session.request(
method="POST",
url=f"{url}/embeddings",
@@ -1408,7 +1418,10 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
else:
request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True)
session = aiohttp.ClientSession(
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
)
r = await session.request(
method=request.method,
url=request_url,
+42
View File
@@ -497,6 +497,48 @@ async def update_prompt_access_by_id(
return Prompts.get_prompt_by_id(prompt_id, db=db)
############################
# TogglePromptActiveById
############################
@router.post("/id/{prompt_id}/toggle", response_model=Optional[PromptModel])
async def toggle_prompt_active(
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
if not prompt:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
prompt.user_id != user.id
and not AccessGrants.has_access(
user_id=user.id,
resource_type="prompt",
resource_id=prompt.id,
permission="write",
db=db,
)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
result = Prompts.toggle_prompt_active(prompt.id, db=db)
if result:
return result
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
# DeletePromptById
############################
+30 -3
View File
@@ -77,6 +77,7 @@ from open_webui.retrieval.web.sougou import search_sougou
from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.external import search_external
from open_webui.retrieval.web.yandex import search_yandex
from open_webui.retrieval.web.ydc import search_youcom
from open_webui.retrieval.utils import (
get_content_from_url,
@@ -270,6 +271,7 @@ async def get_status(request: Request):
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
}
@@ -281,6 +283,7 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
"RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
"openai_config": {
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
"key": request.app.state.config.RAG_OPENAI_API_KEY,
@@ -321,6 +324,7 @@ class EmbeddingModelUpdateForm(BaseModel):
RAG_EMBEDDING_MODEL: str
RAG_EMBEDDING_BATCH_SIZE: Optional[int] = 1
ENABLE_ASYNC_EMBEDDING: Optional[bool] = True
RAG_EMBEDDING_CONCURRENT_REQUESTS: Optional[int] = 0
def unload_embedding_model(request: Request):
@@ -355,6 +359,9 @@ async def update_embedding_config(
request.app.state.config.ENABLE_ASYNC_EMBEDDING = (
form_data.ENABLE_ASYNC_EMBEDDING
)
request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = (
form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS
)
if request.app.state.config.RAG_EMBEDDING_ENGINE in [
"ollama",
@@ -422,6 +429,7 @@ async def update_embedding_config(
else None
),
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
)
return {
@@ -430,6 +438,7 @@ async def update_embedding_config(
"RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
"openai_config": {
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
"key": request.app.state.config.RAG_OPENAI_API_KEY,
@@ -583,6 +592,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
"YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY,
},
}
@@ -649,6 +659,7 @@ class WebConfig(BaseModel):
YANDEX_WEB_SEARCH_URL: Optional[str] = None
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
YOUCOM_API_KEY: Optional[str] = None
class ConfigForm(BaseModel):
@@ -1205,6 +1216,7 @@ async def update_rag_config(
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
form_data.web.YANDEX_WEB_SEARCH_CONFIG
)
request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY
return {
"status": True,
@@ -1332,6 +1344,7 @@ async def update_rag_config(
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
"YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY,
},
}
@@ -1589,6 +1602,7 @@ def save_docs_to_vector_db(
else None
),
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
)
# Run async embedding in sync context using the main event loop
@@ -1746,6 +1760,7 @@ def process_file(
DOCLING_API_KEY=request.app.state.config.DOCLING_API_KEY,
DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE=request.app.state.config.PDF_LOADER_MODE,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
DOCUMENT_INTELLIGENCE_MODEL=request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
@@ -1933,6 +1948,9 @@ async def process_web(
request: Request,
form_data: ProcessUrlForm,
process: bool = Query(True, description="Whether to process and save the content"),
overwrite: bool = Query(
True, description="Whether to overwrite existing collection"
),
user=Depends(get_verified_user),
):
try:
@@ -1952,7 +1970,7 @@ async def process_web(
request,
docs,
collection_name,
overwrite=True,
overwrite=overwrite,
user=user,
)
else:
@@ -2288,6 +2306,13 @@ def search_web(
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
user=user,
)
elif engine == "youcom":
return search_youcom(
request.app.state.config.YOUCOM_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No search engine API key found in environment variables")
@@ -2327,7 +2352,7 @@ async def process_web_search(
# Limited concurrency with semaphore
semaphore = asyncio.Semaphore(concurrent_limit)
async def search_with_limit(query):
async def search_query_with_semaphore(query):
async with semaphore:
return await run_in_threadpool(
search_web,
@@ -2337,7 +2362,9 @@ async def process_web_search(
user,
)
search_tasks = [search_with_limit(query) for query in form_data.queries]
search_tasks = [
search_query_with_semaphore(query) for query in form_data.queries
]
else:
# Unlimited parallel execution (previous behavior)
search_tasks = [
+13 -4
View File
@@ -523,13 +523,17 @@ async def get_schemas():
@router.get("/Users", response_model=SCIMListResponse)
async def get_users(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
startIndex: int = Query(1),
count: int = Query(20),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""List SCIM Users"""
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
startIndex = max(1, startIndex)
count = max(0, min(100, count))
skip = startIndex - 1
limit = count
@@ -794,13 +798,18 @@ async def delete_user(
@router.get("/Groups", response_model=SCIMListResponse)
async def get_groups(
request: Request,
startIndex: int = Query(1, ge=1),
count: int = Query(20, ge=1, le=100),
startIndex: int = Query(1),
count: int = Query(20),
filter: Optional[str] = None,
_: bool = Depends(get_scim_auth),
db: Session = Depends(get_session),
):
"""List SCIM Groups"""
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
startIndex = max(1, startIndex)
count = max(0, min(100, count))
# Get all groups
groups_list = Groups.get_all_groups(db=db)
+39 -20
View File
@@ -64,13 +64,19 @@ async def get_tools(
tools = []
# Local Tools
for tool in Tools.get_tools(db=db):
tool_module = get_tool_module(request, tool.id)
for tool in Tools.get_tools(defer_content=True, db=db):
tool_module = (
request.app.state.TOOLS.get(tool.id)
if hasattr(request.app.state, "TOOLS")
else None
)
tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": hasattr(tool_module, "UserValves"),
"has_user_valves": (
hasattr(tool_module, "UserValves") if tool_module else False
),
}
)
)
@@ -196,27 +202,40 @@ async def get_tool_list(
user=Depends(get_verified_user), db: Session = Depends(get_session)
):
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
tools = Tools.get_tools(db=db)
tools = Tools.get_tools(defer_content=True, db=db)
else:
tools = Tools.get_tools_by_user_id(user.id, "read", db=db)
tools = Tools.get_tools_by_user_id(user.id, "read", defer_content=True, db=db)
return [
ToolAccessResponse(
**tool.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or AccessGrants.has_access(
user_id=user.id,
resource_type="tool",
resource_id=tool.id,
permission="write",
db=db,
user_group_ids = {
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
}
result = []
for tool in tools:
has_write = (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == tool.user_id
or any(
g.permission == "write"
and (
(
g.principal_type == "user"
and (g.principal_id == user.id or g.principal_id == "*")
)
or (
g.principal_type == "group" and g.principal_id in user_group_ids
)
)
),
for g in tool.access_grants
)
)
for tool in tools
]
result.append(
ToolAccessResponse(
**tool.model_dump(),
write_access=has_write,
)
)
return result
############################
+1
View File
@@ -202,6 +202,7 @@ class ChatPermissions(BaseModel):
system_prompt: bool = True
params: bool = True
file_upload: bool = True
web_upload: bool = True
delete: bool = True
delete_message: bool = True
continue_response: bool = True
+7 -3
View File
@@ -511,6 +511,8 @@ async def channel_events(sid, data):
async def ydoc_document_join(sid, data):
"""Handle user joining a document"""
user = SESSION_POOL.get(sid)
if not user:
return
try:
document_id = data["document_id"]
@@ -683,11 +685,13 @@ async def yjs_document_update(sid, data):
skip_sid=sid,
)
user = SESSION_POOL.get(sid)
if not user:
return
async def debounced_save():
await asyncio.sleep(0.5)
await document_save_handler(
document_id, data.get("data", {}), SESSION_POOL.get(sid)
)
await document_save_handler(document_id, data.get("data", {}), user)
if data.get("data"):
await create_task(REDIS, debounced_save(), document_id)
@@ -1,91 +0,0 @@
from test.util.abstract_integration_test import AbstractPostgresTest
from test.util.mock_user import mock_webui_user
class TestPrompts(AbstractPostgresTest):
BASE_PATH = "/api/v1/prompts"
def test_prompts(self):
# Get all prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
assert len(response.json()) == 0
# Create a two new prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url("/create"),
json={
"command": "/my-command",
"title": "Hello World",
"content": "description",
},
)
assert response.status_code == 200
with mock_webui_user(id="3"):
response = self.fast_api_client.post(
self.create_url("/create"),
json={
"command": "/my-command2",
"title": "Hello World 2",
"content": "description 2",
},
)
assert response.status_code == 200
# Get all prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
assert len(response.json()) == 2
# Get prompt by command
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/command/my-command"))
assert response.status_code == 200
data = response.json()
assert data["command"] == "/my-command"
assert data["title"] == "Hello World"
assert data["content"] == "description"
assert data["user_id"] == "2"
# Update prompt
with mock_webui_user(id="2"):
response = self.fast_api_client.post(
self.create_url("/command/my-command2/update"),
json={
"command": "irrelevant for request",
"title": "Hello World Updated",
"content": "description Updated",
},
)
assert response.status_code == 200
data = response.json()
assert data["command"] == "/my-command2"
assert data["title"] == "Hello World Updated"
assert data["content"] == "description Updated"
assert data["user_id"] == "3"
# Get prompt by command
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/command/my-command2"))
assert response.status_code == 200
data = response.json()
assert data["command"] == "/my-command2"
assert data["title"] == "Hello World Updated"
assert data["content"] == "description Updated"
assert data["user_id"] == "3"
# Delete prompt
with mock_webui_user(id="2"):
response = self.fast_api_client.delete(
self.create_url("/command/my-command/delete")
)
assert response.status_code == 200
# Get all prompts
with mock_webui_user(id="2"):
response = self.fast_api_client.get(self.create_url("/"))
assert response.status_code == 200
assert len(response.json()) == 1
+75
View File
@@ -36,6 +36,8 @@ from open_webui.models.chats import Chats
from open_webui.models.channels import Channels, ChannelMember, Channel
from open_webui.models.messages import Messages, Message
from open_webui.models.groups import Groups
from open_webui.models.memories import Memories
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.utils.sanitize import sanitize_code
log = logging.getLogger(__name__)
@@ -634,6 +636,79 @@ async def replace_memory_content(
return json.dumps({"error": str(e)})
async def delete_memory(
memory_id: str,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Delete a memory by its ID.
:param memory_id: The ID of the memory to delete
:return: Confirmation that the memory was deleted
"""
if __request__ is None:
return json.dumps({"error": "Request context not available"})
try:
user = UserModel(**__user__) if __user__ else None
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id)
if result:
VECTOR_DB_CLIENT.delete(
collection_name=f"user-memory-{user.id}", ids=[memory_id]
)
return json.dumps(
{"status": "success", "message": f"Memory {memory_id} deleted"},
ensure_ascii=False,
)
else:
return json.dumps({"error": "Memory not found or access denied"})
except Exception as e:
log.exception(f"delete_memory error: {e}")
return json.dumps({"error": str(e)})
async def list_memories(
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
List all stored memories for the user.
:return: JSON list of all memories with id, content, and dates
"""
if __request__ is None:
return json.dumps({"error": "Request context not available"})
try:
user = UserModel(**__user__) if __user__ else None
memories = Memories.get_memories_by_user_id(user.id)
if memories:
result = [
{
"id": m.id,
"content": m.content,
"created_at": time.strftime(
"%Y-%m-%d %H:%M", time.localtime(m.created_at)
),
"updated_at": time.strftime(
"%Y-%m-%d %H:%M", time.localtime(m.updated_at)
),
}
for m in memories
]
return json.dumps(result, ensure_ascii=False)
else:
return json.dumps([])
except Exception as e:
log.exception(f"list_memories error: {e}")
return json.dumps({"error": str(e)})
# =============================================================================
# NOTES TOOLS
# =============================================================================
+534
View File
@@ -0,0 +1,534 @@
import json
import logging
import aiohttp
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
from open_webui.models.users import UserModel
from open_webui.utils.headers import include_user_info_headers
log = logging.getLogger(__name__)
def is_anthropic_url(url: str) -> bool:
"""Check if the URL is an Anthropic API endpoint."""
return "api.anthropic.com" in url
async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> dict:
"""
Fetch models from Anthropic's /v1/models endpoint with pagination.
Normalizes the response to OpenAI format.
"""
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
all_models = []
after_id = None
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
headers = {
"x-api-key": key,
"anthropic-version": "2023-06-01",
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
while True:
params = {"limit": 1000}
if after_id:
params["after_id"] = after_id
async with session.get(
f"{url}/models",
headers=headers,
params=params,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
if response.status != 200:
error_detail = f"HTTP Error: {response.status}"
try:
res = await response.json()
if "error" in res:
error_detail = f"External Error: {res['error']}"
except Exception:
pass
return {"object": "list", "data": [], "error": error_detail}
data = await response.json()
for model in data.get("data", []):
all_models.append(
{
"id": model.get("id"),
"object": "model",
"created": 0,
"owned_by": "anthropic",
"name": model.get("display_name", model.get("id")),
}
)
if not data.get("has_more", False):
break
after_id = data.get("last_id")
except Exception as e:
log.error(f"Anthropic connection error: {e}")
return None
return {"object": "list", "data": all_models}
##############################
#
# Anthropic Messages API Conversion Utilities
#
##############################
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
"""
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
Anthropic format:
{model, messages: [{role, content}], system, max_tokens, ...}
OpenAI format:
{model, messages: [{role, content}], max_tokens, ...}
"""
openai_payload = {}
# Model
openai_payload["model"] = anthropic_payload.get("model", "")
# Build messages list
messages = []
# System prompt (Anthropic has it as top-level, OpenAI as a system message)
system = anthropic_payload.get("system")
if system:
if isinstance(system, str):
messages.append({"role": "system", "content": system})
elif isinstance(system, list):
# Anthropic supports system as list of content blocks
text_parts = []
for block in system:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif isinstance(block, str):
text_parts.append(block)
messages.append({"role": "system", "content": "\n".join(text_parts)})
# Convert messages
for msg in anthropic_payload.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content")
if isinstance(content, str):
messages.append({"role": role, "content": content})
elif isinstance(content, list):
# Convert Anthropic content blocks to OpenAI format
openai_content = []
tool_calls = []
for block in content:
block_type = block.get("type", "text")
if block_type == "text":
openai_content.append(
{
"type": "text",
"text": block.get("text", ""),
}
)
elif block_type == "image":
source = block.get("source", {})
if source.get("type") == "base64":
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
openai_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:{media_type};base64,{data}",
},
}
)
elif source.get("type") == "url":
openai_content.append(
{
"type": "image_url",
"image_url": {"url": source.get("url", "")},
}
)
elif block_type == "tool_use":
tool_calls.append(
{
"id": block.get("id", ""),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": (
json.dumps(block.get("input", {}))
if isinstance(block.get("input"), dict)
else str(block.get("input", "{}"))
),
},
}
)
elif block_type == "tool_result":
# Tool results become separate tool messages in OpenAI format
tool_content = block.get("content", "")
if isinstance(tool_content, list):
tool_text_parts = []
for tc in tool_content:
if isinstance(tc, dict) and tc.get("type") == "text":
tool_text_parts.append(tc.get("text", ""))
tool_content = "\n".join(tool_text_parts)
# Propagate error status if present
if block.get("is_error"):
tool_content = f"Error: {tool_content}"
messages.append(
{
"role": "tool",
"tool_call_id": block.get("tool_use_id", ""),
"content": tool_content,
}
)
# Build the message
if tool_calls:
# Assistant message with tool calls
msg_dict = {"role": role}
if openai_content:
# If there's only text, flatten it
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
msg_dict["content"] = openai_content[0]["text"]
else:
msg_dict["content"] = openai_content
else:
msg_dict["content"] = ""
msg_dict["tool_calls"] = tool_calls
messages.append(msg_dict)
elif openai_content:
# If there's only a single text block, flatten it to a string
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
messages.append(
{"role": role, "content": openai_content[0]["text"]}
)
else:
messages.append({"role": role, "content": openai_content})
else:
messages.append({"role": role, "content": str(content) if content else ""})
openai_payload["messages"] = messages
# max_tokens
if "max_tokens" in anthropic_payload:
openai_payload["max_tokens"] = anthropic_payload["max_tokens"]
# Common parameters
for param in ("temperature", "top_p", "stop_sequences", "stream"):
if param in anthropic_payload:
if param == "stop_sequences":
openai_payload["stop"] = anthropic_payload[param]
else:
openai_payload[param] = anthropic_payload[param]
# Tools conversion: Anthropic → OpenAI
if "tools" in anthropic_payload:
openai_tools = []
for tool in anthropic_payload["tools"]:
openai_tools.append(
{
"type": "function",
"function": {
"name": tool.get("name", ""),
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {}),
},
}
)
openai_payload["tools"] = openai_tools
# tool_choice
if "tool_choice" in anthropic_payload:
tc = anthropic_payload["tool_choice"]
if isinstance(tc, dict):
tc_type = tc.get("type", "auto")
if tc_type == "auto":
openai_payload["tool_choice"] = "auto"
elif tc_type == "any":
openai_payload["tool_choice"] = "required"
elif tc_type == "tool":
openai_payload["tool_choice"] = {
"type": "function",
"function": {"name": tc.get("name", "")},
}
return openai_payload
def convert_openai_to_anthropic_response(
openai_response: dict, model: str = ""
) -> dict:
"""
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
"""
import uuid as _uuid
choice = {}
if openai_response.get("choices"):
choice = openai_response["choices"][0]
message = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
# Map finish_reason to stop_reason
stop_reason_map = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"content_filter": "end_turn",
}
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
# Build content blocks
content = []
msg_content = message.get("content")
if msg_content:
content.append({"type": "text", "text": msg_content})
# Tool calls → tool_use blocks
tool_calls = message.get("tool_calls", [])
for tc in tool_calls:
func = tc.get("function", {})
try:
tool_input = json.loads(func.get("arguments", "{}"))
except (json.JSONDecodeError, TypeError):
tool_input = {}
content.append(
{
"type": "tool_use",
"id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"),
"name": func.get("name", ""),
"input": tool_input,
}
)
# Usage
openai_usage = openai_response.get("usage", {})
usage = {
"input_tokens": openai_usage.get("prompt_tokens", 0),
"output_tokens": openai_usage.get("completion_tokens", 0),
}
return {
"id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"),
"type": "message",
"role": "assistant",
"content": content,
"model": model or openai_response.get("model", ""),
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": usage,
}
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ""):
"""
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
OpenAI sends: data: {"choices": [{"delta": {"content": "..."}}]}
Anthropic sends: event: content_block_delta\\ndata: {"type": "content_block_delta", ...}
Handles text content, tool calls, and mixed content with proper
multi-block indexing as required by Anthropic's streaming protocol.
"""
import uuid as _uuid
msg_id = f"msg_{_uuid.uuid4().hex[:24]}"
input_tokens = 0
output_tokens = 0
stop_reason = "end_turn"
# Track content blocks with a running index.
# Each text block or tool_use block gets its own index.
current_block_index = 0
text_block_open = False
# Track tool call state: maps OpenAI tool_call index -> Anthropic block index
# This allows handling multiple concurrent tool calls.
tool_call_blocks = {} # {openai_tc_index: anthropic_block_index}
tool_call_started = {} # {openai_tc_index: bool}
# Emit message_start
message_start = {
"type": "message_start",
"message": {
"id": msg_id,
"type": "message",
"role": "assistant",
"content": [],
"model": model,
"stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0},
},
}
yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode()
try:
async for chunk in openai_stream_generator:
if isinstance(chunk, bytes):
chunk = chunk.decode("utf-8", errors="ignore")
for line in chunk.strip().split("\n"):
line = line.strip()
if not line or not line.startswith("data:"):
continue
data_str = line[5:].strip()
if data_str == "[DONE]":
continue
if data_str == "{}":
continue
try:
data = json.loads(data_str)
except (json.JSONDecodeError, TypeError):
continue
choices = data.get("choices", [])
if not choices:
# Check for usage in the final chunk
if data.get("usage"):
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
output_tokens = data["usage"].get(
"completion_tokens", output_tokens
)
continue
delta = choices[0].get("delta", {})
finish_reason = choices[0].get("finish_reason")
# Update usage if present
if data.get("usage"):
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
output_tokens = data["usage"].get(
"completion_tokens", output_tokens
)
# --- Handle text content ---
content = delta.get("content")
if content is not None:
if not text_block_open:
# Start a new text content block
block_start = {
"type": "content_block_start",
"index": current_block_index,
"content_block": {"type": "text", "text": ""},
}
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
text_block_open = True
# Send text delta
block_delta = {
"type": "content_block_delta",
"index": current_block_index,
"delta": {"type": "text_delta", "text": content},
}
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
# --- Handle tool calls ---
tool_calls = delta.get("tool_calls")
if tool_calls:
# Close text block if one is open (text comes before tools)
if text_block_open:
block_stop = {
"type": "content_block_stop",
"index": current_block_index,
}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
text_block_open = False
current_block_index += 1
for tc in tool_calls:
tc_index = tc.get("index", 0)
if tc_index not in tool_call_started:
# First time seeing this tool call — emit content_block_start
tool_call_blocks[tc_index] = current_block_index
tool_call_started[tc_index] = True
# Extract tool call ID and name from the first chunk
tc_id = tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}")
tc_name = tc.get("function", {}).get("name", "")
block_start = {
"type": "content_block_start",
"index": current_block_index,
"content_block": {
"type": "tool_use",
"id": tc_id,
"name": tc_name,
"input": {},
},
}
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
current_block_index += 1
# Emit argument chunks as input_json_delta
args_chunk = tc.get("function", {}).get("arguments", "")
if args_chunk:
block_delta = {
"type": "content_block_delta",
"index": tool_call_blocks[tc_index],
"delta": {
"type": "input_json_delta",
"partial_json": args_chunk,
},
}
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
# --- Handle finish reason ---
if finish_reason is not None:
stop_reason_map = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
}
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
except Exception as e:
log.error(f"Error in Anthropic stream conversion: {e}")
# Close any open text block
if text_block_open:
block_stop = {"type": "content_block_stop", "index": current_block_index}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
# Close any open tool call blocks
for tc_index, block_index in tool_call_blocks.items():
block_stop = {"type": "content_block_stop", "index": block_index}
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
# Emit message_delta with stop reason
message_delta = {
"type": "message_delta",
"delta": {
"stop_reason": stop_reason,
"stop_sequence": None,
},
"usage": {"output_tokens": output_tokens},
}
yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode()
# Emit message_stop
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode()
+4
View File
@@ -290,6 +290,10 @@ async def get_current_user(
if token is None and "token" in request.cookies:
token = request.cookies.get("token")
# Fallback to request.state.token (set by middleware, e.g. for x-api-key)
if token is None and hasattr(request.state, "token") and request.state.token:
token = request.state.token.credentials
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
+41 -8
View File
@@ -12,13 +12,15 @@ from open_webui.env import (
AUDIT_LOG_FILE_ROTATION_SIZE,
AUDIT_LOG_LEVEL,
GLOBAL_LOG_LEVEL,
LOG_FORMAT,
AUDIT_UVICORN_LOGGER_NAMES,
ENABLE_OTEL,
ENABLE_OTEL_LOGS,
_LEVEL_MAP,
)
if TYPE_CHECKING:
from loguru import Record
from loguru import Message, Record
def stdout_format(record: "Record") -> str:
@@ -43,6 +45,29 @@ def stdout_format(record: "Record") -> str:
)
def _json_sink(message: "Message") -> None:
"""Write log records as single-line JSON to stdout.
Used as a Loguru sink when LOG_FORMAT is set to "json".
"""
record = message.record
log_entry = {
"ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
"level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()),
"msg": record["message"],
"caller": f"{record['name']}:{record['function']}:{record['line']}",
}
if record["extra"]:
log_entry["extra"] = record["extra"]
if record["exception"] is not None:
log_entry["error"] = "".join(record["exception"].format_exception()).rstrip()
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n")
sys.stdout.flush()
class InterceptHandler(logging.Handler):
"""
Intercepts log records from Python's standard logging module
@@ -127,14 +152,22 @@ def start_logger():
"""
logger.remove()
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
),
audit_filter = lambda record: (
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
)
if LOG_FORMAT == "json":
logger.add(
_json_sink,
level=GLOBAL_LOG_LEVEL,
filter=audit_filter,
)
else:
logger.add(
sys.stdout,
level=GLOBAL_LOG_LEVEL,
format=stdout_format,
filter=audit_filter,
)
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
try:
logger.add(
+17 -4
View File
@@ -9,14 +9,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
import httpx
from mcp.shared._httpx_utils import create_mcp_http_client
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
client.verify = False
return client
"""Create an httpx AsyncClient with SSL verification disabled.
Note: verify=False must be passed at construction time because httpx
configures the SSL context during __init__. Setting client.verify = False
after construction does not affect the underlying transport's SSL context.
"""
kwargs = {
"follow_redirects": True,
"verify": False,
}
if timeout is not None:
kwargs["timeout"] = timeout
if headers is not None:
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
return httpx.AsyncClient(**kwargs)
class MCPClient:
+255 -206
View File
@@ -171,7 +171,10 @@ def get_citation_source_from_tool_result(
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
"""
try:
tool_result = json.loads(tool_result)
try:
tool_result = json.loads(tool_result)
except (json.JSONDecodeError, TypeError):
pass # keep tool_result as-is (e.g. fetch_url returns plain text)
if isinstance(tool_result, dict) and "error" in tool_result:
return []
@@ -232,6 +235,25 @@ def get_citation_source_from_tool_result(
}
]
elif tool_name == "fetch_url":
url = tool_params.get("url", "")
content = tool_result if isinstance(tool_result, str) else str(tool_result)
snippet = content[:500] + ("..." if len(content) > 500 else "")
return [
{
"source": {"name": url or "fetch_url", "id": url or "fetch_url"},
"document": [snippet],
"metadata": [
{
"source": url,
"name": url,
"url": url,
}
],
}
]
elif tool_name == "query_knowledge_files":
chunks = tool_result
@@ -805,10 +827,15 @@ def apply_source_context_to_messages(
messages: list,
sources: list,
user_message: str,
include_content: bool = True,
) -> list:
"""
Build source context from citation sources and apply to messages.
Uses RAG template to format context for model consumption.
When include_content is False, emit <source> tags with id/name but no
document body — useful when the content is already present elsewhere
(e.g. in a tool result message) and only citation markers are needed.
"""
if not sources or not user_message:
return messages
@@ -822,10 +849,11 @@ def apply_source_context_to_messages(
if src_id not in citation_idx:
citation_idx[src_id] = len(citation_idx) + 1
src_name = source.get("source", {}).get("name")
body = doc if include_content else ""
context_string += (
f'<source id="{citation_idx[src_id]}"'
+ (f' name="{src_name}"' if src_name else "")
+ f">{doc}</source>\n"
+ f">{body}</source>\n"
)
context_string = context_string.strip()
@@ -2056,11 +2084,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Folder "Project" handling
# Check if the request has chat_id and is inside of a folder
# Uses lightweight column query — only fetches folder_id, not the full chat JSON blob
chat_id = metadata.get("chat_id", None)
if chat_id and user:
chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id)
if chat and chat.folder_id:
folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id)
folder_id = Chats.get_chat_folder_id(chat_id, user.id)
if folder_id:
folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id)
if folder and folder.data:
if "system_prompt" in folder.data:
@@ -2197,6 +2226,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None)
# Caller-provided OpenAI-style tools take precedence over server-side
# tool resolution (tool_ids, MCP servers, builtin tools).
payload_tools = form_data.get("tools", None)
# Skills
user_skill_ids = set(form_data.pop("skill_ids", None) or [])
model_skill_ids = set(model.get("info", {}).get("meta", {}).get("skillIds", []))
@@ -2268,228 +2301,235 @@ async def process_chat_payload(request, form_data, user, metadata, model):
}
form_data["metadata"] = metadata
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
direct_tool_servers = metadata.get("tool_servers", None)
# When the caller provides an explicit OpenAI-style `tools` array in the
# request body, skip all server-side tool resolution and pass the caller's
# tools through to the model unchanged.
if not payload_tools:
# Server side tools
tool_ids = metadata.get("tool_ids", None)
# Client side tools
direct_tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}")
log.debug(f"{direct_tool_servers=}")
log.debug(f"{tool_ids=}")
log.debug(f"{direct_tool_servers=}")
tools_dict = {}
tools_dict = {}
mcp_clients = {}
mcp_tools_dict = {}
mcp_clients = {}
mcp_tools_dict = {}
if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id") == server_id
):
mcp_server_connection = server_connection
break
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id")
== server_id
):
mcp_server_connection = server_connection
break
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
continue
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
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
# 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", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "oauth_2.1":
try:
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
elif auth_type == "oauth_2.1":
try:
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
connection_headers = mcp_server_connection.get("headers", None)
if connection_headers and isinstance(connection_headers, dict):
for key, value in connection_headers.items():
headers[key] = value
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get(
"chat_id"
)
if metadata and metadata.get("message_id"):
headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = (
metadata.get("message_id")
)
mcp_clients[server_id] = MCPClient()
await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
function_name_filter_list = mcp_server_connection.get(
"config", {}
).get("function_name_filter_list", "")
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(",")
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(client, function_name):
async def tool_function(**kwargs):
return await client.call_tool(
function_name,
function_args=kwargs,
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
)
return tool_function
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
if function_name_filter_list:
if not is_string_allowed(
tool_spec["name"], function_name_filter_list
):
# Skip this function
continue
connection_headers = mcp_server_connection.get("headers", None)
if connection_headers and isinstance(connection_headers, dict):
for key, value in connection_headers.items():
headers[key] = value
tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
# Add user info headers if enabled
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
headers = include_user_info_headers(headers, user)
if metadata and metadata.get("chat_id"):
headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = (
metadata.get("chat_id")
)
if metadata and metadata.get("message_id"):
headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = (
metadata.get("message_id")
)
mcp_clients[server_id] = MCPClient()
await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function,
"type": "mcp",
"client": mcp_clients[server_id],
"direct": False,
}
except Exception as e:
log.debug(e)
if event_emitter:
await event_emitter(
{
"type": "chat:message:error",
"data": {
"error": {
"content": f"Failed to connect to MCP server '{server_id}'"
}
function_name_filter_list = mcp_server_connection.get(
"config", {}
).get("function_name_filter_list", "")
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(
","
)
tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(client, function_name):
async def tool_function(**kwargs):
return await client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
if function_name_filter_list:
if not is_string_allowed(
tool_spec["name"], function_name_filter_list
):
# Skip this function
continue
tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
)
mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function,
"type": "mcp",
"client": mcp_clients[server_id],
"direct": False,
}
)
continue
except Exception as e:
log.debug(e)
if event_emitter:
await event_emitter(
{
"type": "chat:message:error",
"data": {
"error": {
"content": f"Failed to connect to MCP server '{server_id}'"
}
},
}
)
continue
tools_dict = await get_tools(
request,
tool_ids,
user,
{
**extra_params,
"__model__": models[task_model_id],
"__messages__": form_data["messages"],
"__files__": metadata.get("files", []),
},
)
tools_dict = await get_tools(
request,
tool_ids,
user,
{
**extra_params,
"__model__": models[task_model_id],
"__messages__": form_data["messages"],
"__files__": metadata.get("files", []),
},
)
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if direct_tool_servers:
for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
if direct_tool_servers:
for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", [])
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
for tool in tool_specs:
tools_dict[tool["name"]] = {
"spec": tool,
"direct": True,
"server": tool_server,
}
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
if 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") or {}
).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,
"__skill_ids__": [
s.id for s in available_skills if s.id not in user_skill_ids
],
},
features,
model,
)
for name, tool_dict in builtin_tools.items():
if name not in tools_dict:
tools_dict[name] = tool_dict
# 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") or {}
).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,
"__skill_ids__": [
s.id for s in available_skills if s.id not in user_skill_ids
],
},
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 metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler
metadata["tools"] = tools_dict
form_data["tools"] = [
{"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values()
]
else:
# If the function calling is not native, then call the tools function calling handler
@@ -3958,6 +3998,8 @@ async def streaming_chat_response_handler(response, ctx):
tool_call_retries = 0
tool_call_sources = [] # Track citation sources from tool results
all_tool_call_sources = [] # Accumulated sources across all iterations
user_message = get_last_user_message(form_data["messages"])
while (
len(tool_calls) > 0
@@ -4102,6 +4144,7 @@ async def streaming_chat_response_handler(response, ctx):
tool_function_name
in [
"search_web",
"fetch_url",
"view_knowledge_file",
"query_knowledge_files",
]
@@ -4193,16 +4236,22 @@ async def streaming_chat_response_handler(response, ctx):
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()
# Use metadata_only=True to avoid duplicating content
# that is already in the tool result message.
all_tool_call_sources.extend(tool_call_sources)
if all_tool_call_sources and user_message:
# Restore original user message before re-applying to avoid recursive nesting
form_data["messages"] = add_or_update_user_message(
user_message, form_data["messages"], append=False
)
form_data["messages"] = apply_source_context_to_messages(
request,
form_data["messages"],
all_tool_call_sources,
user_message,
include_content=False,
)
tool_call_sources.clear()
await event_emitter(
{
+11 -3
View File
@@ -91,14 +91,22 @@ def get_message_list(messages_map, message_id):
# Reconstruct the chain by following the parentId links
message_list = []
visited_message_ids = set()
while current_message:
message_list.insert(
0, current_message
) # Insert the message at the beginning of the list
message_id = current_message.get("id")
if message_id in visited_message_ids:
# Cycle detected, break to prevent infinite loop
break
if message_id is not None:
visited_message_ids.add(message_id)
message_list.append(current_message)
parent_id = current_message.get("parentId") # Use .get() for safety
current_message = messages_map.get(parent_id) if parent_id else None
message_list.reverse()
return message_list
+25 -1
View File
@@ -1,3 +1,4 @@
import copy
import time
import logging
import asyncio
@@ -307,6 +308,29 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
except Exception as e:
log.info(f"Failed to load function module for {function_id}: {e}")
# Apply global model defaults to all models
# Per-model overrides take precedence over global defaults
default_metadata = (
getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {}
)
if default_metadata:
for model in models:
info = model.get("info")
if info is None:
model["info"] = {"meta": copy.deepcopy(default_metadata)}
continue
meta = info.setdefault("meta", {})
for key, value in default_metadata.items():
if key == "capabilities":
# Merge capabilities: defaults as base, per-model overrides win
existing = meta.get("capabilities") or {}
meta["capabilities"] = {**value, **existing}
elif meta.get(key) is None:
meta[key] = copy.deepcopy(value)
for model in models:
action_ids = [
action_id
@@ -435,7 +459,7 @@ def get_filtered_models(models, user, db=None):
if model_info:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info["user_id"]
or user.id == model_info.get("user_id")
or model["id"] in accessible_model_ids
):
filtered_models.append(model)
+19 -5
View File
@@ -41,6 +41,7 @@ from open_webui.config import (
ENABLE_OAUTH_ROLE_MANAGEMENT,
ENABLE_OAUTH_GROUP_MANAGEMENT,
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_GROUP_DEFAULT_SHARE,
OAUTH_BLOCKED_GROUPS,
OAUTH_GROUPS_SEPARATOR,
OAUTH_ROLES_SEPARATOR,
@@ -69,6 +70,7 @@ from open_webui.env import (
ENABLE_OAUTH_ID_TOKEN_COOKIE,
ENABLE_OAUTH_EMAIL_FALLBACK,
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
OAUTH_MAX_SESSIONS_PER_USER,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
@@ -113,6 +115,7 @@ auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAI
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
@@ -1245,7 +1248,11 @@ class OAuthManager:
name=group_name,
description=f"Group '{group_name}' created automatically via OAuth.",
permissions=default_permissions, # Use default permissions from function args
user_ids=[], # Start with no users, user will be added later by subsequent logic
data={
"config": {
"share": auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE
}
},
)
# Use determined creator ID (admin or fallback to current user)
created_group = Groups.insert_new_group(
@@ -1679,11 +1686,18 @@ class OAuthManager:
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first
# Enforce max concurrent sessions per user/provider to prevent
# unbounded growth while allowing multi-device usage
sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db)
for session in sessions:
if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id, db=db)
provider_sessions = sorted(
[session for session in sessions if session.provider == provider],
key=lambda session: session.created_at,
reverse=True,
)
# Keep the newest sessions up to the limit, prune the rest
if len(provider_sessions) >= OAUTH_MAX_SESSIONS_PER_USER:
for old_session in provider_sessions[OAUTH_MAX_SESSIONS_PER_USER - 1 :]:
OAuthSessions.delete_session_by_id(old_session.id, db=db)
session = OAuthSessions.create_session(
user_id=user.id,
+2 -2
View File
@@ -187,7 +187,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": bool,
"think": lambda x: x,
}
for key, value in ollama_root_params.items():
@@ -326,7 +326,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
ollama_root_params = {
"format": lambda x: parse_json(x),
"keep_alive": lambda x: parse_json(x),
"think": bool,
"think": lambda x: x,
}
# Ollama's options field can contain parameters that should be at the root level.
+12 -1
View File
@@ -8,7 +8,12 @@ import tempfile
import logging
from typing import Any
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
from open_webui.env import (
PIP_OPTIONS,
PIP_PACKAGE_INDEX_OPTIONS,
OFFLINE_MODE,
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
)
from open_webui.models.functions import Functions
from open_webui.models.tools import Tools
@@ -401,6 +406,12 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
def install_frontmatter_requirements(requirements: str):
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
log.info(
"ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements."
)
return
if OFFLINE_MODE:
log.info("Offline mode enabled, skipping installation of requirements.")
return
+11 -1
View File
@@ -60,6 +60,8 @@ from open_webui.tools.builtin import (
search_memories,
add_memory,
replace_memory_content,
delete_memory,
list_memories,
get_current_timestamp,
calculate_timestamp,
search_notes,
@@ -471,7 +473,15 @@ def get_builtin_tools(
# Add memory tools if builtin category enabled AND enabled for this chat
if is_builtin_tool_enabled("memory") and features.get("memory"):
builtin_functions.extend([search_memories, add_memory, replace_memory_content])
builtin_functions.extend(
[
search_memories,
add_memory,
replace_memory_content,
delete_memory,
list_memories,
]
)
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
if (
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.5",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.3",
"version": "0.8.5",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
+1 -1
View File
@@ -26,7 +26,7 @@
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#171717" />
<meta name="robots" content="noindex,nofollow" />
+7 -2
View File
@@ -281,7 +281,12 @@ export const updateModelById = async (token: string, id: string, model: object)
return res;
};
export const updateModelAccessGrants = async (token: string, id: string, accessGrants: any[]) => {
export const updateModelAccessGrants = async (
token: string,
id: string,
name: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, {
@@ -291,7 +296,7 @@ export const updateModelAccessGrants = async (token: string, id: string, accessG
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ id, access_grants: accessGrants })
body: JSON.stringify({ id, name, access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
+28
View File
@@ -395,6 +395,34 @@ export const setProductionPromptVersion = async (
return res;
};
export const togglePromptById = async (token: string, promptId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/toggle`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deletePromptById = async (token: string, promptId: string) => {
let error = null;
+14 -1
View File
@@ -309,14 +309,27 @@
bind:value={url}
placeholder={$i18n.t('API Base URL')}
autocomplete="off"
list={ollama ? undefined : 'suggestions'}
required
/>
{#if !ollama}
<datalist id="suggestions">
<option value="https://api.openai.com/v1" />
<option value="https://api.anthropic.com/v1" />
<option value="https://generativelanguage.googleapis.com/v1beta/openai" />
<option value="https://api.mistral.ai/v1" />
<option value="https://api.groq.com/openai/v1" />
<option value="https://openrouter.ai/api/v1" />
<option value="https://api.x.ai/v1" />
</datalist>
{/if}
</div>
</div>
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
@@ -7,7 +7,7 @@
</script>
<div class="px-3">
<div class="text-center dark:text-white text-2xl font-medium z-50">
<div class="text-center dark:text-white text-2xl font-medium z-50" role="heading" aria-level="2">
{#if title}
{title}
{:else}
+1 -1
View File
@@ -490,7 +490,7 @@
className="shrink-0 flex items-center mr-1"
>
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
verifyHandler();
}}
+5 -7
View File
@@ -38,15 +38,13 @@
<Modal bind:show size="xl">
<div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start">
<div class="text-xl font-medium">
<h2 class="text-xl font-medium m-0">
{$i18n.t("What's New in")}
{$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
</div>
</h2>
<button class="self-center" on:click={closeModal} aria-label={$i18n.t('Close')}>
<XMark className={'size-5'}>
<p class="sr-only">{$i18n.t('Close')}</p>
</XMark>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex items-center mt-1">
@@ -64,9 +62,9 @@
{#if changelog}
{#each Object.keys(changelog) as version}
<div class=" mb-3 pr-2">
<div class="font-semibold text-xl mb-1 dark:text-white">
<h3 class="font-semibold text-xl mb-1 dark:text-white m-0">
v{version} - {changelog[version].date}
</div>
</h3>
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
+1
View File
@@ -67,6 +67,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
+2 -2
View File
@@ -82,9 +82,9 @@
});
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
role="status"
aria-live="polite"
class="group relative flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
on:dragstart|preventDefault
on:pointerdown={onPointerDown}
+3 -3
View File
@@ -87,15 +87,15 @@
<div class="flex justify-center mt-8">
<div class="flex flex-col justify-center items-center">
<button
aria-labelledby="get-started"
aria-label={$i18n.t('Get started')}
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
on:click={() => {
getStartedHandler();
}}
>
<ArrowRightCircle className="size-6" />
<ArrowRightCircle className="size-6" aria-hidden="true" />
</button>
<div id="get-started" class="mt-1.5 font-primary text-base font-medium">
<div class="mt-1.5 font-primary text-base font-medium" aria-hidden="true">
{$i18n.t(`Get started`)}
</div>
</div>
@@ -158,6 +158,11 @@
if (modelOrderBy === 'name') {
return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
}
if (modelOrderBy === 'tokens') {
const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0;
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@@ -167,6 +172,11 @@
const nameB = b.name || b.user_id;
return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
}
if (userOrderBy === 'tokens') {
const aTokens = a.total_tokens ?? 0;
const bTokens = b.total_tokens ?? 0;
return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
}
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
});
@@ -191,7 +201,7 @@
{#if groups.length > 0}
<select
bind:value={selectedGroupId}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
<option value={null}>{$i18n.t('All Users')}</option>
{#each groups as group}
@@ -201,7 +211,7 @@
{/if}
<select
bind:value={selectedPeriod}
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
>
{#each periods as period}
<option value={period.value}>{$i18n.t(period.label)}</option>
@@ -329,8 +339,42 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleModelSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if modelOrderBy === 'tokens'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-16"
on:click={() => toggleModelSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
%
{#if modelOrderBy === 'percentage'}
<span class="font-normal">
{#if modelDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@@ -349,6 +393,9 @@
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="truncate max-w-[150px]">{model.name}</span>
</div>
@@ -422,7 +469,24 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right"
on:click={() => toggleUserSort('tokens')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Tokens')}
{#if userOrderBy === 'tokens'}
<span class="font-normal">
{#if userDirection === 'asc'}<ChevronUp
className="size-2"
/>{:else}<ChevronDown className="size-2" />{/if}
</span>
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@@ -435,6 +499,9 @@
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/user.png';
}}
/>
<span class="truncate max-w-[150px]"
>{user.name || user.email || user.user_id.substring(0, 8)}</span
@@ -114,7 +114,22 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
@@ -130,7 +145,10 @@
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
alt={model.name}
class="size-5 rounded-full object-cover"
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
</div>
@@ -109,7 +109,22 @@
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
on:click={() => toggleSort('percentage')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Share')}
{#if orderBy === 'percentage'}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}
<span class="invisible"><ChevronUp className="size-2" /></span>
{/if}
</div>
</th>
</tr>
</thead>
<tbody>
+10 -12
View File
@@ -54,15 +54,14 @@
id="users-tabs-container"
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
<a
id="leaderboard"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/evaluations/leaderboard"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'leaderboard'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/evaluations/leaderboard');
}}
>
<div class=" self-center mr-2">
<svg
@@ -79,17 +78,16 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
</button>
</a>
<button
<a
id="feedback"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/evaluations/feedback"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'feedback'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/evaluations/feedback');
}}
>
<div class=" self-center mr-2">
<svg
@@ -106,7 +104,7 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Feedback')}</div>
</button>
</a>
</div>
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
@@ -32,7 +32,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('delete');
show = false;
@@ -180,7 +180,10 @@
<img
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
alt={model.name}
class="size-5 rounded-full object-cover"
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
e.target.src = '/favicon.png';
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1"
+5 -2
View File
@@ -83,7 +83,7 @@
}
const setFilteredItems = () => {
filteredItems = functions
filteredItems = (functions ?? [])
.filter(
(f) =>
(selectedType !== '' ? f.type === selectedType : true) &&
@@ -681,7 +681,8 @@
}
toast.success($i18n.t('Functions imported successfully'));
functions.set(await getFunctions(localStorage.token));
functions = await getFunctionList(localStorage.token);
_functions.set(await getFunctions(localStorage.token));
models.set(
await getModels(
localStorage.token,
@@ -690,6 +691,8 @@
true
)
);
importFiles = null;
functionsImportInputElement.value = '';
};
reader.readAsText(importFiles[0]);
@@ -67,7 +67,7 @@
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
editHandler();
}}
@@ -91,7 +91,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
shareHandler();
}}
@@ -101,7 +101,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
@@ -112,7 +112,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
@@ -125,7 +125,7 @@
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
deleteHandler();
}}
+5 -6
View File
@@ -321,15 +321,14 @@
<!-- {$i18n.t('Pipelines')} -->
<!-- {$i18n.t('Database')} -->
{#each filteredSettings as tab (tab.id)}
<button
<a
id={tab.id}
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
href={tab.route}
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition select-none {selectedTab ===
tab.id
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto(tab.route);
}}
>
<div class=" self-center mr-2">
{#if tab.id === 'general'}
@@ -500,7 +499,7 @@
{/if}
</div>
<div class=" self-center">{$i18n.t(tab.title)}</div>
</button>
</a>
{/each}
</div>
@@ -235,7 +235,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Speech-to-Text Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 cursor-pointer w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="cursor-pointer w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={STT_ENGINE}
placeholder={$i18n.t('Select an engine')}
>
@@ -506,7 +506,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Text-to-Speech Engine')}</div>
<div class="flex items-center relative">
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={TTS_ENGINE}
placeholder={$i18n.t('Select a mode')}
on:change={async (e) => {
@@ -798,7 +798,7 @@
<div class="self-center text-xs font-medium">{$i18n.t('Response splitting')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
aria-label={$i18n.t('Select how to split message text for TTS requests')}
bind:value={TTS_SPLIT_ON}
>
@@ -60,7 +60,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Code Execution Engine')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_EXECUTION_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@@ -109,7 +109,7 @@
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_EXECUTION_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
@@ -151,7 +151,7 @@
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_EXECUTION_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
@@ -186,7 +186,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={config.CODE_INTERPRETER_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@@ -235,7 +235,7 @@
<div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-left"
bind:value={config.CODE_INTERPRETER_JUPYTER_AUTH}
placeholder={$i18n.t('Select an auth method')}
>
@@ -277,7 +277,7 @@
<div class="">
<Tooltip content={$i18n.t('Enter timeout in seconds')}>
<input
class="dark:bg-gray-900 w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
type="number"
bind:value={config.CODE_INTERPRETER_JUPYTER_TIMEOUT}
placeholder={$i18n.t('e.g. 60')}
@@ -3,6 +3,7 @@
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@@ -75,10 +76,10 @@
/>
</Tooltip>
<div class="flex gap-1">
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Manage')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showManageModal = true;
}}
@@ -90,7 +91,7 @@
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}
@@ -99,5 +100,15 @@
<Cog6 />
</button>
</Tooltip>
<Tooltip content={(config?.enable ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={config.enable}
on:change={() => {
config.enable = config.enable ?? false;
onSubmit({ url, key: config?.key ?? '', config });
}}
/>
</Tooltip>
</div>
</div>
@@ -3,6 +3,7 @@
const i18n = getContext('i18n');
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
@@ -98,10 +99,10 @@
</div>
</Tooltip>
<div class="flex gap-1">
<div class="flex gap-1 items-center">
<Tooltip content={$i18n.t('Configure')} className="self-start">
<button
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
on:click={() => {
showConfigModal = true;
}}
@@ -110,5 +111,15 @@
<Cog6 />
</button>
</Tooltip>
<Tooltip content={(config?.enable ?? true) ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={config.enable}
on:change={() => {
config.enable = config.enable ?? false;
onSubmit({ url, key, config });
}}
/>
</Tooltip>
</div>
</div>
@@ -42,6 +42,7 @@
let RAG_EMBEDDING_MODEL = '';
let RAG_EMBEDDING_BATCH_SIZE = 1;
let ENABLE_ASYNC_EMBEDDING = true;
let RAG_EMBEDDING_CONCURRENT_REQUESTS = 0;
let rerankingModel = '';
@@ -104,7 +105,8 @@
RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_BATCH_SIZE,
ENABLE_ASYNC_EMBEDDING
ENABLE_ASYNC_EMBEDDING,
RAG_EMBEDDING_CONCURRENT_REQUESTS
});
updateEmbeddingModelLoading = true;
@@ -113,6 +115,7 @@
RAG_EMBEDDING_MODEL: RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_BATCH_SIZE: RAG_EMBEDDING_BATCH_SIZE,
ENABLE_ASYNC_EMBEDDING: ENABLE_ASYNC_EMBEDDING,
RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS,
ollama_config: {
key: OllamaKey,
url: OllamaUrl
@@ -241,6 +244,7 @@
RAG_EMBEDDING_MODEL = embeddingConfig.RAG_EMBEDDING_MODEL;
RAG_EMBEDDING_BATCH_SIZE = embeddingConfig.RAG_EMBEDDING_BATCH_SIZE ?? 1;
ENABLE_ASYNC_EMBEDDING = embeddingConfig.ENABLE_ASYNC_EMBEDDING ?? true;
RAG_EMBEDDING_CONCURRENT_REQUESTS = embeddingConfig.RAG_EMBEDDING_CONCURRENT_REQUESTS ?? 0;
OpenAIKey = embeddingConfig.openai_config.key;
OpenAIUrl = embeddingConfig.openai_config.url;
@@ -336,7 +340,7 @@
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.CONTENT_EXTRACTION_ENGINE}
>
<option value="">{$i18n.t('Default')}</option>
@@ -377,7 +381,7 @@
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.PDF_LOADER_MODE}
>
<option value="page">{$i18n.t('Page')}</option>
@@ -548,7 +552,7 @@
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DATALAB_MARKER_OUTPUT_FORMAT}
>
<option value="markdown">{$i18n.t('Markdown')}</option>
@@ -655,7 +659,7 @@
{$i18n.t('API Mode')}
</div>
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden"
bind:value={RAGConfig.MINERU_API_MODE}
on:change={() => {
// Auto-update URL when switching modes if it's empty or matches the opposite mode's default
@@ -761,7 +765,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Text Splitter')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.TEXT_SPLITTER}
>
<option value="">{$i18n.t('Default')} ({$i18n.t('Character')})</option>
@@ -867,7 +871,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={RAG_EMBEDDING_ENGINE}
placeholder={$i18n.t('Select an embedding model engine')}
on:change={(e) => {
@@ -1043,6 +1047,28 @@
<Switch bind:state={ENABLE_ASYNC_EMBEDDING} />
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class="self-center text-xs font-medium">
<Tooltip
content={$i18n.t(
'Limits the number of concurrent embedding requests. Set to 0 for unlimited.'
)}
placement="top-start"
>
{$i18n.t('Embedding Concurrent Requests')}
</Tooltip>
</div>
<div class="">
<input
bind:value={RAG_EMBEDDING_CONCURRENT_REQUESTS}
type="number"
class=" bg-transparent text-center w-14 outline-none"
min="0"
step="1"
/>
</div>
</div>
{/if}
</div>
@@ -1099,7 +1125,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.RAG_RERANKING_ENGINE}
placeholder={$i18n.t('Select a reranking model engine')}
on:change={(e) => {
@@ -350,7 +350,7 @@
<div class="flex items-center">
<select
class="dark:bg-gray-900 w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
@@ -1,7 +1,8 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { v4 as uuidv4 } from 'uuid';
import { getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import {
getAdminConfig,
getLdapConfig,
@@ -10,16 +11,19 @@
updateLdapConfig,
updateLdapServer
} from '$lib/apis/auths';
import { getBanners, setBanners } from '$lib/apis/configs';
import { getGroups } from '$lib/apis/groups';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants';
import { config, showChangelog } from '$lib/stores';
import { banners as _banners, config, showChangelog } from '$lib/stores';
import type { Banner } from '$lib/types';
import { compareVersion } from '$lib/utils';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Textarea from '$lib/components/common/Textarea.svelte';
import Banners from './Interface/Banners.svelte';
const i18n = getContext('i18n');
@@ -35,6 +39,8 @@
let webhookUrl = '';
let groups = [];
let banners: Banner[] = [];
// LDAP
let ENABLE_LDAP = false;
let LDAP_SERVER = {
@@ -78,12 +84,20 @@
}
};
const updateBanners = async () => {
_banners.set(await setBanners(localStorage.token, banners));
};
const updateHandler = async () => {
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
const res = await updateAdminConfig(localStorage.token, adminConfig);
await updateLdapConfig(localStorage.token, ENABLE_LDAP);
await updateLdapServerHandler();
await updateBanners();
await config.set(await getBackendConfig());
if (res) {
saveHandler();
} else {
@@ -114,6 +128,8 @@
const ldapConfig = await getLdapConfig(localStorage.token);
ENABLE_LDAP = ldapConfig.ENABLE_LDAP;
banners = await getBanners(localStorage.token);
});
</script>
@@ -293,7 +309,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_USER_ROLE}
placeholder={$i18n.t('Select a role')}
>
@@ -308,7 +324,7 @@
<div class=" self-center text-xs font-medium">{$i18n.t('Default Group')}</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={adminConfig.DEFAULT_GROUP_ID}
placeholder={$i18n.t('Select a group')}
>
@@ -526,7 +542,6 @@
>
<input
class="w-full bg-transparent outline-hidden py-0.5"
required
placeholder={$i18n.t('Enter Application DN')}
bind:value={LDAP_SERVER.app_dn}
/>
@@ -538,6 +553,7 @@
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Application DN Password')}
required={false}
bind:value={LDAP_SERVER.app_dn_password}
/>
</div>
@@ -811,6 +827,53 @@
</div>
</div>
</div>
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('UI')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Banners')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<Banners bind:banners />
</div>
</div>
</div>
{/if}
</div>
@@ -402,7 +402,7 @@
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGE_GENERATION_ENGINE}
placeholder={$i18n.t('Select Engine')}
>
@@ -867,7 +867,7 @@
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGES_GEMINI_ENDPOINT_METHOD}
placeholder={$i18n.t('Select Method')}
>
@@ -950,7 +950,7 @@
</div>
<select
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 cursor-pointer rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={config.IMAGE_EDIT_ENGINE}
placeholder={$i18n.t('Select Engine')}
>
@@ -1,27 +1,15 @@
<script lang="ts">
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { v4 as uuidv4 } from 'uuid';
import { getModels, getTaskConfig, updateTaskConfig } from '$lib/apis';
import { config, settings } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { getBackendConfig, getModels, getTaskConfig, updateTaskConfig } from '$lib/apis';
import { setDefaultPromptSuggestions } from '$lib/apis/configs';
import { config, settings, user } from '$lib/stores';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { banners as _banners } from '$lib/stores';
import type { Banner } from '$lib/types';
import { getBaseModels } from '$lib/apis/models';
import { getBanners, setBanners } from '$lib/apis/configs';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Banners from './Interface/Banners.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
const dispatch = createEventDispatcher();
@@ -46,21 +34,8 @@
VOICE_MODE_PROMPT_TEMPLATE: ''
};
let promptSuggestions = [];
let banners: Banner[] = [];
const updateInterfaceHandler = async () => {
taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await updateBanners();
await config.set(await getBackendConfig());
};
const updateBanners = async () => {
_banners.set(await setBanners(localStorage.token, banners));
};
let workspaceModels = null;
@@ -69,33 +44,37 @@
let models = null;
const init = async () => {
taskConfig = await getTaskConfig(localStorage.token);
promptSuggestions = $config?.default_prompt_suggestions ?? [];
banners = await getBanners(localStorage.token);
try {
taskConfig = await getTaskConfig(localStorage.token);
workspaceModels = await getBaseModels(localStorage.token);
baseModels = await getModels(localStorage.token, null, false);
workspaceModels = await getBaseModels(localStorage.token);
baseModels = await getModels(localStorage.token, null, false);
models = baseModels.map((m) => {
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
models = baseModels.map((m) => {
const workspaceModel = workspaceModels.find((wm) => wm.id === m.id);
if (workspaceModel) {
return {
...m,
...workspaceModel
};
} else {
return {
...m,
id: m.id,
name: m.name,
if (workspaceModel) {
return {
...m,
...workspaceModel
};
} else {
return {
...m,
id: m.id,
name: m.name,
is_active: true
};
}
});
is_active: true
};
}
});
console.debug('models', models);
console.debug('models', models);
} catch (err) {
console.error('Failed to initialize Interface settings:', err);
toast.error(err?.detail ?? err?.message ?? $i18n.t('Failed to load Interface settings'));
models = [];
}
};
onMount(async () => {
@@ -435,63 +414,6 @@
</Tooltip>
</div>
</div>
<div class="mb-3.5">
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('UI')}</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="mb-2.5">
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Banners')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
type="button"
on:click={() => {
if (banners.length === 0 || banners.at(-1).content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
</div>
<Banners bind:banners />
</div>
{#if $user?.role === 'admin'}
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
{/if}
</div>
</div>
<div class="flex justify-end text-sm font-medium">
+97 -106
View File
@@ -28,9 +28,10 @@
import ModelEditor from '$lib/components/workspace/Models/ModelEditor.svelte';
import { toast } from 'svelte-sonner';
import Badge from '$lib/components/common/Badge.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
import ModelSettingsModal from './Models/ModelSettingsModal.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import Download from '$lib/components/icons/Download.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte';
@@ -304,7 +305,7 @@
});
</script>
<ConfigureModelsModal bind:show={showConfigModal} initHandler={init} />
<ModelSettingsModal bind:show={showConfigModal} initHandler={init} />
<ManageModelsModal bind:show={showManageModal} />
{#if models !== null}
@@ -322,6 +323,69 @@
</div>
<div class="flex w-full justify-end gap-1.5">
{#if $user?.role === 'admin'}
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
modelsImportInProgress = true;
try {
const models = JSON.parse(String(event.target.result));
const res = await importModels(localStorage.token, models);
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error(e?.detail ?? $i18n.t('Invalid JSON file'));
console.error(e);
}
modelsImportInProgress = false;
};
reader.readAsText(importFiles[0]);
}
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement.click();
}}
>
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Import')}
</div>
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
downloadModels(models);
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
</button>
{/if}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
type="button"
@@ -335,7 +399,7 @@
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black transition font-medium"
type="button"
on:click={() => {
showConfigModal = true;
@@ -405,7 +469,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
enableAllHandler();
}}
@@ -415,7 +479,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
disableAllHandler();
}}
@@ -445,7 +509,7 @@
selectedModelId = model.id;
}}
>
<div class=" self-center w-8">
<div class=" self-center w-9">
<div
class=" rounded-full object-cover {(model?.is_active ?? true)
? ''
@@ -473,9 +537,32 @@
className=" w-fit"
placement="top-start"
>
<div class=" font-semibold line-clamp-1">{model.name}</div>
<div class="font-medium line-clamp-1 flex items-center gap-2">
{model.name}
<Badge
type={(model?.access_grants ?? []).some(
(g) =>
g.principal_type === 'user' &&
g.principal_id === '*' &&
g.permission === 'read'
)
? 'success'
: 'muted'}
content={(model?.access_grants ?? []).some(
(g) =>
g.principal_type === 'user' &&
g.principal_id === '*' &&
g.permission === 'read'
)
? $i18n.t('Public')
: $i18n.t('Private')}
/>
</div>
</Tooltip>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<div
class=" text-xs overflow-hidden text-ellipsis line-clamp-1 flex items-center gap-1 text-gray-500"
>
<span class=" line-clamp-1">
{!!model?.meta?.description
? model?.meta?.description
@@ -590,103 +677,6 @@
<Pagination bind:page={currentPage} count={filteredModels.length} {perPage} />
{/if}
</div>
{#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-1">
<input
id="models-import-input"
bind:this={modelsImportInputElement}
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
modelsImportInProgress = true;
try {
const models = JSON.parse(String(event.target.result));
const res = await importModels(localStorage.token, models);
if (res) {
toast.success($i18n.t('Models imported successfully'));
await init();
} else {
toast.error($i18n.t('Failed to import models'));
}
} catch (e) {
toast.error(e?.detail ?? $i18n.t('Invalid JSON file'));
console.error(e);
}
modelsImportInProgress = false;
};
reader.readAsText(importFiles[0]);
}
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
disabled={modelsImportInProgress}
on:click={() => {
modelsImportInputElement.click();
}}
>
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Presets')}
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={async () => {
downloadModels(models);
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Presets')} ({models.length})
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
{/if}
{:else}
<ModelEditor
edit
@@ -697,8 +687,9 @@
upsertModelHandler(model);
selectedModelId = null;
}}
onBack={() => {
onBack={async () => {
selectedModelId = null;
await init();
}}
/>
{/if}
@@ -1,242 +0,0 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { models } from '$lib/stores';
import { deleteAllModels } from '$lib/apis/models';
import { getModelsConfig, setModelsConfig } from '$lib/apis/configs';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ModelList from './ModelList.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelSelector from './ModelSelector.svelte';
import Model from '../Evaluations/Model.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let selectedModelId = '';
let defaultModelIds = [];
let selectedPinnedModelId = '';
let defaultPinnedModelIds = [];
let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false;
let showResetModal = false;
$: if (show) {
init();
}
const init = async () => {
config = await getModelsConfig(localStorage.token);
if (config?.DEFAULT_MODELS) {
defaultModelIds = (config?.DEFAULT_MODELS).split(',').filter((id) => id);
} else {
defaultModelIds = [];
}
if (config?.DEFAULT_PINNED_MODELS) {
defaultPinnedModelIds = (config?.DEFAULT_PINNED_MODELS).split(',').filter((id) => id);
} else {
defaultPinnedModelIds = [];
}
const modelOrderList = config.MODEL_ORDER_LIST || [];
const allModelIds = $models.map((model) => model.id);
// Create a Set for quick lookup of ordered IDs
const orderedSet = new Set(modelOrderList);
modelIds = [
// Add all IDs from MODEL_ORDER_LIST that exist in allModelIds
...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)),
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
sortKey = '';
sortOrder = '';
};
const submitHandler = async () => {
loading = true;
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: defaultModelIds.join(','),
DEFAULT_PINNED_MODELS: defaultPinnedModelIds.join(','),
MODEL_ORDER_LIST: modelIds
});
if (res) {
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
show = false;
} else {
toast.error($i18n.t('Failed to save models configuration'));
}
loading = false;
};
onMount(async () => {
init();
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
initHandler();
}
}}
/>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Settings')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if config}
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div>
<div class="flex flex-col w-full">
<button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds />
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<ModelSelector
title={$i18n.t('Default Models')}
models={$models}
bind:modelIds={defaultModelIds}
/>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<ModelSelector
title={$i18n.t('Default Pinned Models')}
models={$models}
bind:modelIds={defaultPinnedModelIds}
/>
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
<Tooltip content={$i18n.t('This will delete all models including custom models')}>
<button
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-950 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
type="button"
on:click={() => {
showResetModal = true;
}}
>
<!-- {$i18n.t('Delete All Models')} -->
{$i18n.t('Reset All Models')}
</button>
</Tooltip>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
{:else}
<div>
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>
</Modal>
@@ -56,7 +56,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
hideHandler();
}}
@@ -108,7 +108,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
pinModelHandler(model?.id);
}}
@@ -129,7 +129,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
copyLinkHandler();
}}
@@ -139,19 +139,21 @@
<div class="flex items-center">{$i18n.t('Copy Link')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
>
<DocumentDuplicate />
{#if model?.is_active ?? true}
<DropdownMenu.Item
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
cloneHandler();
}}
>
<DocumentDuplicate />
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
exportHandler();
}}
@@ -3,8 +3,10 @@
const i18n = getContext('i18n');
import Minus from '$lib/components/icons/Minus.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let title = '';
export let tooltip = '';
export let models = [];
export let modelIds = [];
@@ -14,12 +16,32 @@
<div>
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{title}</div>
<div class="text-xs text-gray-500 flex items-center gap-1">
{title}
{#if tooltip}
<Tooltip content={tooltip} className="cursor-help">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
/>
</svg>
</Tooltip>
{/if}
</div>
</div>
<div class="flex items-center -mr-1">
<select
class="dark:bg-gray-900 w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={selectedModelId}
@@ -0,0 +1,465 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { models, config as _config } from '$lib/stores';
import { DEFAULT_CAPABILITIES } from '$lib/constants';
import { deleteAllModels } from '$lib/apis/models';
import { getModelsConfig, setModelsConfig, setDefaultPromptSuggestions } from '$lib/apis/configs';
import { getBackendConfig } from '$lib/apis';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ModelList from './ModelList.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ModelSelector from './ModelSelector.svelte';
import Model from '../Evaluations/Model.svelte';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
import Eye from '$lib/components/icons/Eye.svelte';
export let show = false;
export let initHandler = () => {};
let config = null;
let selectedTab = 'defaults';
let selectedModelId = '';
let defaultModelIds = [];
let selectedPinnedModelId = '';
let defaultPinnedModelIds = [];
let modelIds = [];
let sortKey = '';
let sortOrder = '';
let loading = false;
let showResetModal = false;
let showDefaultCapabilities = false;
let showDefaultParams = false;
let showDefaultPromptSuggestions = false;
let defaultCapabilities = {};
let defaultFeatureIds = [];
let defaultParams = {};
let builtinTools = {};
let promptSuggestions = [];
$: if (show) {
init();
}
const init = async () => {
config = await getModelsConfig(localStorage.token);
if (config?.DEFAULT_MODELS) {
defaultModelIds = (config?.DEFAULT_MODELS).split(',').filter((id) => id);
} else {
defaultModelIds = [];
}
if (config?.DEFAULT_PINNED_MODELS) {
defaultPinnedModelIds = (config?.DEFAULT_PINNED_MODELS).split(',').filter((id) => id);
} else {
defaultPinnedModelIds = [];
}
const modelOrderList = config.MODEL_ORDER_LIST || [];
const allModelIds = $models.map((model) => model.id);
// Create a Set for quick lookup of ordered IDs
const orderedSet = new Set(modelOrderList);
modelIds = [
// Add all IDs from MODEL_ORDER_LIST that exist in allModelIds
...modelOrderList.filter((id) => orderedSet.has(id) && allModelIds.includes(id)),
// Add remaining IDs not in MODEL_ORDER_LIST, sorted alphabetically
...allModelIds.filter((id) => !orderedSet.has(id)).sort((a, b) => a.localeCompare(b))
];
sortKey = '';
sortOrder = '';
const savedMeta = config?.DEFAULT_MODEL_METADATA;
if (savedMeta && Object.keys(savedMeta).length > 0) {
defaultCapabilities = savedMeta.capabilities ?? { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = savedMeta.defaultFeatureIds ?? [];
builtinTools = savedMeta.builtinTools ?? {};
} else {
defaultCapabilities = { ...DEFAULT_CAPABILITIES };
defaultFeatureIds = [];
builtinTools = {};
}
defaultParams = config?.DEFAULT_MODEL_PARAMS ?? {};
promptSuggestions = $_config?.default_prompt_suggestions ?? [];
};
const submitHandler = async () => {
loading = true;
const metadata = {
capabilities: defaultCapabilities,
...(defaultFeatureIds.length > 0 ? { defaultFeatureIds } : {}),
...(Object.keys(builtinTools).length > 0 ? { builtinTools } : {})
};
const res = await setModelsConfig(localStorage.token, {
DEFAULT_MODELS: defaultModelIds.join(','),
DEFAULT_PINNED_MODELS: defaultPinnedModelIds.join(','),
MODEL_ORDER_LIST: modelIds,
DEFAULT_MODEL_METADATA: metadata,
DEFAULT_MODEL_PARAMS: Object.fromEntries(
Object.entries(defaultParams).filter(([_, v]) => v !== null && v !== '' && v !== undefined)
)
});
if (res) {
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
promptSuggestions = await setDefaultPromptSuggestions(localStorage.token, promptSuggestions);
await _config.set(await getBackendConfig());
toast.success($i18n.t('Models configuration saved successfully'));
initHandler();
show = false;
} else {
toast.error($i18n.t('Failed to save models configuration'));
}
loading = false;
};
onMount(async () => {
init();
});
</script>
<ConfirmDialog
title={$i18n.t('Reset All Models')}
message={$i18n.t('This will delete all models including custom models and cannot be undone.')}
bind:show={showResetModal}
onConfirm={async () => {
const res = deleteAllModels(localStorage.token);
if (res) {
toast.success($i18n.t('All models deleted successfully'));
initHandler();
}
}}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Settings')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if config}
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div
id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'defaults'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'defaults';
}}
type="button"
>
<div class=" self-center mr-2">
<AdjustmentsHorizontal />
</div>
<div class=" self-center">{$i18n.t('Defaults')}</div>
</button>
<button
class="px-0.5 py-1 max-w-fit w-fit rounded-lg flex-1 lg:flex-none flex text-right transition {selectedTab ===
'display'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
selectedTab = 'display';
}}
type="button"
>
<div class=" self-center mr-2">
<Eye />
</div>
<div class=" self-center">{$i18n.t('Display')}</div>
</button>
</div>
<div class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] flex flex-col min-w-0">
<div class="w-full h-full overflow-y-auto overflow-x-hidden scrollbar-hidden">
{#if selectedTab === 'defaults'}
<ModelSelector
title={$i18n.t('Selected Models')}
tooltip={$i18n.t(
'Set the default models that are automatically selected for all users when a new chat is created.'
)}
models={$models}
bind:modelIds={defaultModelIds}
/>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<ModelSelector
title={$i18n.t('Pinned Models')}
tooltip={$i18n.t(
'Set the models that are automatically pinned to the sidebar for all users.'
)}
models={$models}
bind:modelIds={defaultPinnedModelIds}
/>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultPromptSuggestions = !showDefaultPromptSuggestions;
}}
>
<div class="text-xs text-gray-500 font-medium">
{$i18n.t('Prompt Suggestions')}
</div>
<div>
{#if showDefaultPromptSuggestions}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultPromptSuggestions}
<div class="mt-2">
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2 text-gray-500">
{$i18n.t(
'Adjusting these settings will apply changes universally to all users.'
)}
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultCapabilities = !showDefaultCapabilities;
}}
>
<div class="text-xs text-gray-500 font-medium">
{$i18n.t('Model Capabilities')}
</div>
<div>
{#if showDefaultCapabilities}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultCapabilities}
<div class="mt-2">
<Capabilities bind:capabilities={defaultCapabilities} />
{#if Object.keys(defaultCapabilities).filter((key) => defaultCapabilities[key]).length > 0}
{@const availableFeatures = Object.entries(defaultCapabilities)
.filter(
([key, value]) =>
value &&
['web_search', 'code_interpreter', 'image_generation'].includes(
key
)
)
.map(([key, value]) => key)}
{#if availableFeatures.length > 0}
<div class="mt-4">
<DefaultFeatures
{availableFeatures}
bind:featureIds={defaultFeatureIds}
/>
</div>
{/if}
{/if}
{#if defaultCapabilities.builtin_tools}
<div class="mt-4">
<BuiltinTools bind:builtinTools />
</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-50 dark:border-gray-800/10 my-2.5 w-full" />
<div>
<button
class="flex w-full justify-between items-center"
type="button"
on:click={() => {
showDefaultParams = !showDefaultParams;
}}
>
<div class="text-xs text-gray-500 font-medium">
{$i18n.t('Model Parameters')}
</div>
<div>
{#if showDefaultParams}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</div>
</button>
{#if showDefaultParams}
<div class="mt-2">
<AdvancedParams admin={true} bind:params={defaultParams} />
</div>
{/if}
</div>
{:else if selectedTab === 'display'}
<div>
<div class="flex flex-col w-full">
<button
class="mb-1 flex gap-2"
type="button"
on:click={() => {
sortKey = 'model';
if (sortOrder === 'asc') {
sortOrder = 'desc';
} else {
sortOrder = 'asc';
}
modelIds = modelIds
.filter((id) => id !== '')
.sort((a, b) => {
const nameA = $models.find((model) => model.id === a)?.name || a;
const nameB = $models.find((model) => model.id === b)?.name || b;
return sortOrder === 'desc'
? nameA.localeCompare(nameB)
: nameB.localeCompare(nameA);
});
}}
>
<div class="text-xs text-gray-500">{$i18n.t('Reorder Models')}</div>
{#if sortKey === 'model'}
<span class="font-normal self-center">
{#if sortOrder === 'asc'}
<ChevronUp className="size-3" />
{:else}
<ChevronDown className="size-3" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-3" />
</span>
{/if}
</button>
<ModelList bind:modelIds />
</div>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-3 text-sm font-medium gap-1.5">
<div>
<Tooltip
content={$i18n.t('This will delete all models including custom models')}
>
<button
class="text-sm font-normal text-gray-500 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 transition hover:underline"
type="button"
on:click={() => {
showResetModal = true;
}}
>
{$i18n.t('Reset')}
</button>
</Tooltip>
</div>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Save')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
</div>
</form>
{:else}
<div>
<Spinner className="size-5" />
</div>
{/if}
</div>
</div>
</div>
</Modal>
@@ -37,7 +37,8 @@
'sougou',
'firecrawl',
'external',
'yandex'
'yandex',
'youcom'
];
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external'];
@@ -155,7 +156,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={webConfig.WEB_SEARCH_ENGINE}
placeholder={$i18n.t('Select a engine')}
required
@@ -786,6 +787,19 @@
</Tooltip>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'youcom'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('You.com API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter You.com API Key')}
bind:value={webConfig.YOUCOM_API_KEY}
/>
</div>
</div>
{/if}
{#if webConfig.WEB_SEARCH_ENGINE === 'duckduckgo'}
@@ -936,7 +950,7 @@
</div>
<div class="flex items-center relative">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
class="w-fit pr-8 rounded-sm px-2 p-1 text-xs bg-transparent outline-hidden text-right"
bind:value={webConfig.WEB_LOADER_ENGINE}
placeholder={$i18n.t('Select a engine')}
>
+10 -12
View File
@@ -60,15 +60,14 @@
id="users-tabs-container"
class="mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<button
<a
id="overview"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/users/overview"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'overview'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/users/overview');
}}
>
<div class=" self-center mr-2">
<svg
@@ -83,17 +82,16 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Overview')}</div>
</button>
</a>
<button
<a
id="groups"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
href="/admin/users/groups"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
'groups'
? ''
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={() => {
goto('/admin/users/groups');
}}
>
<div class=" self-center mr-2">
<svg
@@ -108,7 +106,7 @@
</svg>
</div>
<div class=" self-center">{$i18n.t('Groups')}</div>
</button>
</a>
</div>
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
+169 -121
View File
@@ -12,16 +12,15 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import UsersSolid from '$lib/components/icons/UsersSolid.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Search from '$lib/components/icons/Search.svelte';
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import EditGroupModal from './Groups/EditGroupModal.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte';
import GroupItem from './Groups/GroupItem.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Check from '$lib/components/icons/Check.svelte';
import { Select } from 'bits-ui';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import {
getUserDefaultPermissions,
@@ -34,19 +33,34 @@
let loaded = false;
let groups = [];
let filteredGroups;
$: filteredGroups = groups.filter((user) => {
if (search === '') {
return true;
} else {
let name = user.name.toLowerCase();
const query = search.toLowerCase();
return name.includes(query);
}
});
let query = '';
let sortBy = 'members';
const sortItems = [
{ value: 'name', label: $i18n.t('Name') },
{ value: 'members', label: $i18n.t('Members') }
];
$: filteredGroups = groups
.filter((group) => {
if (query === '') {
return true;
} else {
let name = group.name.toLowerCase();
const q = query.toLowerCase();
return name.includes(q);
}
})
.sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name);
} else if (sortBy === 'members') {
return (b.member_count ?? 0) - (a.member_count ?? 0);
}
return 0;
});
let search = '';
let defaultPermissions = {};
let showAddGroupModal = false;
@@ -105,120 +119,154 @@
onSubmit={addGroupHandler}
/>
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Groups')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{groups.length}
</div>
</div>
<div class="flex gap-1">
<div class=" flex w-full space-x-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={search}
placeholder={$i18n.t('Search')}
/>
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Groups')}
</div>
<div>
<Tooltip content={$i18n.t('Create Group')}>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{groups.length}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
<button
class="px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
on:click={() => {
showAddGroupModal = !showAddGroupModal;
}}
>
<Plus className="size-3" strokeWidth="2.5" />
<div class="hidden md:block md:ml-1 text-xs">{$i18n.t('New Group')}</div>
</button>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class="flex items-center w-full space-x-2 py-0.5 px-3.5">
<div class="flex flex-1">
<div class="self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class="w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search Groups')}
placeholder={$i18n.t('Search Groups')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
aria-label={$i18n.t('Clear search')}
on:click={() => {
showAddGroupModal = !showAddGroupModal;
query = '';
}}
>
<Plus className="size-3.5" />
<XMark className="size-3" strokeWidth="2" />
</button>
</Tooltip>
</div>
{/if}
</div>
<Select.Root
selected={sortItems.find((item) => item.value === sortBy)}
items={sortItems}
onSelectedChange={(selectedItem) => {
sortBy = selectedItem.value;
}}
>
<Select.Trigger
class="relative flex items-center gap-0.5 px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl shrink-0"
aria-label={$i18n.t('Sort by')}
>
<Select.Value
class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
placeholder={$i18n.t('Sort by')}
/>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</Select.Trigger>
<Select.Content
class="rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sameWidth={false}
align="end"
>
{#each sortItems as item}
<Select.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
value={item.value}
label={item.label}
>
{item.label}
{#if sortBy === item.value}
<div class="ml-auto">
<Check />
</div>
{/if}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if filteredGroups.length !== 0}
<div class="my-2 px-3 grid grid-cols-1 gap-1">
{#each filteredGroups as group}
<GroupItem {group} {setGroups} {defaultPermissions} />
{/each}
</div>
{:else}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class="text-3xl mb-3">👥</div>
<div class="text-lg font-medium mb-1">{$i18n.t('No groups found')}</div>
<div class="text-gray-500 text-center text-xs">
{$i18n.t('Use groups to organize your users and assign permissions.')}
</div>
</div>
</div>
{/if}
</div>
<EditGroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}
custom={false}
onSubmit={updateDefaultPermissionsHandler}
/>
<button
class="flex items-center justify-between rounded-lg w-full transition mt-4"
aria-haspopup="dialog"
on:click={() => {
showDefaultPermissionsModal = true;
}}
>
<div class="flex items-center gap-2.5">
<div class="p-1.5 bg-black/5 dark:bg-white/10 rounded-full">
<UsersSolid className="size-4" />
</div>
<div class="text-left">
<div class=" text-sm font-medium">{$i18n.t('Default permissions')}</div>
<div class="flex text-xs mt-0.5">
{$i18n.t('applies to all users with the "user" role')}
</div>
</div>
</div>
</div>
<div>
{#if filteredGroups.length === 0}
<div class="flex flex-col items-center justify-center h-40">
<div class=" text-xl font-medium">
{$i18n.t('Organize your users')}
</div>
<div class="mt-1 text-sm dark:text-gray-300">
{$i18n.t('Use groups to group your users and assign permissions.')}
</div>
<div class="mt-3">
<button
class=" px-4 py-1.5 text-sm rounded-full bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition font-medium flex items-center space-x-1"
aria-label={$i18n.t('Create Group')}
on:click={() => {
showAddGroupModal = true;
}}
>
{$i18n.t('Create Group')}
</button>
</div>
</div>
{:else}
<div>
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-medium">
<div class="w-full basis-3/5">{$i18n.t('Group')}</div>
<div class="w-full basis-2/5 text-right">{$i18n.t('Users')}</div>
</div>
<hr class="mt-1.5 border-gray-100/30 dark:border-gray-850/30" />
{#each filteredGroups as group}
<div class="my-2">
<GroupItem {group} {setGroups} {defaultPermissions} />
</div>
{/each}
</div>
{/if}
<hr class="mb-2 border-gray-100/30 dark:border-gray-850/30" />
<EditGroupModal
bind:show={showDefaultPermissionsModal}
tabs={['permissions']}
bind:permissions={defaultPermissions}
custom={false}
onSubmit={updateDefaultPermissionsHandler}
/>
<button
class="flex items-center justify-between rounded-lg w-full transition pt-1"
on:click={() => {
showDefaultPermissionsModal = true;
}}
>
<div class="flex items-center gap-2.5">
<div class="p-1.5 bg-black/5 dark:bg-white/10 rounded-full">
<UsersSolid className="size-4" />
</div>
<div class="text-left">
<div class=" text-sm font-medium">{$i18n.t('Default permissions')}</div>
<div class="flex text-xs mt-0.5">
{$i18n.t('applies to all users with the "user" role')}
</div>
</div>
</div>
<div>
<ChevronRight strokeWidth="2.5" />
</div>
</button>
</div>
<div>
<ChevronRight strokeWidth="2.5" />
</div>
</button>
{/if}
@@ -77,7 +77,7 @@
<div class="flex items-center gap-2 p-1">
<select
class="text-sm bg-transparent dark:bg-gray-900 outline-hidden rounded-lg px-2"
class="text-sm bg-transparent outline-hidden rounded-lg px-2"
value={data?.config?.share ?? true}
on:change={(e) => {
const value = e.target.value;
@@ -8,7 +8,6 @@
import { deleteGroupById, updateGroupById } from '$lib/apis/groups';
import Pencil from '$lib/components/icons/Pencil.svelte';
import User from '$lib/components/icons/User.svelte';
import EditGroupModal from './EditGroupModal.svelte';
export let group = {
@@ -63,26 +62,34 @@
/>
<button
class="flex items-center gap-3 justify-between px-1 text-xs w-full transition"
class="flex space-x-4 cursor-pointer text-left w-full px-3.5 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
on:click={() => {
showEdit = true;
}}
>
<div class="flex items-center gap-1.5 w-full font-medium flex-1">
<div class="line-clamp-1">
{group.name}
</div>
</div>
<div class="w-full">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="text-sm font-medium line-clamp-1">{group.name}</div>
</div>
<div class="flex items-center gap-1.5 w-fit font-medium text-right justify-end">
{group?.member_count}
<div class="flex items-center gap-2 mt-0.5 line-clamp-1">
<div class="text-xs text-gray-500 shrink-0">
{$i18n.t('{{COUNT}} members', { COUNT: group?.member_count ?? 0 })}
</div>
<div>
<User className="size-3.5" />
</div>
{#if group?.description}
<div class="text-xs text-gray-500 line-clamp-1">
{group.description}
</div>
{/if}
</div>
</div>
<div class=" rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition">
<Pencil className="size-3.5" />
<div class="flex self-center ml-2">
<Pencil className="size-3.5" />
</div>
</div>
</div>
</button>
@@ -414,6 +414,22 @@
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Web Upload')}
</div>
<Switch bind:state={permissions.chat.web_upload} />
</div>
{#if defaultPermissions?.chat?.web_upload && !permissions.chat.web_upload}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
@@ -208,6 +208,7 @@
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
aria-label={$i18n.t('Search')}
placeholder={$i18n.t('Search')}
/>
</div>
@@ -357,6 +358,7 @@
<td class="px-3 py-1 min-w-[7rem] w-28">
<button
class=" translate-y-0.5"
aria-label={$i18n.t('Change User Role')}
on:click={() => {
selectedUser = user;
showEditUserModal = !showEditUserModal;
@@ -408,6 +410,7 @@
<Tooltip content={$i18n.t('Chats')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Chats')}
on:click={async () => {
showUserChatsModal = !showUserChatsModal;
selectedUser = user;
@@ -421,6 +424,7 @@
<Tooltip content={$i18n.t('Edit User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Edit User')}
on:click={async () => {
showEditUserModal = !showEditUserModal;
selectedUser = user;
@@ -447,6 +451,7 @@
<Tooltip content={$i18n.t('Delete User')}>
<button
class="self-center w-fit text-sm px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
aria-label={$i18n.t('Delete User')}
on:click={async () => {
showDeleteConfirmDialog = true;
selectedUser = user;
@@ -133,6 +133,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Add User')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
@@ -180,8 +181,9 @@
<div class="flex-1">
<select
class="dark:bg-gray-900 w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-hidden"
class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
aria-label={$i18n.t('Role')}
placeholder={$i18n.t('Enter Your Role')}
required
>
@@ -200,6 +202,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="text"
bind:value={_user.name}
aria-label={$i18n.t('Name')}
placeholder={$i18n.t('Enter Your Full Name')}
autocomplete="off"
required
@@ -217,6 +220,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
aria-label={$i18n.t('Email')}
placeholder={$i18n.t('Enter Your Email')}
required
/>
@@ -231,6 +235,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="password"
bind:value={_user.password}
aria-label={$i18n.t('Password')}
placeholder={$i18n.t('Enter Your Password')}
autocomplete="off"
required
@@ -72,6 +72,7 @@
<div class=" text-lg font-medium self-center">{$i18n.t('Edit User')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;
}}
@@ -138,8 +139,9 @@
<div class="flex-1">
<select
class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
bind:value={_user.role}
aria-label={$i18n.t('Role')}
disabled={_user.id == sessionUser.id}
required
>
@@ -158,6 +160,7 @@
class="w-full text-sm bg-transparent outline-hidden"
type="text"
bind:value={_user.name}
aria-label={$i18n.t('Name')}
placeholder={$i18n.t('Enter Your Name')}
autocomplete="off"
required
@@ -173,6 +176,7 @@
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
aria-label={$i18n.t('Email')}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
@@ -202,6 +206,7 @@
<SensitiveInput
class="w-full text-sm bg-transparent outline-hidden"
type="password"
aria-label={$i18n.t('New Password')}
placeholder={$i18n.t('Enter New Password')}
bind:value={_user.password}
autocomplete="new-password"
+7 -2
View File
@@ -6,7 +6,10 @@
let selected = '';
</script>
<div class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8">
<nav
aria-label="App navigation"
class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8"
>
<div class="flex justify-center relative">
{#if selected === 'home'}
<div class="absolute top-0 left-0 flex h-full">
@@ -16,6 +19,7 @@
<Tooltip content="Home" placement="right">
<button
aria-label="Home"
class=" cursor-pointer {selected === 'home' ? 'rounded-2xl' : 'rounded-full'}"
on:click={() => {
selected = 'home';
@@ -44,6 +48,7 @@
</div>
{/if}
<button
aria-label="Chat"
class=" cursor-pointer bg-transparent"
on:click={() => {
selected = '';
@@ -63,4 +68,4 @@
<Plus className="size-4" strokeWidth="2" />
</button>
</div> -->
</div>
</nav>
@@ -54,7 +54,7 @@
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadFilesHandler();
}}
@@ -64,7 +64,7 @@
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
screenCaptureHandler();
}}
+47 -13
View File
@@ -120,6 +120,7 @@
let eventConfirmationInput = false;
let eventConfirmationInputPlaceholder = '';
let eventConfirmationInputValue = '';
let eventConfirmationInputType = '';
let eventCallback = null;
let chatIdUnsubscriber: Unsubscriber | undefined;
@@ -329,24 +330,34 @@
// Set Default Features
if (model?.info?.meta?.defaultFeatureIds) {
if (model.info?.meta?.capabilities?.['image_generation']) {
if (
model.info?.meta?.capabilities?.['image_generation'] &&
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
) {
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
}
if (model.info?.meta?.capabilities?.['web_search']) {
if (
model.info?.meta?.capabilities?.['web_search'] &&
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
) {
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
}
if (model.info?.meta?.capabilities?.['code_interpreter']) {
if (
model.info?.meta?.capabilities?.['code_interpreter'] &&
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
) {
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
}
};
const showMessage = async (message, ignoreSettings = false) => {
await tick();
const showMessage = async (message, scroll = true) => {
const _chatId = JSON.parse(JSON.stringify($chatId));
let _messageId = JSON.parse(JSON.stringify(message.id));
@@ -366,18 +377,19 @@
history.currentId = _messageId;
await tick();
await tick();
await tick();
if (($settings?.scrollOnBranchChange ?? true) || ignoreSettings) {
if (($settings?.scrollOnBranchChange ?? true) && scroll) {
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' });
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
await tick();
await tick();
await tick();
saveChatHandler(_chatId, history);
};
@@ -415,6 +427,15 @@
message.files = data.files;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
// Auto-scroll to the embed once it's rendered in the DOM
await tick();
setTimeout(() => {
const embedEl = document.getElementById(`${event.message_id}-embeds-container`);
if (embedEl) {
embedEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
} else if (type === 'chat:message:error') {
message.error = data.error;
} else if (type === 'chat:message:follow_ups') {
@@ -504,6 +525,7 @@
eventConfirmationMessage = data.message;
eventConfirmationInputPlaceholder = data.placeholder;
eventConfirmationInputValue = data?.value ?? '';
eventConfirmationInputType = data?.type ?? '';
} else {
console.log('Unknown message type', data);
}
@@ -811,6 +833,11 @@
};
const uploadWeb = async (urls) => {
if ($user?.role !== 'admin' && !($user?.permissions?.chat?.web_upload ?? true)) {
toast.error($i18n.t('You do not have permission to upload web content.'));
return;
}
if (!Array.isArray(urls)) {
urls = [urls];
}
@@ -1011,12 +1038,16 @@
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) {
if (availableModels.length > 0) {
if (defaultModels && defaultModels.length > 0) {
// Set from default models
selectedModels = defaultModels.filter((modelId) => availableModels.includes(modelId));
}
// Set to first available model
selectedModels = [availableModels?.at(0) ?? ''];
if (
selectedModels.length === 0 ||
(selectedModels.length === 1 && selectedModels[0] === '')
) {
// Only fall back to first available model if default models didn't resolve
selectedModels = [availableModels?.at(0) ?? ''];
}
} else {
selectedModels = [''];
}
@@ -1044,6 +1075,8 @@
chatFiles = [];
params = {};
taskIds = null;
messageQueue = [];
if ($page.url.searchParams.get('youtube')) {
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
@@ -2514,6 +2547,7 @@
input={eventConfirmationInput}
inputPlaceholder={eventConfirmationInputPlaceholder}
inputValue={eventConfirmationInputValue}
inputType={eventConfirmationInputType}
on:confirm={(e) => {
if (e.detail) {
eventCallback(e.detail);
@@ -57,6 +57,7 @@
<button
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
aria-label="Close embed"
on:click={() => {
showControls.set(false);
showEmbeds.set(false);
@@ -254,6 +254,7 @@
>
{#each actions as action}
<button
aria-label={action.label}
class="px-1.5 py-[1px] hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
on:click={async () => {
selectedText = window.getSelection().toString();
@@ -291,6 +292,7 @@
id="floating-message-input"
class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm"
placeholder={$i18n.t('Ask a question')}
aria-label={$i18n.t('Ask a question')}
bind:value={floatingInputValue}
on:keydown={(e) => {
if (e.key === 'Enter') {
@@ -301,6 +303,7 @@
<div class="ml-1 mr-1">
<button
aria-label={$i18n.t('Submit question')}
class="{floatingInputValue !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center"
@@ -22,6 +22,7 @@
<div class=" text-lg font-medium self-center font-primary">{$i18n.t('Chat Controls')}</div>
<button
class="self-center"
aria-label={$i18n.t('Close chat controls')}
on:click={() => {
dispatch('close');
}}
+17 -1
View File
@@ -797,7 +797,10 @@
}
};
const onDragLeave = () => {
const onDragLeave = (e) => {
if (e.currentTarget.contains(e.relatedTarget)) {
return;
}
dragged = false;
};
@@ -821,6 +824,19 @@
shiftKey = true;
}
// Cmd/Ctrl+Shift+L to toggle dictation
if (e.key.toLowerCase() === 'l' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
e.preventDefault();
if (recording) {
// Confirm and stop recording
document.getElementById('confirm-recording-button')?.click();
} else {
// Start recording (same logic as voice-input-button click)
document.getElementById('voice-input-button')?.click();
}
return;
}
if (e.key === 'Escape') {
console.log('Escape');
dragged = false;
@@ -1,16 +1,8 @@
<script lang="ts">
import { knowledge, prompts } from '$lib/stores';
import { getPrompts } from '$lib/apis/prompts';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import Prompts from './Commands/Prompts.svelte';
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import Skills from './Commands/Skills.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { onMount } from 'svelte';
export let char = '';
export let query = '';
@@ -21,23 +13,8 @@
export let insertTextHandler = (text) => {};
let suggestionElement = null;
let loading = false;
let filteredItems = [];
const init = async () => {
loading = true;
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})()
]);
loading = false;
};
onMount(() => {
init();
});
const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
@@ -81,90 +58,83 @@
id="suggestions-container"
>
<div class="overflow-y-auto scrollbar-thin max-h-60">
{#if !loading}
{#if char === '/'}
<Prompts
bind:this={suggestionElement}
{query}
bind:filteredItems
prompts={$prompts ?? []}
onSelect={(e) => {
const { type, data } = e;
{#if char === '/'}
<Prompts
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if char === '#'}
<Knowledge
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if char === '#'}
<Knowledge
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'knowledge') {
insertTextHandler('');
if (type === 'knowledge') {
insertTextHandler('');
onUpload({
type: 'file',
data: data
});
} else if (type === 'web') {
insertTextHandler('');
onUpload({
type: 'file',
data: data
});
} else if (type === 'web') {
insertTextHandler('');
onUpload({
type: 'web',
data: data
});
}
}}
/>
{:else if char === '@'}
<Models
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
onUpload({
type: 'web',
data: data
});
}
}}
/>
{:else if char === '@'}
<Models
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'model') {
insertTextHandler('');
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{:else if char === '$'}
<Skills
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
onSelect({
type: 'model',
data: data
});
}
}}
/>
{:else if char === '$'}
<Skills
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'skill') {
command({
id: `${data.id}|${data.name}`,
label: data.name
});
if (type === 'skill') {
command({
id: `${data.id}|${data.name}`,
label: data.name
});
onSelect({
type: 'skill',
data: data
});
}
}}
/>
{/if}
{:else}
<div class="py-4 flex flex-col w-full rounded-xl text-gray-700 dark:text-gray-300">
<Spinner />
</div>
onSelect({
type: 'skill',
data: data
});
}
}}
/>
{/if}
</div>
</div>
@@ -3,21 +3,23 @@
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
import { getPrompts } from '$lib/apis/prompts';
const i18n = getContext('i18n');
export let query = '';
export let prompts = [];
export let onSelect = (e) => {};
let selectedPromptIdx = 0;
export let filteredItems = [];
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let debouncedQuery = '';
let items = [];
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
debouncedQuery = query;
getItems();
}, 200);
}
@@ -25,14 +27,21 @@
clearTimeout(searchDebounceTimer);
});
$: filteredItems = prompts
.filter((p) => p.command.toLowerCase().includes(debouncedQuery.toLowerCase()))
$: filteredItems = items
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
$: if (query) {
selectedPromptIdx = 0;
}
const getItems = async () => {
const res = await getPrompts(localStorage.token).catch(() => null);
if (res) {
items = res;
}
};
export const selectUp = () => {
selectedPromptIdx = Math.max(0, selectedPromptIdx - 1);
};
@@ -56,6 +56,9 @@
fileUploadCapableModels.length === selectedModels.length &&
($user?.role === 'admin' || $user?.permissions?.chat?.file_upload);
let webUploadEnabled = true;
$: webUploadEnabled = $user?.role === 'admin' || ($user?.permissions?.chat?.web_upload ?? true);
$: if (!fileUploadEnabled && files.length > 0) {
files = [];
}
@@ -138,7 +141,7 @@
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -162,7 +165,7 @@
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -185,19 +188,17 @@
</Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
content={!webUploadEnabled
? $i18n.t('You do not have permission to upload web content.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!webUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
if (webUploadEnabled) {
showAttachWebpageModal = true;
}
}}
@@ -217,7 +218,7 @@
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -302,7 +303,7 @@
{#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => {
uploadGoogleDriveHandler();
}}
@@ -339,7 +340,7 @@
{#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)}
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
@@ -448,7 +449,7 @@
{:else if tab === 'knowledge'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -467,7 +468,7 @@
{:else if tab === 'notes'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -486,7 +487,7 @@
{:else if tab === 'chats'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -505,7 +506,7 @@
{:else if tab === 'microsoft_onedrive'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
@@ -521,7 +522,7 @@
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('personal');
}}
@@ -534,7 +535,7 @@
{#if $config?.features?.enable_onedrive_business}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('organizations');
}}

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