Compare commits

..
114 Commits
Author SHA1 Message Date
+6 8d7d79d54b 0.6.33 (#18118)
* feat: improve ollama model management experience

This commit introduces several improvements to the Ollama model management modal:

- Adds a cancel button to the model pulling operation, using the existing 'x' button pattern.
- Adds a cancel button to the "Update All" models operation, allowing the user to cancel the update for the currently processing model.
- Cleans up toast notifications when updating all models. A single toast is now shown at the beginning and a summary toast at the end, preventing notification spam.
- Refactors the `ManageOllama.svelte` component to support these new cancellation features.
- Adds tooltips to all buttons in the modal to improve clarity.
- Disables buttons when their corresponding input fields are empty to prevent accidental clicks.

* fix

* i18n: improve Chinese translation

* fix: handle non‑UTF8 chars in third‑party responses without error

* German translation of new strings in i18n

* log web search queries only with level 'debug' instead of 'info'

* Tool calls now only include text and dont inlcude other content like image b64

* fix onedrive

* fix: discovery url

* fix: default permissions not being loaded

* fix: ai hallucination

* fix: non rich text input copy

* refac: rm print statements

* refac: disable direct models from model editors

* refac/fix: do not process xlsx files with azure doc intelligence

* Update pull_request_template.md

* Update generated image translation in DE-de

* added missing danish translations

* feat(onedrive): Enable search and "My Organization" pivot

* style(onedrive): Formatting fix

* feat: Implement toggling for vertical and horizontal flow layouts

This commit introduces the necessary logic and UI controls to allow users to switch the Flow component layout between vertical and horizontal orientations.

*   **`Flow.svelte` Refactoring:**
    *   Updates logic for calculating level offsets and node positions to consistently respect the current flow orientation.
    *   Adds a control panel using `<Controls>` and `<SwitchButton>` components.
    *   Provides user interface elements to easily switch the flow layout between horizontal and vertical orientations.

* build(deps): bump pydantic from 2.11.7 to 2.11.9 in /backend

Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.7 to 2.11.9.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/v2.11.9/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.7...v2.11.9)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump black from 25.1.0 to 25.9.0 in /backend

Bumps [black](https://github.com/psf/black) from 25.1.0 to 25.9.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/25.1.0...25.9.0)

---
updated-dependencies:
- dependency-name: black
  dependency-version: 25.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump markdown from 3.8.2 to 3.9 in /backend

Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.8.2 to 3.9.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.8.2...3.9.0)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: '3.9'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump chromadb from 1.0.20 to 1.1.0 in /backend

Bumps [chromadb](https://github.com/chroma-core/chroma) from 1.0.20 to 1.1.0.
- [Release notes](https://github.com/chroma-core/chroma/releases)
- [Changelog](https://github.com/chroma-core/chroma/blob/main/RELEASE_PROCESS.md)
- [Commits](https://github.com/chroma-core/chroma/compare/1.0.20...1.1.0)

---
updated-dependencies:
- dependency-name: chromadb
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump opentelemetry-api from 1.36.0 to 1.37.0

Bumps [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) from 1.36.0 to 1.37.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.36.0...v1.37.0)

---
updated-dependencies:
- dependency-name: opentelemetry-api
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* refac: ollama embed form data

* fix: non rich text handling

* fix: oauth client registration

* refac

* chore: dep bump

* chore: fastapi bump

* chore/refac: bump bcrypt and remove passlib

* Improving Korean Translation

* refac

* Improving Korean Translation

* feat: PWA share_target implementation

Co-Authored-By: gjveld <19951982+gjveld@users.noreply.github.com>

* refac: message input mobile detection behaviour

* feat: model_ids per folder

* Update translation.json (pt-BR)

inclusion of new translations of items that have been added

* refac

* refac

* refac

* refac

* refac/fix: temp chat

* refac

* refac: stop task

* refac/fix: azure audio escape

* refac: external tool validation

* refac/enh: start.sh additional args support

* refac

* refac: styling

* refac/fix: direct connection floating action buttons

* refac/fix: system prompt duplication

* refac/enh: openai tts additional params support

* refac

* feat: load data in parallel to accelerate page loading speed

* i18n: improve Chinese translation

* refac

* refac: model selector

* UPD: i18n es-ES Translation v0.6.33

UPD: i18n es-ES Translation v0.6.33

Updated new strings.

* refac

* improved query pref by querying only relevant columns

* refac/enh: docling params

* refac

* refac: openai additional headers support

* refac

* FEAT: Add Vega Char Visualizer Renderer

### FEAT: Add Vega Char Visualizer Renderer

Feature required in https://github.com/open-webui/open-webui/discussions/18022

Added npm vega lib to package.json
Added function for visualization renderer to src/libs/utils/index.ts
Added logic to src/lib/components/chat/Messages/CodeBlock.svelte

The treatment is similar as for mermaid diagrams.

Reference: https://vega.github.io/vega/

* refac

* chore

* refac

* FEAT: Add Vega-Lite Char Visualizer Renderer

### FEAT: Add Vega Char Visualizer Renderer

Add suport for Vega-Lite Specifications.
Vega-Lite is a "compiled" version of Vega Char Visualizer.
For be rendered with Vega it have to be compiled.
This PR add the check and compile if necessary, is a complement of recent Vega Renderer Feature added.

* refac

* refac/fix: switch

* enh/refac: url input handling

* refac

* refac: styling

* UPD: Add Validators & Error Toast for Mermaid & Vega diagrams

### UPD: Feat:  Add Validators & Error Toast for Mermaid & Vega diagrams

Description:
As many time the diagrams generated or entered have syntax errors the diagrams are not rendered due to that errors, but as there isn't any notification is difficult to know what happend.

This PR add validator and toast notification when error on Mermaid and Vega/Vega-Lite diagrams, helping the user to fix its.

* removed redundant knowledge API call

* Fix Code Format

* refac: model workspace view

* refac

* refac: knowledge

* refac: prompts

* refac: tools

* refac

* feat: attach folder

* refac: make tencentcloud-sdk-python optional

* refac/fix: oauth

* enh: ENABLE_OAUTH_EMAIL_FALLBACK

* refac/fix: folders

* Update requirements.txt

* Update pyproject.toml

* UPD: Add Validators & Error Toast for Mermaid & Vega diagrams

### UPD: Feat:  Add Validators & Error Toast for Mermaid & Vega diagrams

Description:
As many time the diagrams generated or entered have syntax errors the diagrams are not rendered due to that errors, but as there isn't any notification is difficult to know what happend.

This PR add validator and toast notification when error on Mermaid and Vega/Vega-Lite diagrams, helping the user to fix its.

Note:
Another possibility of integrating this Graph Visualizer is through its svelte component: https://github.com/vega/svelte-vega/tree/main/packages/svelte-vega

* Removed unused toast import & Code Format

* refac

* refac: external tool server view

* refac

* refac: overview

* refac: styling

* refac

* Update bug_report.yaml

* refac

* refac

* refac

* refac

* refac: oauth client fallback

* Fixed: Cannot handle batch sizes > 1 if no padding token is defined

Fixes Cannot handle batch sizes > 1 if no padding token is defined

For reranker models that do not have this defined in their config by using the eos_token_id if present as pad_token_id.

* refac: fallback to reasoning content

* fix(i18n): corrected typo in Spanish translation for "Reasoning Tags"

Typo fixed in Spanish translation file at line 1240 of `open-webui/src/lib/i18n/locales/es-ES/translation.json`:

- Incorrect: "Eriquetas de Razonamiento"
- Correct:   "Etiquetas de Razonamiento"

This improves clarity and consistency in the UI.

* refac/fix: ENABLE_STAR_SESSIONS_MIDDLEWARE

* refac/fix: redirect

* refac

* refac

* refac

* refac: web search error handling

* refac: source parsing

* refac: functions

* refac

* refac/enh: note pdf export

* refac/fix: mcp oauth2.1

* chore: format

* chore: Changelog (#17995)

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* refac

* chore: dep bump

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: silentoplayz <jacwoo21@outlook.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Jan Kessler <jakessle@uni-mainz.de>
Co-authored-by: Jacob Leksan <jacob.leksan@expedient.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: sinejespersen <sinejespersen@protonmail.com>
Co-authored-by: Selene Blok <selene.blok@rws.nl>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Cyp <cypher9715@naver.com>
Co-authored-by: gjveld <19951982+gjveld@users.noreply.github.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: _00_ <131402327+rgaricano@users.noreply.github.com>
Co-authored-by: expruc <eygabi01@gmail.com>
Co-authored-by: YetheSamartaka <55753928+YetheSamartaka@users.noreply.github.com>
Co-authored-by: Akutangulo <akutangulo@gmail.com>
2025-10-07 16:20:27 -05:00
Tim Jaeryang Baek 4d7fddaf7e Merge pull request #17892 from open-webui/dev
Dev
2025-09-29 10:07:13 -05:00
Timothy Jaeryang Baek 5438eb0057 doc: changelog 2025-09-29 10:04:21 -05:00
Tim Jaeryang Baek 88f7852928 Merge pull request #17890 from Classic298/patch-1
DEV branch changelog 0.6.32
2025-09-29 10:03:53 -05:00
Timothy Jaeryang Baek a5a098943c refac 2025-09-29 10:03:03 -05:00
Classic298 f267e479ba DEV branch changelog 0.6.32 2025-09-29 15:55:58 +02:00
Tim Jaeryang Baek 37d1c85c99 Merge pull request #17827 from open-webui/dev
0.6.32
2025-09-29 01:13:00 -05:00
Timothy Jaeryang Baek b6a485371d doc: changelog 2025-09-29 01:04:59 -05:00
Timothy Jaeryang Baek da236d5b38 chore: bump 2025-09-29 00:58:45 -05:00
Timothy Jaeryang Baek e7fa86aa26 chore: format 2025-09-29 00:58:21 -05:00
Timothy Jaeryang Baek 51a138aec3 refac: edit user modal 2025-09-29 00:51:06 -05:00
Timothy Jaeryang Baek aee7aaae68 refac 2025-09-29 00:35:21 -05:00
Tim Jaeryang Baek 2d94b8e905 Merge pull request #17837 from Classic298/milvus-multitenancy
feat: Impelement Milvus multitenancy // breaking: set milvus multitenancy as standard option (just like Qdrant already is)
2025-09-29 00:29:35 -05:00
Timothy Jaeryang Baek 8656bfc5f4 refac 2025-09-29 00:03:03 -05:00
Tim Jaeryang Baek d37f108ccd Merge pull request #17848 from Classic298/feat/group-permission-warning
Feat/chore: Add warning for conflicting group permissions / Refactored Permissions.svelte
2025-09-28 23:51:52 -05:00
Timothy Jaeryang Baek 88aca2e4d8 chore: format 2025-09-28 23:42:38 -05:00
Timothy Jaeryang Baek 234aa69753 refac 2025-09-28 22:16:11 -05:00
Timothy Jaeryang Baek f58fc753e3 feat/enh: embed citation 2025-09-28 22:15:47 -05:00
Timothy Jaeryang Baek 118549caf3 enh/fix: filter content metadata 2025-09-28 20:17:27 -05:00
Tim Jaeryang Baek 1c418a7f83 Merge pull request #17871 from silentoplayz/backend-json-model-import
feat: move JSON model import to backend for massive speedup
2025-09-28 19:25:40 -05:00
silentoplayz fe28097817 feat: refactor model import to a single backend endpoint
This refactors the model import functionality to improve performance and user experience by centralizing the logic on the backend.

Previously, the frontend would parse an imported JSON file and send an individual API request for each model, which was slow and inefficient.

This change introduces a new backend endpoint, `/api/v1/models/import`, that accepts a list of model objects. The frontend now reads the selected JSON file, parses it, and sends the entire payload to the backend in a single request. The backend then processes this list, creating or updating models as necessary.

This commit also includes the following fixes:
- Handles cases where the imported JSON contains models without `meta` or `params` fields by providing default empty values.
2025-09-28 18:49:42 -04:00
Timothy Jaeryang Baek 7bae9053ac refac: copy behaviour 2025-09-28 17:10:11 -05:00
silentoplayz 231d182c35 feat: move JSON model import to backend
This moves the JSON model import functionality to the backend. Instead of the frontend parsing the JSON file and sending multiple requests, it now uploads the file to a new endpoint (/api/v1/models/import), which processes the file and imports the models. This improves efficiency and provides better user feedback.
2025-09-28 18:09:58 -04:00
Timothy Jaeryang Baek fe54fb61aa fix: session middleware should be required by default 2025-09-28 16:35:13 -05:00
Timothy Jaeryang Baek 7e8ee46d2c refac 2025-09-28 15:18:12 -05:00
Timothy Jaeryang Baek da6748a68f refac 2025-09-28 14:59:30 -05:00
Timothy Jaeryang Baek 91b6483aa9 refac 2025-09-28 14:46:01 -05:00
Classic298 cf20f04bdc Update Permissions.svelte 2025-09-28 21:33:40 +02:00
Classic298 67b3929954 Delete src/lib/components/common/Warning.svelte 2025-09-28 21:27:31 +02:00
Classic298 ad9cae32dc Delete src/lib/components/icons/ExclamationTriangle.svelte 2025-09-28 21:27:20 +02:00
Classic298 a4e0c10f34 Delete src/lib/components/admin/Users/Groups/PermissionSwitch.svelte 2025-09-28 21:27:06 +02:00
Classic298 b1e63639cd ADD FAT WARNING - QDRANT 2025-09-28 21:17:07 +02:00
Classic298 0e99c43495 ADD FAT WARNING 2025-09-28 21:16:02 +02:00
Timothy Jaeryang Baek 4f06f29348 refac 2025-09-28 13:22:39 -05:00
Timothy Jaeryang Baek 4c2c58214c refac 2025-09-28 13:09:09 -05:00
Tim Jaeryang Baek 9a8c9ecb4d Merge pull request #17823 from ITOTI-Y/main
fix: correct messageId parameter in createMessagesList call
2025-09-28 12:57:56 -05:00
Timothy Jaeryang Baek 95462a394a refac 2025-09-28 12:54:31 -05:00
Tim Jaeryang Baek 11fd0735d1 Merge pull request #17851 from Classic298/feat-toggle-chat-title-in-browser-tab
feat: add toggle to show/hide chat title in browser tab
2025-09-28 12:53:19 -05:00
Tim Jaeryang Baek 2cb35e7142 Merge pull request #17843 from Classic298/feat/clone-default-group-permissions
feat/chore: Clone default group permissions for new groups and remove redundant modal
2025-09-28 12:50:59 -05:00
Tim Jaeryang Baek e71ed76165 Merge pull request #17857 from silentoplayz/feat-bulk-unarchive
feat: add backend handling for unarchiving all chats
2025-09-28 12:45:49 -05:00
Timothy Jaeryang Baek 742e2ff193 refac 2025-09-28 12:42:02 -05:00
Timothy Jaeryang Baek 3aad157006 refac 2025-09-28 12:26:13 -05:00
silentoplayz a572cf4842 feat: add backend handling for unarchiving all chats
The previous implementation for unarchiving all chats in `ArchivedChatsModal.svelte` was inefficient, as it sent a separate request for each chat, which could potentially overload the server.

This commit introduces a new backend endpoint, `/chats/unarchive/all`, to handle the bulk unarchiving of all chats for a user with a single API call.

The frontend has been updated to use this new endpoint, resolving the performance issue by minimizing the number of requests to the server.
2025-09-28 13:25:34 -04:00
Timothy Jaeryang Baek 97dae432ab refac 2025-09-28 12:23:20 -05:00
Timothy Jaeryang Baek db9d3d386d refac: mcp spec/response handling 2025-09-28 12:22:11 -05:00
Timothy Jaeryang Baek 4aa41aa139 refac 2025-09-28 12:00:19 -05:00
Tim Jaeryang Baek 5568a312cd Merge pull request #17835 from BakirBukvic/main
feat/ i18n: Add Bosnian (Latin) language
2025-09-28 11:48:35 -05:00
Tim Jaeryang Baek 2915bcf28c Merge pull request #17833 from ShirasawaSama/patch-20
i18n: improved Chinese (zh-CN & zh-TW) translation
2025-09-28 11:47:06 -05:00
Tim Jaeryang Baek 54ed62f7b0 Merge pull request #17832 from silentoplayz/pinned-chats-not-updating
fix: pinned chats not updating after archiving all chats
2025-09-28 11:46:53 -05:00
Tim Jaeryang Baek baf913c832 Merge pull request #17850 from silentoplayz/remove-unused-params
chore: Remove unused parameter definitions
2025-09-28 11:37:50 -05:00
google-labs-jules[bot] 57c02b2523 feat: add toggle to show/hide chat title in browser tab
This commit introduces a new setting in the Interface settings that allows users to control whether the chat title is used as the browser's tab title.

The following changes were made:
- Added `useChatTitleAsTabTitle` to the `Settings` type in `src/lib/stores/index.ts`.
- Added a toggle switch in `src/lib/components/chat/Settings/Interface.svelte` to manage this new setting.
- Updated `src/lib/components/chat/Chat.svelte` to conditionally set the document title based on the `useChatTitleAsTabTitle` setting.
2025-09-28 16:10:00 +00:00
silentoplayz 670dfca6a8 remove: used advanced params 2025-09-28 11:41:12 -04:00
Classic298 fb3eeaa126 Update Groups.svelte 2025-09-28 16:56:04 +02:00
google-labs-jules[bot] 30550d9190 Feat: Add warning for conflicting group permissions
This change introduces a visual warning in the group settings page. The warning appears when an admin attempts to disable a permission for a group that is already enabled in the default 'user' group. This is necessary because permissions are additive, and disabling a permission in a specific group will not revoke it if it's enabled in the default group.

To achieve this, the following changes were made:
- A new `PermissionSwitch.svelte` component was created to encapsulate the permission switch and its warning logic, avoiding redundant code.
- The `Groups.svelte` component was updated to correctly fetch the default user group's permissions.
- The `Permissions.svelte` component was refactored to use the new `PermissionSwitch.svelte` component, making the code cleaner and more maintainable.
2025-09-28 13:42:10 +00:00
google-labs-jules[bot] cafe748b1b feat(groups): Clone default group permissions for new groups
When creating a new user group, the permissions for the new group will now be pre-populated with the same permissions as the "default user group".

This is achieved by removing the separate `AddGroupModal` and instead using the `EditGroupModal` for both creating and editing groups. When creating a new group, the `EditGroupModal` is now pre-populated with the default permissions, saving administrators from having to manually configure them each time.

This change simplifies the codebase by removing a redundant component and directly addresses the user's request to streamline the group creation process.
2025-09-28 12:56:39 +00:00
Classic298 1773a4d4b8 typo 2025-09-28 11:14:27 +02:00
Classic298 27af4506df Update config.py 2025-09-28 11:10:37 +02:00
Classic298 01d4a8ab7a Update factory.py 2025-09-28 11:06:29 +02:00
Classic298 8dc43f9e3a Create milvus_multitenancy.py 2025-09-28 11:05:15 +02:00
Classic298 50a2204046 add env vars 2025-09-28 11:04:24 +02:00
bakir baed3035ac add bosnian to languages.json 2025-09-28 09:23:33 +02:00
bakir 88bfe777cd add Bosnian language pack 2025-09-28 09:19:51 +02:00
Shirasawa f912b06b42 i18n: improve Chinese translation 2025-09-28 03:39:00 +00:00
silentoplayz 2b5dca2929 fix: pinned chats not updating after archiving all chats
This commit fixes a UI bug where pinned chats would remain visible in the sidebar after all chats were archived from the Data Controls menu.

The `archiveAllChatsHandler` in `DataControls.svelte` has been updated to clear the `pinnedChats` store, ensuring the sidebar UI is correctly updated.
2025-09-27 22:44:04 -04:00
Tim Jaeryang Baek 2c80f60f3e Merge pull request #17812 from silentoplayz/long-text-truncation
fix: truncate long names in delete confirmation modals
2025-09-27 16:30:33 -05:00
Tim Jaeryang Baek 1283a8ce79 Merge pull request #17819 from silentoplayz/fix-layout-and-i18n
fix: i81n.t and correct button layout issue
2025-09-27 16:30:07 -05:00
ITOTI 68a4fdf0e9 fix: correct messageId parameter in createMessagesList call
- Fix FloatingButtons component to use messageId instead of id
2025-09-27 20:41:05 +00:00
silentoplayz 27cd87e9ad fix: i81n.t and correct button layout issue
1.  **i18n Regression:** A latent bug in `src/routes/(app)/workspace/models/create/+page.svelte` was causing an `i18n.t is not a function` error. This was due to an incorrect call to the `i18n` Svelte store. The fix corrects the call to use the proper auto-subscription syntax (`$i18n.t()`).

2.  **Vertical Button Text:** In `src/lib/components/playground/Chat.svelte`, the "Assistant"/"User" role button's text was displaying vertically. This was caused by a `flex-1` class on its container, which has been removed.
2025-09-27 15:45:25 -04:00
Timothy Jaeryang Baek 272c6f5ec5 refac 2025-09-27 04:38:54 -05:00
Timothy Jaeryang Baek 86ef57f6c3 refac 2025-09-27 04:33:00 -05:00
Timothy Jaeryang Baek b4eea78aff refac 2025-09-27 04:06:42 -05:00
Timothy Jaeryang Baek 1a18928c94 enh: reply to message 2025-09-27 04:05:12 -05:00
Timothy Jaeryang Baek d7c54d92b5 refac 2025-09-26 22:57:38 -05:00
silentoplayz e62f2b3c75 fix: truncate more long names in delete confirmation modals 2025-09-26 23:56:18 -04:00
Timothy Jaeryang Baek 45e1c72613 refac 2025-09-26 22:53:57 -05:00
Timothy Jaeryang Baek 23f62a7312 enh: tool server import/export 2025-09-26 22:53:02 -05:00
Timothy Jaeryang Baek d1a6c6b209 refac 2025-09-26 22:09:27 -05:00
Timothy Jaeryang Baek bad7d69a58 feat/enh: external tool server manual JSON spec 2025-09-26 22:02:48 -05:00
Timothy Jaeryang Baek a05dab6298 refac 2025-09-26 21:16:34 -05:00
Timothy Jaeryang Baek c80bb31968 refac/enh: folder optimization 2025-09-26 20:48:17 -05:00
Timothy Jaeryang Baek 54beeeaf72 refac: tools 2025-09-26 19:01:22 -05:00
Tim Jaeryang Baek 1de5827eb3 Merge pull request #17805 from silentoplayz/fix-username-truncation
fix: truncate long usernames in UI
2025-09-26 17:53:37 -05:00
Timothy Jaeryang Baek b77848244b refac: user valves 2025-09-26 17:49:42 -05:00
silentoplayz 16cf973ce5 fix: truncate long usernames in UI
Long usernames were causing layout issues in several parts of the application. This change truncates long usernames with an ellipsis to prevent them from overflowing.

The following areas have been fixed:
- Edit User modal
- User Chats modal
- Edit User Group modal
- Users table in the admin overview

fix: truncate long usernames in UI

Long usernames were causing layout issues in several parts of the application. This change truncates long usernames with an ellipsis to prevent them from overflowing.

The following areas have been fixed:
- Edit User modal
- User Chats modal
- Edit User Group modal
- Users table in the admin overview

Revert "fix: truncate long usernames in UI"

This reverts commit b623fdc95d0c494228b49f9369db3bbb3042cef0.
2025-09-26 18:30:48 -04:00
Tim Jaeryang Baek 807a8be299 Merge pull request #17767 from ShirasawaSama/patch-31
fix: fixed the hover effect for the MessageInput Integrations button
2025-09-26 16:58:15 -05:00
Tim Jaeryang Baek 5765a87720 Merge pull request #17779 from silentoplayz/fix-set-as-default
fix: 'Set as default' click position
2025-09-26 16:57:31 -05:00
Timothy Jaeryang Baek a1829f6a3e refac: styling 2025-09-26 16:55:38 -05:00
Timothy Jaeryang Baek b8c3e5ed3e refac 2025-09-26 16:43:12 -05:00
Tim Jaeryang Baek d10b518f42 Merge pull request #17801 from Classic298/feature/notes-public-sharing-permission
feat: add permission toggle for public sharing of notes
2025-09-26 16:11:17 -05:00
Timothy Jaeryang Baek 4997ef2662 refac 2025-09-26 15:57:03 -05:00
Timothy Jaeryang Baek cda4c95c86 fix: default tool calling 2025-09-26 15:50:16 -05:00
google-labs-jules[bot] 41e4e7395c feat: add permission toggle for public sharing of notes
This commit introduces a new permission toggle that allows administrators to control whether users can publicly share their notes.

- Adds a new environment variable `USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING` to control the default setting.
- Adds a `public_notes` permission to the `sharing` section of the user permissions.
- Adds a toggle switch to the admin panel for managing this permission.
- Implements backend logic to enforce the permission when a user attempts to share a note publicly.
2025-09-26 20:48:01 +00:00
Timothy Jaeryang Baek 680783266c refac/fix: tool response header type check 2025-09-26 15:42:17 -05:00
Timothy Jaeryang Baek 99d7773230 refac: styling 2025-09-26 15:00:06 -05:00
Timothy Jaeryang Baek ac08529380 refac 2025-09-26 14:40:30 -05:00
Tim Jaeryang Baek a80d5240f6 Merge pull request #17796 from silentoplayz/truncate-selector-tags
fix: truncate long filter tags in model selector and prevent wrapping
2025-09-26 14:35:58 -05:00
Tim Jaeryang Baek 9b86932714 Merge pull request #17797 from SZegotaM/dev
i18n(de-DE): add translations for newly added UI items + other translation fixes
2025-09-26 14:35:12 -05:00
Timothy Jaeryang Baek 0431ad9cc4 refac: get_discovery_urls 2025-09-26 14:34:26 -05:00
Tim Jaeryang Baek f8a3ed2d18 Merge pull request #17770 from Classic298/feat-milvus-diskann-support
feat: Add DISKANN index type support for Milvus
2025-09-26 14:23:53 -05:00
SZegotaM dcb0933149 Update German translations in translation.json
Added missing German translation
2025-09-26 21:16:35 +02:00
silentoplayz b516431569 fix: truncate long filter tags in model selector and prevent wrapping
This commit addresses an issue where long filter tags at the top of the model selector dropdown were not truncated correctly and would wrap to a new line, causing layout issues.

- A hard character limit of 16 characters is applied to the filter tags within the `Selector.svelte` component. Tags longer than 16 characters are truncated with an ellipsis (...) directly in the code. The full tag name remains available in the tooltip.
- The `whitespace-nowrap` class has been added to the tag container to ensure that the tags remain on a single, horizontally scrollable line.
2025-09-26 15:03:44 -04:00
Tim Jaeryang Baek af6a25eed7 Merge pull request #17769 from Classic298/fix-milvus-limit-error
Fix: milvus error because the limit set to None by default
2025-09-26 12:54:00 -05:00
Tim Jaeryang Baek acdafcd18d Merge pull request #17777 from ShirasawaSama/patch-33
fix: show error message when the uploading file is modified
2025-09-26 12:52:09 -05:00
Tim Jaeryang Baek a97cdc0da0 Merge pull request #17791 from silentoplayz/truncate-models-tag
Fix: truncate long model tags with a 32 characters limit
2025-09-26 12:46:45 -05:00
silentoplayz ac6292b812 Fix: truncate long model tags with a character limit
Long model tags on the Models page in the workspace section were not truncated consistently, which could cause layout issues.

This change implements a hard character limit of 32 characters on the model tags. Tags longer than 32 characters are truncated with an ellipsis (...) directly in the code. The full tag name remains available in the tooltip.
2025-09-26 13:28:35 -04:00
Tim Jaeryang Baek d126c23ef8 Merge pull request #17783 from joaoback/patch-10
Update translation.json (pt-BR)
2025-09-26 12:26:25 -05:00
joaoback 36da2b06a5 Update translation.json (pt-BR)
translation of the new items that were included in the latest version.
2025-09-26 09:18:27 -03:00
silentoplayz 90a5b3befb fix: 'Set as default' 2025-09-26 06:42:23 -04:00
Shirasawa 42faa63227 fix: show error message when the uploading file is modified 2025-09-26 17:31:16 +08:00
Classic298 9e3d5407ae Merge branch 'open-webui:main' into feat-milvus-diskann-support 2025-09-26 10:43:01 +02:00
Classic298 b550d78905 Merge branch 'open-webui:main' into fix-milvus-limit-error 2025-09-26 10:42:53 +02:00
google-labs-jules[bot] 123dbf152e feat: Add DISKANN index type support for Milvus
This commit introduces support for the DISKANN index type in the Milvus vector database integration.

Changes include:
- Added `MILVUS_DISKANN_MAX_DEGREE` and `MILVUS_DISKANN_SEARCH_LIST_SIZE` configuration variables.
- Updated the Milvus client to recognize and configure the DISKANN index type during collection creation.
2025-09-26 06:54:06 +00:00
google-labs-jules[bot] e7ccaf6e78 Fix: milvus error because the limit set to None by default
The pymilvus library expects -1 for unlimited queries, but the code was passing None, which caused a TypeError. This commit changes the default value of the limit parameter in the query method from None to -1. It also updates the call site in the get method to pass -1 instead of None and updates the type hint and a comment to reflect this change.
2025-09-26 06:39:54 +00:00
Shirasawa 15bd5040f1 fix: fixed the hover effect for the MessageInput Integrations button 2025-09-26 11:56:34 +08:00
215 changed files with 11592 additions and 4530 deletions

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ body:
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.** - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.**
- Check for opened, **but also for (recently) CLOSED issues** as the issue you are trying to report **might already have been fixed!**
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication. - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
- **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality. - **Contributing**: If you encounter an issue, consider submitting a pull request or forking the project. We prioritize preventing contributor burnout to maintain Open WebUI's quality.
+5 -4
View File
@@ -4,14 +4,15 @@
**Before submitting, make sure you've checked the following:** **Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Please verify that the pull request targets the `dev` branch. - [ ] **Target branch:** Verify that the pull request targets the `dev` branch. Not targeting the `dev` branch may lead to immediate closure of the PR.
- [ ] **Description:** Provide a concise description of the changes made in this pull request. - [ ] **Description:** Provide a concise description of the changes made in this pull request.
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description. - [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
- [ ] **Documentation:** Have you updated relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs), or other documentation sources? - [ ] **Documentation:** If necessary, update relevant documentation [Open WebUI Docs](https://github.com/open-webui/docs) like environment variables, the tutorials, or other documentation sources.
- [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation?
- [ ] **Testing:** Have you written and run sufficient tests to validate the changes? - [ ] **Testing:** Perform manual tests to verify the implemented fix/feature works as intended AND does not break any other functionality. Take this as an opportunity to make screenshots of the feature/fix and include it in the PR description.
- [ ] **Agentic AI Code:**: Confirm this Pull Request is **not written by any AI Agent** or has at least gone through additional human review **and** manual testing. If any AI Agent is the co-author of this PR, it may lead to immediate closure of the PR.
- [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards? - [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards?
- [ ] **Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following: - [ ] **Title Prefix:** To clearly categorize this pull request, prefix the pull request title using one of the following:
- **BREAKING CHANGE**: Significant changes that may affect compatibility - **BREAKING CHANGE**: Significant changes that may affect compatibility
- **build**: Changes that affect the build system or external dependencies - **build**: Changes that affect the build system or external dependencies
- **ci**: Changes to our continuous integration processes or workflows - **ci**: Changes to our continuous integration processes or workflows
+105
View File
@@ -5,6 +5,111 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.33] - 2025-10-08
### Added
- 🎨 Workspace interface received a comprehensive redesign across Models, Knowledge, Prompts, and Tools sections, featuring reorganized controls, view filters for created vs shared items, tag selectors, improved visual hierarchy, and streamlined import/export functionality. [Commit](https://github.com/open-webui/open-webui/commit/2c59a288603d8c5f004f223ee00fef37cc763a8e), [Commit](https://github.com/open-webui/open-webui/commit/6050c86ab6ef6b8c96dd3f99c62a6867011b67a4), [Commit](https://github.com/open-webui/open-webui/commit/96ecb47bc71c072aa34ef2be10781b042bef4e8c), [Commit](https://github.com/open-webui/open-webui/commit/2250d102b28075a9611696e911536547abb8b38a), [Commit](https://github.com/open-webui/open-webui/commit/23c8f6d507bfee75ab0015a3e2972d5c26f7e9bf), [Commit](https://github.com/open-webui/open-webui/commit/a743b16728c6ae24b8befbc2d7f24eb9e20c4ad5)
- 🛠️ Functions admin interface received a comprehensive redesign with creator attribution display, ownership filters for created vs shared items, improved organization, and refined styling. [Commit](https://github.com/open-webui/open-webui/commit/f5e1a42f51acc0b9d5b63a33c1ca2e42470239c1)
- ⚡ Page initialization performance is significantly improved through parallel data loading and optimized folder API calls, reducing initial page load time. [#17559](https://github.com/open-webui/open-webui/pull/17559), [#17889](https://github.com/open-webui/open-webui/pull/17889)
- ⚡ Chat overview component is now dynamically loaded on demand, reducing initial page bundle size by approximately 470KB and improving first-screen loading speed. [#17595](https://github.com/open-webui/open-webui/pull/17595)
- 📁 Folders can now be attached to chats using the "#" command, automatically expanding to include all files within the folder for streamlined knowledge base integration. [Commit](https://github.com/open-webui/open-webui/commit/d2cb78179d66dc85188172a08622d4c97a2ea1ee)
- 📱 Progressive Web App now supports Android share target functionality, allowing users to share web pages, YouTube videos, and text directly to Open WebUI from the system share menu. [#17633](https://github.com/open-webui/open-webui/pull/17633), [#17125](https://github.com/open-webui/open-webui/issues/17125)
- 🗄️ Redis session storage is now available as an experimental option for OAuth authentication flows via the ENABLE_STAR_SESSIONS_MIDDLEWARE environment variable, providing shared session state across multi-replica deployments to address CSRF errors, though currently only basic Redis setups are supported. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373), [Docs:Commit](https://github.com/open-webui/docs/commit/14052347f165d1b597615370373d7289ce44c7f9)
- 📊 Vega and Vega-Lite chart visualization renderers are now supported in code blocks, enabling inline rendering of data visualizations with automatic compilation of Vega-Lite specifications. [#18033](https://github.com/open-webui/open-webui/pull/18033), [#18040](https://github.com/open-webui/open-webui/pull/18040), [#18022](https://github.com/open-webui/open-webui/issues/18022)
- 🔗 OpenAI connections now support custom HTTP headers, enabling users to configure authentication and routing headers for specific deployment requirements. [#18021](https://github.com/open-webui/open-webui/pull/18021), [#9732](https://github.com/open-webui/open-webui/discussions/9732)
- 🔐 OpenID Connect authentication now supports OIDC providers without email scope via the ENABLE_OAUTH_WITHOUT_EMAIL environment variable, enabling compatibility with identity providers that don't expose email addresses. [#18047](https://github.com/open-webui/open-webui/pull/18047), [#18045](https://github.com/open-webui/open-webui/issues/18045)
- 🤖 Ollama model management modal now features individual model update cancellation, comprehensive tooltips for all buttons, and streamlined notification behavior to reduce toast spam. [#16863](https://github.com/open-webui/open-webui/pull/16863)
- ☁️ OneDrive file picker now includes search functionality and "My Organization" pivot for business accounts, enabling easier file discovery across organizational content. [#17930](https://github.com/open-webui/open-webui/pull/17930), [#17929](https://github.com/open-webui/open-webui/issues/17929)
- 📊 Chat overview flow diagram now supports toggling between vertical and horizontal layout orientations for improved visualization flexibility. [#17941](https://github.com/open-webui/open-webui/pull/17941)
- 🔊 OpenAI Text-to-Speech engine now supports additional parameters, allowing users to customize TTS behavior with provider-specific options via JSON configuration. [#17985](https://github.com/open-webui/open-webui/issues/17985), [#17188](https://github.com/open-webui/open-webui/pull/17188)
- 🛠️ Tool server list now displays server name, URL, and type (OpenAPI or MCP) for easier identification and management. [#18062](https://github.com/open-webui/open-webui/issues/18062)
- 📁 Folders now remember the last selected model, automatically applying it when starting new chats within that folder. [#17836](https://github.com/open-webui/open-webui/issues/17836)
- 🔢 Ollama embedding endpoint now supports the optional dimensions parameter for controlling embedding output size, compatible with Ollama v0.11.11 and later. [#17942](https://github.com/open-webui/open-webui/pull/17942)
- ⚡ Workspace knowledge page load time is improved by removing redundant API calls, enhancing overall responsiveness. [#18057](https://github.com/open-webui/open-webui/pull/18057)
- ⚡ File metadata query performance is enhanced by selecting only relevant columns instead of retrieving entire records, reducing database overhead. [#18013](https://github.com/open-webui/open-webui/pull/18013)
- 📄 Note PDF exports now include titles and properly render in dark mode with appropriate background colors. [Commit](https://github.com/open-webui/open-webui/commit/216fb5c3db1a223ffe6e72d97aa9551fe0e2d028)
- 📄 Docling document extraction now supports additional parameters for VLM pipeline configuration, enabling customized vision model settings. [#17363](https://github.com/open-webui/open-webui/pull/17363)
- ⚙️ Server startup script now supports passing arbitrary arguments to uvicorn, enabling custom server configuration options. [#17919](https://github.com/open-webui/open-webui/pull/17919), [#17918](https://github.com/open-webui/open-webui/issues/17918)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for German, Danish, Spanish, Korean, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
### Fixed
- 💬 System prompts are no longer duplicated in chat requests, eliminating confusion and excessive token usage caused by repeated instructions being sent to models. [#17198](https://github.com/open-webui/open-webui/issues/17198), [#16855](https://github.com/open-webui/open-webui/issues/16855)
- 🔐 MCP OAuth 2.1 authentication now complies with the standard by implementing PKCE with S256 code challenge method and explicitly passing client credentials during token authorization, resolving "code_challenge: Field required" and "client_id: Field required" errors when connecting to OAuth-secured MCP servers. [Commit](https://github.com/open-webui/open-webui/commit/911a114ad459f5deebd97543c13c2b90196efb54), [#18010](https://github.com/open-webui/open-webui/issues/18010), [#18087](https://github.com/open-webui/open-webui/pull/18087)
- 🔐 OAuth signup flow now handles password hashing correctly by migrating from passlib to native bcrypt, preventing failures when passwords exceed 72 bytes. [#17917](https://github.com/open-webui/open-webui/issues/17917)
- 🔐 OAuth token refresh errors are resolved by properly registering and storing OAuth clients, fixing "Constructor parameter should be str" exceptions for Google, Microsoft, and OIDC providers. [#17829](https://github.com/open-webui/open-webui/issues/17829)
- 🔐 OAuth server metadata URL is now correctly accessed via the proper attribute, fixing automatic token refresh and logout functionality for Microsoft OAuth provider when OPENID_PROVIDER_URL is not set. [#18065](https://github.com/open-webui/open-webui/pull/18065)
- 🔐 OAuth credential decryption failures now allow the application to start gracefully with clear error messages instead of crashing, preventing complete service outages when WEBUI_SECRET_KEY mismatches occur during database migrations or environment changes. [#18094](https://github.com/open-webui/open-webui/pull/18094), [#18092](https://github.com/open-webui/open-webui/issues/18092)
- 🔐 OAuth 2.1 server discovery now correctly attempts all configured discovery URLs in sequence instead of only trying the first URL. [#17906](https://github.com/open-webui/open-webui/pull/17906), [#17904](https://github.com/open-webui/open-webui/issues/17904), [#18026](https://github.com/open-webui/open-webui/pull/18026)
- 🔐 Login redirect now correctly honors the redirect query parameter after authentication, ensuring users are returned to their intended destination with query parameters intact instead of defaulting to the homepage. [#18071](https://github.com/open-webui/open-webui/issues/18071)
- ☁️ OneDrive Business integration authentication regression is resolved, ensuring the popup now properly triggers when connecting to OneDrive accounts. [#17902](https://github.com/open-webui/open-webui/pull/17902), [#17825](https://github.com/open-webui/open-webui/discussions/17825), [#17816](https://github.com/open-webui/open-webui/issues/17816)
- 👥 Default group settings now persist correctly after page navigation, ensuring configuration changes are properly saved and retained. [#17899](https://github.com/open-webui/open-webui/issues/17899), [#18003](https://github.com/open-webui/open-webui/issues/18003)
- 📁 Folder data integrity is now verified on retrieval, automatically fixing orphaned folders with invalid parent references and ensuring proper cascading deletion of nested folder structures. [Commit](https://github.com/open-webui/open-webui/commit/5448618dd5ea181b9635b77040cef60926a902ff)
- 🗄️ Redis Sentinel and Redis Cluster configurations with the experimental ENABLE_STAR_SESSIONS_MIDDLEWARE feature are now properly isolated by making the feature opt-in only, preventing ReadOnlyError failures when connecting to read replicas in multi-node Redis deployments. [#18073](https://github.com/open-webui/open-webui/issues/18073)
- 📊 Mermaid and Vega diagram rendering now displays error toast notifications when syntax errors are detected, helping users identify and fix diagram issues instead of silently failing. [#18068](https://github.com/open-webui/open-webui/pull/18068)
- 🤖 Reasoning models that return reasoning_content instead of content no longer cause NoneType errors during chat title generation, follow-up suggestions, and tag generation. [#18080](https://github.com/open-webui/open-webui/pull/18080)
- 📚 Citation rendering now correctly handles multiple source references in a single bracket, parsing formats like [1,2] and [1, 2] into separate clickable citation links. [#18120](https://github.com/open-webui/open-webui/pull/18120)
- 🔍 Web search now handles individual source failures gracefully, continuing to process remaining sources instead of failing entirely when a single URL is unreachable or returns an error. [Commit](https://github.com/open-webui/open-webui/commit/e000494e488090c5f66989a2b3f89d3eaeb7946b), [Commit](https://github.com/open-webui/open-webui/commit/53e98620bff38ab9280aee5165af0a704bdd99b9)
- 🔍 Hybrid search with reranking now handles empty result sets gracefully instead of crashing with ValueError when all results are filtered out due to relevance thresholds. [#18096](https://github.com/open-webui/open-webui/issues/18096)
- 🔍 Reranking models without defined padding tokens now work correctly by automatically falling back to eos_token_id as pad_token_id, fixing "Cannot handle batch sizes > 1" errors for models like Qwen3-Reranker. [#18108](https://github.com/open-webui/open-webui/pull/18108), [#16027](https://github.com/open-webui/open-webui/discussions/16027)
- 🔍 Model selector search now correctly returns results for non-admin users by dynamically updating the search index when the model list changes, fixing a race condition that caused empty search results. [#17996](https://github.com/open-webui/open-webui/pull/17996), [#17960](https://github.com/open-webui/open-webui/pull/17960)
- ⚡ Task model function calling performance is improved by excluding base64 image data from payloads, significantly reducing token count and memory usage when images are present in conversations. [#17897](https://github.com/open-webui/open-webui/pull/17897)
- 🤖 Text selection "Ask" action now correctly recognizes and uses local models configured via direct connections instead of only showing external provider models. [#17896](https://github.com/open-webui/open-webui/issues/17896)
- 🛑 Task cancellation API now returns accurate response status, correctly reporting successful cancellations instead of incorrectly indicating failures. [#17920](https://github.com/open-webui/open-webui/issues/17920)
- 💬 Follow-up query suggestions are now generated and displayed in temporary chats, matching the behavior of saved chats. [#14987](https://github.com/open-webui/open-webui/issues/14987)
- 🔊 Azure Text-to-Speech now properly escapes special characters like ampersands in SSML, preventing HTTP 400 errors and ensuring audio generation succeeds for all text content. [#17962](https://github.com/open-webui/open-webui/issues/17962)
- 🛠️ OpenAPI tool server calls with optional parameters now execute successfully even when no arguments are provided, removing the incorrect requirement for a request body. [#18036](https://github.com/open-webui/open-webui/issues/18036)
- 🛠️ MCP mode tool server connections no longer incorrectly validate the OpenAPI path field, allowing seamless switching between OpenAPI and MCP connection types. [#17989](https://github.com/open-webui/open-webui/pull/17989), [#17988](https://github.com/open-webui/open-webui/issues/17988)
- 🛠️ Third-party tool responses containing non-UTF8 or invalid byte sequences are now handled gracefully without causing request failures. [#17882](https://github.com/open-webui/open-webui/pull/17882)
- 🎨 Workspace filter dropdown now correctly renders model tags as strings instead of displaying individual characters, fixing broken filtering interface when models have multiple tags. [#18034](https://github.com/open-webui/open-webui/issues/18034)
- ⌨️ Ctrl+Enter keyboard shortcut now correctly sends messages in mobile and narrow browser views on Chrome instead of inserting newlines. [#17975](https://github.com/open-webui/open-webui/issues/17975)
- ⌨️ Tab characters are now preserved when pasting code or formatted text into the chat input box in plain text mode. [#17958](https://github.com/open-webui/open-webui/issues/17958)
- 📋 Text selection copying from the chat input box now correctly copies only the selected text instead of the entire textbox content. [#17911](https://github.com/open-webui/open-webui/issues/17911)
- 🔍 Web search query logging now uses debug level instead of info level, preventing user search queries from appearing in production logs. [#17888](https://github.com/open-webui/open-webui/pull/17888)
- 📝 Debug print statements in middleware were removed to prevent excessive log pollution and respect configured logging levels. [#17943](https://github.com/open-webui/open-webui/issues/17943)
### Changed
- 🗄️ Milvus vector database dependency is updated from pymilvus 2.5.0 to 2.6.2, ensuring compatibility with newer Milvus versions but requiring users on older Milvus instances to either upgrade their database or manually downgrade the pymilvus package. [#18066](https://github.com/open-webui/open-webui/pull/18066)
## [0.6.32] - 2025-09-29
### Added
- ⚡ JSON model import moved to backend processing for significant performance improvements when importing large model files. [#17871](https://github.com/open-webui/open-webui/pull/17871)
- ⚠️ Visual warnings for group permissions that display when a permission is disabled in a group but remains enabled in the default user role, clarifying inheritance behavior for administrators. [#17848](https://github.com/open-webui/open-webui/pull/17848)
- 🗄️ Milvus multi-tenancy mode using shared collections with resource ID filtering for improved scalability, mirroring the existing Qdrant implementation and configurable via ENABLE_MILVUS_MULTITENANCY_MODE environment variable. [#17837](https://github.com/open-webui/open-webui/pull/17837)
- 🛠️ Enhanced tool result processing with improved error handling, better MCP tool result handling, and performance improvements for embedded UI components. [Commit](https://github.com/open-webui/open-webui/commit/4f06f29348b2c9d71c87d1bbe5b748a368f5101f)
- 👥 New user groups now automatically inherit default group permissions, streamlining the admin setup process by eliminating manual permission configuration. [#17843](https://github.com/open-webui/open-webui/pull/17843)
- 🗂️ Bulk unarchive functionality for all chats, providing a single backend endpoint to efficiently restore all archived chats at once. [#17857](https://github.com/open-webui/open-webui/pull/17857)
- 🏷️ Browser tab title toggle setting allows users to control whether chat titles appear in the browser tab or display only "Open WebUI". [#17851](https://github.com/open-webui/open-webui/pull/17851)
- 💬 Reply-to-message functionality in channels, allowing users to reply directly to specific messages with visual threading and context display. [Commit](https://github.com/open-webui/open-webui/commit/1a18928c94903ad1f1f0391b8ade042c3e60205b)
- 🔧 Tool server import and export functionality, allowing direct upload of openapi.json and openapi.yaml files as an alternative to URL-based configuration. [#14446](https://github.com/open-webui/open-webui/issues/14446)
- 🔧 User valve configuration for Functions is now available in the integration menu, providing consistent management alongside Tools. [#17784](https://github.com/open-webui/open-webui/issues/17784)
- 🔐 Admin permission toggle for controlling public sharing of notes, configurable via USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING environment variable. [#17801](https://github.com/open-webui/open-webui/pull/17801), [Docs:#715](https://github.com/open-webui/docs/pull/715)
- 🗄️ DISKANN index type support for Milvus vector database with configurable maximum degree and search list size parameters. [#17770](https://github.com/open-webui/open-webui/pull/17770), [Docs:Commit](https://github.com/open-webui/docs/commit/cec50ab4d4b659558ca1ccd4b5e6fc024f05fb83)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Chinese (Simplified & Traditional) and Bosnian (Latin) were enhanced and expanded.
### Fixed
- 🛠️ MCP tool calls are now correctly routed to the appropriate server when multiple streamable-http MCP servers are enabled, preventing "Tool not found" errors. [#17817](https://github.com/open-webui/open-webui/issues/17817)
- 🛠️ External tool servers (OpenAPI/MCP) now properly process and return tool results to the model, restoring functionality that was broken in v0.6.31. [#17764](https://github.com/open-webui/open-webui/issues/17764)
- 🔧 User valve detection now correctly identifies valves in imported tool code, ensuring gear icons appear in the integrations menu for all tools with user valves. [#17765](https://github.com/open-webui/open-webui/issues/17765)
- 🔐 MCP OAuth discovery now correctly handles multi-tenant configurations by including subpaths in metadata URL discovery. [#17768](https://github.com/open-webui/open-webui/issues/17768)
- 🗄️ Milvus query operations now correctly use -1 instead of None for unlimited queries, preventing TypeError exceptions. [#17769](https://github.com/open-webui/open-webui/pull/17769), [#17088](https://github.com/open-webui/open-webui/issues/17088)
- 📁 File upload error messages are now displayed when files are modified during upload, preventing user confusion on Android and Windows devices. [#17777](https://github.com/open-webui/open-webui/pull/17777)
- 🎨 MessageInput Integrations button hover effect now displays correctly with proper visual feedback. [#17767](https://github.com/open-webui/open-webui/pull/17767)
- 🎯 "Set as default" label positioning is fixed to ensure it remains clickable in all scenarios, including multi-model configurations. [#17779](https://github.com/open-webui/open-webui/pull/17779)
- 🎛️ Floating buttons now correctly retrieve message context by using the proper messageId parameter in createMessagesList calls. [#17823](https://github.com/open-webui/open-webui/pull/17823)
- 📌 Pinned chats are now properly cleared from the sidebar after archiving all chats, ensuring UI consistency without requiring a page refresh. [#17832](https://github.com/open-webui/open-webui/pull/17832)
- 🗑️ Delete confirmation modals now properly truncate long names for Notes, Prompts, Tools, and Functions to prevent modal overflow. [#17812](https://github.com/open-webui/open-webui/pull/17812)
- 🌐 Internationalization function calls now use proper Svelte store subscription syntax, preventing "i18n.t is not a function" errors on the model creation page. [#17819](https://github.com/open-webui/open-webui/pull/17819)
- 🎨 Playground chat interface button layout is corrected to prevent vertical text rendering for Assistant/User role buttons. [#17819](https://github.com/open-webui/open-webui/pull/17819)
- 🏷️ UI text truncation is improved across multiple components including usernames in admin panels, arena model names, model tags, and filter tags to prevent layout overflow issues. [#17805](https://github.com/open-webui/open-webui/pull/17805), [#17803](https://github.com/open-webui/open-webui/pull/17803), [#17791](https://github.com/open-webui/open-webui/pull/17791), [#17796](https://github.com/open-webui/open-webui/pull/17796)
## [0.6.31] - 2025-09-25 ## [0.6.31] - 2025-09-25
### Added ### Added
+55 -12
View File
@@ -605,8 +605,8 @@ def load_oauth_providers():
OAUTH_PROVIDERS.clear() OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value: if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
def google_oauth_register(client: OAuth): def google_oauth_register(oauth: OAuth):
client.register( client = oauth.register(
name="google", name="google",
client_id=GOOGLE_CLIENT_ID.value, client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value, client_secret=GOOGLE_CLIENT_SECRET.value,
@@ -621,6 +621,7 @@ def load_oauth_providers():
}, },
redirect_uri=GOOGLE_REDIRECT_URI.value, redirect_uri=GOOGLE_REDIRECT_URI.value,
) )
return client
OAUTH_PROVIDERS["google"] = { OAUTH_PROVIDERS["google"] = {
"redirect_uri": GOOGLE_REDIRECT_URI.value, "redirect_uri": GOOGLE_REDIRECT_URI.value,
@@ -633,8 +634,8 @@ def load_oauth_providers():
and MICROSOFT_CLIENT_TENANT_ID.value and MICROSOFT_CLIENT_TENANT_ID.value
): ):
def microsoft_oauth_register(client: OAuth): def microsoft_oauth_register(oauth: OAuth):
client.register( client = oauth.register(
name="microsoft", name="microsoft",
client_id=MICROSOFT_CLIENT_ID.value, client_id=MICROSOFT_CLIENT_ID.value,
client_secret=MICROSOFT_CLIENT_SECRET.value, client_secret=MICROSOFT_CLIENT_SECRET.value,
@@ -649,6 +650,7 @@ def load_oauth_providers():
}, },
redirect_uri=MICROSOFT_REDIRECT_URI.value, redirect_uri=MICROSOFT_REDIRECT_URI.value,
) )
return client
OAUTH_PROVIDERS["microsoft"] = { OAUTH_PROVIDERS["microsoft"] = {
"redirect_uri": MICROSOFT_REDIRECT_URI.value, "redirect_uri": MICROSOFT_REDIRECT_URI.value,
@@ -658,8 +660,8 @@ def load_oauth_providers():
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value: if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
def github_oauth_register(client: OAuth): def github_oauth_register(oauth: OAuth):
client.register( client = oauth.register(
name="github", name="github",
client_id=GITHUB_CLIENT_ID.value, client_id=GITHUB_CLIENT_ID.value,
client_secret=GITHUB_CLIENT_SECRET.value, client_secret=GITHUB_CLIENT_SECRET.value,
@@ -677,6 +679,7 @@ def load_oauth_providers():
}, },
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value, redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
) )
return client
OAUTH_PROVIDERS["github"] = { OAUTH_PROVIDERS["github"] = {
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value, "redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
@@ -690,7 +693,7 @@ def load_oauth_providers():
and OPENID_PROVIDER_URL.value and OPENID_PROVIDER_URL.value
): ):
def oidc_oauth_register(client: OAuth): def oidc_oauth_register(oauth: OAuth):
client_kwargs = { client_kwargs = {
"scope": OAUTH_SCOPES.value, "scope": OAUTH_SCOPES.value,
**( **(
@@ -716,7 +719,7 @@ def load_oauth_providers():
% ("S256", OAUTH_CODE_CHALLENGE_METHOD.value) % ("S256", OAUTH_CODE_CHALLENGE_METHOD.value)
) )
client.register( client = oauth.register(
name="oidc", name="oidc",
client_id=OAUTH_CLIENT_ID.value, client_id=OAUTH_CLIENT_ID.value,
client_secret=OAUTH_CLIENT_SECRET.value, client_secret=OAUTH_CLIENT_SECRET.value,
@@ -724,6 +727,7 @@ def load_oauth_providers():
client_kwargs=client_kwargs, client_kwargs=client_kwargs,
redirect_uri=OPENID_REDIRECT_URI.value, redirect_uri=OPENID_REDIRECT_URI.value,
) )
return client
OAUTH_PROVIDERS["oidc"] = { OAUTH_PROVIDERS["oidc"] = {
"name": OAUTH_PROVIDER_NAME.value, "name": OAUTH_PROVIDER_NAME.value,
@@ -733,8 +737,8 @@ def load_oauth_providers():
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value: if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
def feishu_oauth_register(client: OAuth): def feishu_oauth_register(oauth: OAuth):
client.register( client = oauth.register(
name="feishu", name="feishu",
client_id=FEISHU_CLIENT_ID.value, client_id=FEISHU_CLIENT_ID.value,
client_secret=FEISHU_CLIENT_SECRET.value, client_secret=FEISHU_CLIENT_SECRET.value,
@@ -752,6 +756,7 @@ def load_oauth_providers():
}, },
redirect_uri=FEISHU_REDIRECT_URI.value, redirect_uri=FEISHU_REDIRECT_URI.value,
) )
return client
OAUTH_PROVIDERS["feishu"] = { OAUTH_PROVIDERS["feishu"] = {
"register": feishu_oauth_register, "register": feishu_oauth_register,
@@ -1217,6 +1222,11 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
== "true" == "true"
) )
USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower()
== "true"
)
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = ( USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = (
os.environ.get( os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
@@ -1354,6 +1364,7 @@ DEFAULT_USER_PERMISSIONS = {
"public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING, "public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING,
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING, "public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING, "public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
}, },
"chat": { "chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS, "controls": USER_PERMISSIONS_CHAT_CONTROLS,
@@ -1999,16 +2010,23 @@ if VECTOR_DB == "chroma":
# this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2)
# Milvus # Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default") MILVUS_DB = os.environ.get("MILVUS_DB", "default")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None) MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW") MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW")
MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE") MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE")
MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16")) MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16"))
MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100")) MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100"))
MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128")) MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128"))
MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56"))
MILVUS_DISKANN_SEARCH_LIST_SIZE = int(
os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100")
)
ENABLE_MILVUS_MULTITENANCY_MODE = (
os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "false").lower() == "true"
)
# Hyphens not allowed, need to use underscores in collection names
MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui")
# Qdrant # Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None) QDRANT_URI = os.environ.get("QDRANT_URI", None)
@@ -2297,6 +2315,18 @@ DOCLING_SERVER_URL = PersistentConfig(
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
) )
docling_params = os.getenv("DOCLING_PARAMS", "")
try:
docling_params = json.loads(docling_params)
except json.JSONDecodeError:
docling_params = {}
DOCLING_PARAMS = PersistentConfig(
"DOCLING_PARAMS",
"rag.docling_params",
docling_params,
)
DOCLING_DO_OCR = PersistentConfig( DOCLING_DO_OCR = PersistentConfig(
"DOCLING_DO_OCR", "DOCLING_DO_OCR",
"rag.docling_do_ocr", "rag.docling_do_ocr",
@@ -3348,6 +3378,19 @@ AUDIO_TTS_OPENAI_API_KEY = PersistentConfig(
os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY), os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY),
) )
audio_tts_openai_params = os.getenv("AUDIO_TTS_OPENAI_PARAMS", "")
try:
audio_tts_openai_params = json.loads(audio_tts_openai_params)
except json.JSONDecodeError:
audio_tts_openai_params = {}
AUDIO_TTS_OPENAI_PARAMS = PersistentConfig(
"AUDIO_TTS_OPENAI_PARAMS",
"audio.tts.openai.params",
audio_tts_openai_params,
)
AUDIO_TTS_API_KEY = PersistentConfig( AUDIO_TTS_API_KEY = PersistentConfig(
"AUDIO_TTS_API_KEY", "AUDIO_TTS_API_KEY",
"audio.tts.api_key", "audio.tts.api_key",
+8 -2
View File
@@ -212,6 +212,11 @@ ENABLE_FORWARD_USER_INFO_HEADERS = (
os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true" os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true"
) )
# Experimental feature, may be removed in future
ENABLE_STAR_SESSIONS_MIDDLEWARE = (
os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true"
)
#################################### ####################################
# WEBUI_BUILD_HASH # WEBUI_BUILD_HASH
#################################### ####################################
@@ -468,7 +473,9 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
#################################### ####################################
# OAUTH Configuration # OAUTH Configuration
#################################### ####################################
ENABLE_OAUTH_EMAIL_FALLBACK = (
os.environ.get("ENABLE_OAUTH_EMAIL_FALLBACK", "False").lower() == "true"
)
ENABLE_OAUTH_ID_TOKEN_COOKIE = ( ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true" os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
@@ -482,7 +489,6 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
) )
#################################### ####################################
# SCIM Configuration # SCIM Configuration
#################################### ####################################
+6
View File
@@ -86,6 +86,10 @@ async def get_function_models(request):
try: try:
function_module = get_function_module_by_id(request, pipe.id) function_module = get_function_module_by_id(request, pipe.id)
has_user_valves = False
if hasattr(function_module, "UserValves"):
has_user_valves = True
# Check if function is a manifold # Check if function is a manifold
if hasattr(function_module, "pipes"): if hasattr(function_module, "pipes"):
sub_pipes = [] sub_pipes = []
@@ -124,6 +128,7 @@ async def get_function_models(request):
"created": pipe.created_at, "created": pipe.created_at,
"owned_by": "openai", "owned_by": "openai",
"pipe": pipe_flag, "pipe": pipe_flag,
"has_user_valves": has_user_valves,
} }
) )
else: else:
@@ -141,6 +146,7 @@ async def get_function_models(request):
"created": pipe.created_at, "created": pipe.created_at,
"owned_by": "openai", "owned_by": "openai",
"pipe": pipe_flag, "pipe": pipe_flag,
"has_user_valves": has_user_valves,
} }
) )
except Exception as e: except Exception as e:
+95 -57
View File
@@ -8,6 +8,7 @@ import shutil
import sys import sys
import time import time
import random import random
import re
from uuid import uuid4 from uuid import uuid4
@@ -174,13 +175,14 @@ from open_webui.config import (
AUDIO_STT_AZURE_LOCALES, AUDIO_STT_AZURE_LOCALES,
AUDIO_STT_AZURE_BASE_URL, AUDIO_STT_AZURE_BASE_URL,
AUDIO_STT_AZURE_MAX_SPEAKERS, AUDIO_STT_AZURE_MAX_SPEAKERS,
AUDIO_TTS_API_KEY,
AUDIO_TTS_ENGINE, AUDIO_TTS_ENGINE,
AUDIO_TTS_MODEL, AUDIO_TTS_MODEL,
AUDIO_TTS_VOICE,
AUDIO_TTS_OPENAI_API_BASE_URL, AUDIO_TTS_OPENAI_API_BASE_URL,
AUDIO_TTS_OPENAI_API_KEY, AUDIO_TTS_OPENAI_API_KEY,
AUDIO_TTS_OPENAI_PARAMS,
AUDIO_TTS_API_KEY,
AUDIO_TTS_SPLIT_ON, AUDIO_TTS_SPLIT_ON,
AUDIO_TTS_VOICE,
AUDIO_TTS_AZURE_SPEECH_REGION, AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_BASE_URL, AUDIO_TTS_AZURE_SPEECH_BASE_URL,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT, AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
@@ -246,6 +248,7 @@ from open_webui.config import (
EXTERNAL_DOCUMENT_LOADER_API_KEY, EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL, TIKA_SERVER_URL,
DOCLING_SERVER_URL, DOCLING_SERVER_URL,
DOCLING_PARAMS,
DOCLING_DO_OCR, DOCLING_DO_OCR,
DOCLING_FORCE_OCR, DOCLING_FORCE_OCR,
DOCLING_OCR_ENGINE, DOCLING_OCR_ENGINE,
@@ -447,6 +450,7 @@ from open_webui.env import (
ENABLE_OTEL, ENABLE_OTEL,
EXTERNAL_PWA_MANIFEST_URL, EXTERNAL_PWA_MANIFEST_URL,
AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_SESSION_SSL,
ENABLE_STAR_SESSIONS_MIDDLEWARE,
) )
@@ -834,6 +838,7 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCLING_PARAMS = DOCLING_PARAMS
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
@@ -1095,11 +1100,15 @@ app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES
app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL
app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS
app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE
app.state.config.TTS_MODEL = AUDIO_TTS_MODEL app.state.config.TTS_MODEL = AUDIO_TTS_MODEL
app.state.config.TTS_VOICE = AUDIO_TTS_VOICE app.state.config.TTS_VOICE = AUDIO_TTS_VOICE
app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
app.state.config.TTS_OPENAI_PARAMS = AUDIO_TTS_OPENAI_PARAMS
app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY app.state.config.TTS_API_KEY = AUDIO_TTS_API_KEY
app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON app.state.config.TTS_SPLIT_ON = AUDIO_TTS_SPLIT_ON
@@ -1170,12 +1179,32 @@ class RedirectMiddleware(BaseHTTPMiddleware):
path = request.url.path path = request.url.path
query_params = dict(parse_qs(urlparse(str(request.url)).query)) query_params = dict(parse_qs(urlparse(str(request.url)).query))
redirect_params = {}
# Check for the specific watch path and the presence of 'v' parameter # Check for the specific watch path and the presence of 'v' parameter
if path.endswith("/watch") and "v" in query_params: if path.endswith("/watch") and "v" in query_params:
# Extract the first 'v' parameter # Extract the first 'v' parameter
video_id = query_params["v"][0] youtube_video_id = query_params["v"][0]
encoded_video_id = urlencode({"youtube": video_id}) redirect_params["youtube"] = youtube_video_id
redirect_url = f"/?{encoded_video_id}"
if "shared" in query_params and len(query_params["shared"]) > 0:
# PWA share_target support
text = query_params["shared"][0]
if text:
urls = re.match(r"https://\S+", text)
if urls:
from open_webui.retrieval.loaders.youtube import _parse_video_id
if youtube_video_id := _parse_video_id(urls[0]):
redirect_params["youtube"] = youtube_video_id
else:
redirect_params["load-url"] = urls[0]
else:
redirect_params["q"] = text
if redirect_params:
redirect_url = f"/?{urlencode(redirect_params)}"
return RedirectResponse(url=redirect_url) return RedirectResponse(url=redirect_url)
# Proceed with the normal flow of other requests # Proceed with the normal flow of other requests
@@ -1474,7 +1503,7 @@ async def chat_completion(
} }
if metadata.get("chat_id") and (user and user.role != "admin"): if metadata.get("chat_id") and (user and user.role != "admin"):
if metadata["chat_id"] != "local": if not metadata["chat_id"].startswith("local:"):
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id) chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
if chat is None: if chat is None:
raise HTTPException( raise HTTPException(
@@ -1501,13 +1530,14 @@ async def chat_completion(
response = await chat_completion_handler(request, form_data, user) response = await chat_completion_handler(request, form_data, user)
if metadata.get("chat_id") and metadata.get("message_id"): if metadata.get("chat_id") and metadata.get("message_id"):
try: try:
Chats.upsert_message_to_chat_by_id_and_message_id( if not metadata["chat_id"].startswith("local:"):
metadata["chat_id"], Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["message_id"], metadata["chat_id"],
{ metadata["message_id"],
"model": model_id, {
}, "model": model_id,
) },
)
except: except:
pass pass
@@ -1528,13 +1558,14 @@ async def chat_completion(
if metadata.get("chat_id") and metadata.get("message_id"): if metadata.get("chat_id") and metadata.get("message_id"):
# Update the chat message with the error # Update the chat message with the error
try: try:
Chats.upsert_message_to_chat_by_id_and_message_id( if not metadata["chat_id"].startswith("local:"):
metadata["chat_id"], Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["message_id"], metadata["chat_id"],
{ metadata["message_id"],
"error": {"content": str(e)}, {
}, "error": {"content": str(e)},
) },
)
event_emitter = get_event_emitter(metadata) event_emitter = get_event_emitter(metadata)
await event_emitter( await event_emitter(
@@ -1552,7 +1583,7 @@ async def chat_completion(
finally: finally:
try: try:
if mcp_clients := metadata.get("mcp_clients"): if mcp_clients := metadata.get("mcp_clients"):
for client in mcp_clients: for client in mcp_clients.values():
await client.disconnect() await client.disconnect()
except Exception as e: except Exception as e:
log.debug(f"Error cleaning up: {e}") log.debug(f"Error cleaning up: {e}")
@@ -1903,43 +1934,45 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
"oauth_client_info", "" "oauth_client_info", ""
) )
oauth_client_info = decrypt_data(oauth_client_info) try:
app.state.oauth_client_manager.add_client( oauth_client_info = decrypt_data(oauth_client_info)
f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info) app.state.oauth_client_manager.add_client(
) f"mcp:{server_id}",
OAuthClientInformationFull(**oauth_client_info),
)
except Exception as e:
log.error(
f"Error adding OAuth client for MCP tool server {server_id}: {e}"
)
pass
try:
# SessionMiddleware is used by authlib for oauth if ENABLE_STAR_SESSIONS_MIDDLEWARE:
if len(OAUTH_PROVIDERS) > 0: redis_session_store = RedisStore(
try: url=REDIS_URL,
if REDIS_URL: prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"),
redis_session_store = RedisStore(
url=REDIS_URL,
prefix=(
f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"
),
)
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(
StarSessionsMiddleware,
store=redis_session_store,
cookie_name="oui-session",
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
)
log.info("Using Redis for session")
else:
raise ValueError("No Redis URL provided")
except Exception as e:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
session_cookie="oui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
) )
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(
StarSessionsMiddleware,
store=redis_session_store,
cookie_name="owui-session",
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
)
log.info("Using Redis for session")
else:
raise ValueError("No Redis URL provided")
except Exception as e:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
session_cookie="owui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
)
@app.get("/oauth/clients/{client_id}/authorize") @app.get("/oauth/clients/{client_id}/authorize")
async def oauth_client_authorize( async def oauth_client_authorize(
@@ -2009,6 +2042,11 @@ async def get_manifest_json():
"purpose": "maskable", "purpose": "maskable",
}, },
], ],
"share_target": {
"action": "/",
"method": "GET",
"params": {"text": "shared"},
},
} }
@@ -0,0 +1,34 @@
"""Add reply_to_id column to message
Revision ID: a5c220713937
Revises: 38d63c18f30f
Create Date: 2025-09-27 02:24:18.058455
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a5c220713937"
down_revision: Union[str, None] = "38d63c18f30f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add 'reply_to_id' column to the 'message' table for replying to messages
op.add_column(
"message",
sa.Column("reply_to_id", sa.Text(), nullable=True),
)
pass
def downgrade() -> None:
# Remove 'reply_to_id' column from the 'message' table
op.drop_column("message", "reply_to_id")
pass
+15 -1
View File
@@ -366,6 +366,15 @@ class ChatTable:
except Exception: except Exception:
return False return False
def unarchive_all_chats_by_user_id(self, user_id: str) -> bool:
try:
with get_db() as db:
db.query(Chat).filter_by(user_id=user_id).update({"archived": False})
db.commit()
return True
except Exception:
return False
def update_chat_share_id_by_id( def update_chat_share_id_by_id(
self, id: str, share_id: Optional[str] self, id: str, share_id: Optional[str]
) -> Optional[ChatModel]: ) -> Optional[ChatModel]:
@@ -810,7 +819,7 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_folder_id_and_user_id( def get_chats_by_folder_id_and_user_id(
self, folder_id: str, user_id: str self, folder_id: str, user_id: str, skip: int = 0, limit: int = 60
) -> list[ChatModel]: ) -> list[ChatModel]:
with get_db() as db: with get_db() as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id) query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
@@ -819,6 +828,11 @@ class ChatTable:
query = query.order_by(Chat.updated_at.desc()) query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all() all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
+3 -1
View File
@@ -186,7 +186,9 @@ class FilesTable:
created_at=file.created_at, created_at=file.created_at,
updated_at=file.updated_at, updated_at=file.updated_at,
) )
for file in db.query(File) for file in db.query(
File.id, File.meta, File.created_at, File.updated_at
)
.filter(File.id.in_(ids)) .filter(File.id.in_(ids))
.order_by(File.updated_at.desc()) .order_by(File.updated_at.desc())
.all() .all()
+14
View File
@@ -50,6 +50,20 @@ class FolderModel(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class FolderMetadataResponse(BaseModel):
icon: Optional[str] = None
class FolderNameIdResponse(BaseModel):
id: str
name: str
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None
is_expanded: bool = False
created_at: int
updated_at: int
#################### ####################
# Forms # Forms
#################### ####################
+27 -1
View File
@@ -3,7 +3,7 @@ import time
from typing import Optional from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users from open_webui.models.users import Users, UserModel
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index
@@ -76,6 +76,10 @@ class FunctionWithValvesModel(BaseModel):
#################### ####################
class FunctionUserResponse(FunctionModel):
user: Optional[UserModel] = None
class FunctionResponse(BaseModel): class FunctionResponse(BaseModel):
id: str id: str
user_id: str user_id: str
@@ -203,6 +207,28 @@ class FunctionsTable:
FunctionModel.model_validate(function) for function in functions FunctionModel.model_validate(function) for function in functions
] ]
def get_function_list(self) -> list[FunctionUserResponse]:
with get_db() as db:
functions = db.query(Function).order_by(Function.updated_at.desc()).all()
user_ids = list(set(func.user_id for func in functions))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
return [
FunctionUserResponse.model_validate(
{
**FunctionModel.model_validate(func).model_dump(),
"user": (
users_dict.get(func.user_id).model_dump()
if func.user_id in users_dict
else None
),
}
)
for func in functions
]
def get_functions_by_type( def get_functions_by_type(
self, type: str, active_only=False self, type: str, active_only=False
) -> list[FunctionModel]: ) -> list[FunctionModel]:
+98 -13
View File
@@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.tags import TagModel, Tag, Tags from open_webui.models.tags import TagModel, Tag, Tags
from open_webui.models.users import Users, UserNameResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -43,6 +44,7 @@ class Message(Base):
user_id = Column(Text) user_id = Column(Text)
channel_id = Column(Text, nullable=True) channel_id = Column(Text, nullable=True)
reply_to_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True) parent_id = Column(Text, nullable=True)
content = Column(Text) content = Column(Text)
@@ -60,6 +62,7 @@ class MessageModel(BaseModel):
user_id: str user_id: str
channel_id: Optional[str] = None channel_id: Optional[str] = None
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None parent_id: Optional[str] = None
content: str content: str
@@ -77,6 +80,7 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel): class MessageForm(BaseModel):
content: str content: str
reply_to_id: Optional[str] = None
parent_id: Optional[str] = None parent_id: Optional[str] = None
data: Optional[dict] = None data: Optional[dict] = None
meta: Optional[dict] = None meta: Optional[dict] = None
@@ -88,7 +92,15 @@ class Reactions(BaseModel):
count: int count: int
class MessageResponse(MessageModel): class MessageUserResponse(MessageModel):
user: Optional[UserNameResponse] = None
class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None
class MessageResponse(MessageReplyToResponse):
latest_reply_at: Optional[int] latest_reply_at: Optional[int]
reply_count: int reply_count: int
reactions: list[Reactions] reactions: list[Reactions]
@@ -107,6 +119,7 @@ class MessageTable:
"id": id, "id": id,
"user_id": user_id, "user_id": user_id,
"channel_id": channel_id, "channel_id": channel_id,
"reply_to_id": form_data.reply_to_id,
"parent_id": form_data.parent_id, "parent_id": form_data.parent_id,
"content": form_data.content, "content": form_data.content,
"data": form_data.data, "data": form_data.data,
@@ -128,19 +141,32 @@ class MessageTable:
if not message: if not message:
return None return None
reactions = self.get_reactions_by_message_id(id) reply_to_message = (
replies = self.get_replies_by_message_id(id) self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
return MessageResponse( reactions = self.get_reactions_by_message_id(id)
**{ thread_replies = self.get_thread_replies_by_message_id(id)
user = Users.get_user_by_id(message.user_id)
return MessageResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(), **MessageModel.model_validate(message).model_dump(),
"latest_reply_at": replies[0].created_at if replies else None, "user": user.model_dump() if user else None,
"reply_count": len(replies), "reply_to_message": (
reply_to_message.model_dump() if reply_to_message else None
),
"latest_reply_at": (
thread_replies[0].created_at if thread_replies else None
),
"reply_count": len(thread_replies),
"reactions": reactions, "reactions": reactions,
} }
) )
def get_replies_by_message_id(self, id: str) -> list[MessageModel]: def get_thread_replies_by_message_id(self, id: str) -> list[MessageReplyToResponse]:
with get_db() as db: with get_db() as db:
all_messages = ( all_messages = (
db.query(Message) db.query(Message)
@@ -148,7 +174,27 @@ class MessageTable:
.order_by(Message.created_at.desc()) .order_by(Message.created_at.desc())
.all() .all()
) )
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]: def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db: with get_db() as db:
@@ -159,7 +205,7 @@ class MessageTable:
def get_messages_by_channel_id( def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50 self, channel_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]: ) -> list[MessageReplyToResponse]:
with get_db() as db: with get_db() as db:
all_messages = ( all_messages = (
db.query(Message) db.query(Message)
@@ -169,11 +215,31 @@ class MessageTable:
.limit(limit) .limit(limit)
.all() .all()
) )
return [MessageModel.model_validate(message) for message in all_messages]
messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def get_messages_by_parent_id( def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50 self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]: ) -> list[MessageReplyToResponse]:
with get_db() as db: with get_db() as db:
message = db.get(Message, parent_id) message = db.get(Message, parent_id)
@@ -193,7 +259,26 @@ class MessageTable:
if len(all_messages) < limit: if len(all_messages) < limit:
all_messages.append(message) all_messages.append(message)
return [MessageModel.model_validate(message) for message in all_messages] messages = []
for message in all_messages:
reply_to_message = (
self.get_message_by_id(message.reply_to_id)
if message.reply_to_id
else None
)
messages.append(
MessageReplyToResponse.model_validate(
{
**MessageModel.model_validate(message).model_dump(),
"reply_to_message": (
reply_to_message.model_dump()
if reply_to_message
else None
),
}
)
)
return messages
def update_message_by_id( def update_message_by_id(
self, id: str, form_data: MessageForm self, id: str, form_data: MessageForm
+1 -3
View File
@@ -346,11 +346,9 @@ class Loader:
self.engine == "document_intelligence" self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != "" and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
and ( and (
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"] file_ext in ["pdf", "docx", "ppt", "pptx"]
or file_content_type or file_content_type
in [ in [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-powerpoint", "application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.openxmlformats-officedocument.presentationml.presentation",
@@ -157,3 +157,10 @@ class YoutubeLoader:
f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed." f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed."
) )
raise NoTranscriptFound(self.video_id, self.language, list(transcript_list)) raise NoTranscriptFound(self.video_id, self.language, list(transcript_list))
async def aload(self) -> Generator[Document, None, None]:
"""Asynchronously load YouTube transcripts into `Document` objects."""
import asyncio
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.load)
+44 -2
View File
@@ -6,6 +6,7 @@ import requests
import hashlib import hashlib
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import time import time
import re
from urllib.parse import quote from urllib.parse import quote
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
@@ -16,6 +17,7 @@ from langchain_core.documents import Document
from open_webui.config import VECTOR_DB from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
from open_webui.models.files import Files from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges from open_webui.models.knowledge import Knowledges
@@ -27,6 +29,9 @@ from open_webui.retrieval.vector.main import GetResult
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.utils.misc import get_message_list from open_webui.utils.misc import get_message_list
from open_webui.retrieval.web.utils import get_web_loader
from open_webui.retrieval.loaders.youtube import YoutubeLoader
from open_webui.env import ( from open_webui.env import (
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
@@ -49,6 +54,33 @@ from langchain_core.callbacks import CallbackManagerForRetrieverRun
from langchain_core.retrievers import BaseRetriever from langchain_core.retrievers import BaseRetriever
def is_youtube_url(url: str) -> bool:
youtube_regex = r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$"
return re.match(youtube_regex, url) is not None
def get_loader(request, url: str):
if is_youtube_url(url):
return YoutubeLoader(
url,
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
)
else:
return get_web_loader(
url,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
)
def get_content_from_url(request, url: str) -> str:
loader = get_loader(request, url)
docs = loader.load()
content = " ".join([doc.page_content for doc in docs])
return content, docs
class VectorSearchRetriever(BaseRetriever): class VectorSearchRetriever(BaseRetriever):
collection_name: Any collection_name: Any
embedding_function: Any embedding_function: Any
@@ -188,7 +220,11 @@ def query_doc_with_hybrid_search(
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
) )
sorted_items = sorted_items[:k] sorted_items = sorted_items[:k]
distances, documents, metadatas = map(list, zip(*sorted_items))
if sorted_items:
distances, documents, metadatas = map(list, zip(*sorted_items))
else:
distances, documents, metadatas = [], [], []
result = { result = {
"distances": [distances], "distances": [distances],
@@ -571,6 +607,13 @@ def get_sources_from_items(
"metadatas": [[{"file_id": chat.id, "name": chat.title}]], "metadatas": [[{"file_id": chat.id, "name": chat.title}]],
} }
elif item.get("type") == "url":
content, docs = get_content_from_url(request, item.get("url"))
if docs:
query_result = {
"documents": [[content]],
"metadatas": [[{"url": item.get("url"), "name": item.get("url")}]],
}
elif item.get("type") == "file": elif item.get("type") == "file":
if ( if (
item.get("context") == "full" item.get("context") == "full"
@@ -736,7 +779,6 @@ def get_sources_from_items(
sources.append(source) sources.append(source)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
return sources return sources
@@ -11,7 +11,7 @@ from open_webui.retrieval.vector.main import (
SearchResult, SearchResult,
GetResult, GetResult,
) )
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
from open_webui.config import ( from open_webui.config import (
CHROMA_DATA_PATH, CHROMA_DATA_PATH,
@@ -146,7 +146,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items] ids = [item["id"] for item in items]
documents = [item["text"] for item in items] documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items] embeddings = [item["vector"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items] metadatas = [process_metadata(item["metadata"]) for item in items]
for batch in create_batches( for batch in create_batches(
api=self.client, api=self.client,
@@ -166,7 +166,7 @@ class ChromaClient(VectorDBBase):
ids = [item["id"] for item in items] ids = [item["id"] for item in items]
documents = [item["text"] for item in items] documents = [item["text"] for item in items]
embeddings = [item["vector"] for item in items] embeddings = [item["vector"] for item in items]
metadatas = [stringify_metadata(item["metadata"]) for item in items] metadatas = [process_metadata(item["metadata"]) for item in items]
collection.upsert( collection.upsert(
ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas
@@ -3,7 +3,7 @@ from typing import Optional
import ssl import ssl
from elasticsearch.helpers import bulk, scan from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
VectorDBBase, VectorDBBase,
VectorItem, VectorItem,
@@ -245,7 +245,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name, "collection": collection_name,
"vector": item["vector"], "vector": item["vector"],
"text": item["text"], "text": item["text"],
"metadata": stringify_metadata(item["metadata"]), "metadata": process_metadata(item["metadata"]),
}, },
} }
for item in batch for item in batch
@@ -266,7 +266,7 @@ class ElasticsearchClient(VectorDBBase):
"collection": collection_name, "collection": collection_name,
"vector": item["vector"], "vector": item["vector"],
"text": item["text"], "text": item["text"],
"metadata": stringify_metadata(item["metadata"]), "metadata": process_metadata(item["metadata"]),
}, },
"doc_as_upsert": True, "doc_as_upsert": True,
} }
@@ -6,7 +6,7 @@ import json
import logging import logging
from typing import Optional from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
VectorDBBase, VectorDBBase,
VectorItem, VectorItem,
@@ -22,6 +22,8 @@ from open_webui.config import (
MILVUS_HNSW_M, MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION, MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST, MILVUS_IVF_FLAT_NLIST,
MILVUS_DISKANN_MAX_DEGREE,
MILVUS_DISKANN_SEARCH_LIST_SIZE,
) )
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@@ -131,12 +133,18 @@ class MilvusClient(VectorDBBase):
elif index_type == "IVF_FLAT": elif index_type == "IVF_FLAT":
index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST} index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST}
log.info(f"IVF_FLAT params: {index_creation_params}") log.info(f"IVF_FLAT params: {index_creation_params}")
elif index_type == "DISKANN":
index_creation_params = {
"max_degree": MILVUS_DISKANN_MAX_DEGREE,
"search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE,
}
log.info(f"DISKANN params: {index_creation_params}")
elif index_type in ["FLAT", "AUTOINDEX"]: elif index_type in ["FLAT", "AUTOINDEX"]:
log.info(f"Using {index_type} index with no specific build-time params.") log.info(f"Using {index_type} index with no specific build-time params.")
else: else:
log.warning( log.warning(
f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. " f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. "
f"Supported types: HNSW, IVF_FLAT, FLAT, AUTOINDEX. " f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. "
f"Milvus will use its default for the collection if this type is not directly supported for index creation." f"Milvus will use its default for the collection if this type is not directly supported for index creation."
) )
# For unsupported types, pass the type directly to Milvus; it might handle it or use a default. # For unsupported types, pass the type directly to Milvus; it might handle it or use a default.
@@ -189,7 +197,7 @@ class MilvusClient(VectorDBBase):
) )
return self._result_to_search_result(result) return self._result_to_search_result(result)
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None): def query(self, collection_name: str, filter: dict, limit: int = -1):
connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB) connections.connect(uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB)
# Construct the filter string for querying # Construct the filter string for querying
@@ -222,7 +230,7 @@ class MilvusClient(VectorDBBase):
"data", "data",
"metadata", "metadata",
], ],
limit=limit, # Pass the limit directly; None means no limit. limit=limit, # Pass the limit directly; -1 means no limit.
) )
while True: while True:
@@ -249,7 +257,7 @@ class MilvusClient(VectorDBBase):
) )
# Using query with a trivial filter to get all items. # Using query with a trivial filter to get all items.
# This will use the paginated query logic. # This will use the paginated query logic.
return self.query(collection_name=collection_name, filter={}, limit=None) return self.query(collection_name=collection_name, filter={}, limit=-1)
def insert(self, collection_name: str, items: list[VectorItem]): def insert(self, collection_name: str, items: list[VectorItem]):
# Insert the items into the collection, if the collection does not exist, it will be created. # Insert the items into the collection, if the collection does not exist, it will be created.
@@ -281,7 +289,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"], "id": item["id"],
"vector": item["vector"], "vector": item["vector"],
"data": {"text": item["text"]}, "data": {"text": item["text"]},
"metadata": stringify_metadata(item["metadata"]), "metadata": process_metadata(item["metadata"]),
} }
for item in items for item in items
], ],
@@ -317,7 +325,7 @@ class MilvusClient(VectorDBBase):
"id": item["id"], "id": item["id"],
"vector": item["vector"], "vector": item["vector"],
"data": {"text": item["text"]}, "data": {"text": item["text"]},
"metadata": stringify_metadata(item["metadata"]), "metadata": process_metadata(item["metadata"]),
} }
for item in items for item in items
], ],
@@ -0,0 +1,282 @@
import logging
from typing import Optional, Tuple, List, Dict, Any
from open_webui.config import (
MILVUS_URI,
MILVUS_TOKEN,
MILVUS_DB,
MILVUS_COLLECTION_PREFIX,
MILVUS_INDEX_TYPE,
MILVUS_METRIC_TYPE,
MILVUS_HNSW_M,
MILVUS_HNSW_EFCONSTRUCTION,
MILVUS_IVF_FLAT_NLIST,
)
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
VectorDBBase,
VectorItem,
)
from pymilvus import (
connections,
utility,
Collection,
CollectionSchema,
FieldSchema,
DataType,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
RESOURCE_ID_FIELD = "resource_id"
class MilvusClient(VectorDBBase):
def __init__(self):
# Milvus collection names can only contain numbers, letters, and underscores.
self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_")
connections.connect(
alias="default",
uri=MILVUS_URI,
token=MILVUS_TOKEN,
db_name=MILVUS_DB,
)
# Main collection types for multi-tenancy
self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories"
self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge"
self.FILE_COLLECTION = f"{self.collection_prefix}_files"
self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search"
self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based"
self.shared_collections = [
self.MEMORY_COLLECTION,
self.KNOWLEDGE_COLLECTION,
self.FILE_COLLECTION,
self.WEB_SEARCH_COLLECTION,
self.HASH_BASED_COLLECTION,
]
def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, str]:
"""
Maps the traditional collection name to multi-tenant collection and resource ID.
WARNING: This mapping relies on current Open WebUI naming conventions for
collection names. If Open WebUI changes how it generates collection names
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
formats), this mapping will break and route data to incorrect collections.
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
DATA MAPPING INSIDE THE DATABASE.
"""
resource_id = collection_name
if collection_name.startswith("user-memory-"):
return self.MEMORY_COLLECTION, resource_id
elif collection_name.startswith("file-"):
return self.FILE_COLLECTION, resource_id
elif collection_name.startswith("web-search-"):
return self.WEB_SEARCH_COLLECTION, resource_id
elif len(collection_name) == 63 and all(
c in "0123456789abcdef" for c in collection_name
):
return self.HASH_BASED_COLLECTION, resource_id
else:
return self.KNOWLEDGE_COLLECTION, resource_id
def _create_shared_collection(self, mt_collection_name: str, dimension: int):
fields = [
FieldSchema(
name="id",
dtype=DataType.VARCHAR,
is_primary=True,
auto_id=False,
max_length=36,
),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="metadata", dtype=DataType.JSON),
FieldSchema(name=RESOURCE_ID_FIELD, dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields, "Shared collection for multi-tenancy")
collection = Collection(mt_collection_name, schema)
index_params = {
"metric_type": MILVUS_METRIC_TYPE,
"index_type": MILVUS_INDEX_TYPE,
"params": {},
}
if MILVUS_INDEX_TYPE == "HNSW":
index_params["params"] = {
"M": MILVUS_HNSW_M,
"efConstruction": MILVUS_HNSW_EFCONSTRUCTION,
}
elif MILVUS_INDEX_TYPE == "IVF_FLAT":
index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST}
collection.create_index("vector", index_params)
collection.create_index(RESOURCE_ID_FIELD)
log.info(f"Created shared collection: {mt_collection_name}")
return collection
def _ensure_collection(self, mt_collection_name: str, dimension: int):
if not utility.has_collection(mt_collection_name):
self._create_shared_collection(mt_collection_name, dimension)
def has_collection(self, collection_name: str) -> bool:
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return False
collection = Collection(mt_collection)
collection.load()
res = collection.query(expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", limit=1)
return len(res) > 0
def upsert(self, collection_name: str, items: List[VectorItem]):
if not items:
return
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
dimension = len(items[0]["vector"])
self._ensure_collection(mt_collection, dimension)
collection = Collection(mt_collection)
entities = [
{
"id": item["id"],
"vector": item["vector"],
"text": item["text"],
"metadata": item["metadata"],
RESOURCE_ID_FIELD: resource_id,
}
for item in items
]
collection.insert(entities)
collection.flush()
def search(
self, collection_name: str, vectors: List[List[float]], limit: int
) -> Optional[SearchResult]:
if not vectors:
return None
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}}
results = collection.search(
data=vectors,
anns_field="vector",
param=search_params,
limit=limit,
expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'",
output_fields=["id", "text", "metadata"],
)
ids, documents, metadatas, distances = [], [], [], []
for hits in results:
batch_ids, batch_docs, batch_metadatas, batch_dists = [], [], [], []
for hit in hits:
batch_ids.append(hit.entity.get("id"))
batch_docs.append(hit.entity.get("text"))
batch_metadatas.append(hit.entity.get("metadata"))
batch_dists.append(hit.distance)
ids.append(batch_ids)
documents.append(batch_docs)
metadatas.append(batch_metadatas)
distances.append(batch_dists)
return SearchResult(
ids=ids, documents=documents, metadatas=metadatas, distances=distances
)
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict[str, Any]] = None,
):
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
# Build expression
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if ids:
# Milvus expects a string list for 'in' operator
id_list_str = ", ".join([f"'{id_val}'" for id_val in ids])
expr.append(f"id in [{id_list_str}]")
if filter:
for key, value in filter.items():
expr.append(f"metadata['{key}'] == '{value}'")
collection.delete(" and ".join(expr))
def reset(self):
for collection_name in self.shared_collections:
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
def delete_collection(self, collection_name: str):
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return
collection = Collection(mt_collection)
collection.delete(f"{RESOURCE_ID_FIELD} == '{resource_id}'")
def query(
self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None
) -> Optional[GetResult]:
mt_collection, resource_id = self._get_collection_and_resource_id(
collection_name
)
if not utility.has_collection(mt_collection):
return None
collection = Collection(mt_collection)
collection.load()
expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"]
if filter:
for key, value in filter.items():
if isinstance(value, str):
expr.append(f"metadata['{key}'] == '{value}'")
else:
expr.append(f"metadata['{key}'] == {value}")
results = collection.query(
expr=" and ".join(expr),
output_fields=["id", "text", "metadata"],
limit=limit,
)
ids = [res["id"] for res in results]
documents = [res["text"] for res in results]
metadatas = [res["metadata"] for res in results]
return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas])
def get(self, collection_name: str) -> Optional[GetResult]:
return self.query(collection_name, filter={}, limit=None)
def insert(self, collection_name: str, items: List[VectorItem]):
return self.upsert(collection_name, items)
@@ -2,7 +2,7 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk from opensearchpy.helpers import bulk
from typing import Optional from typing import Optional
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
VectorDBBase, VectorDBBase,
VectorItem, VectorItem,
@@ -201,7 +201,7 @@ class OpenSearchClient(VectorDBBase):
"_source": { "_source": {
"vector": item["vector"], "vector": item["vector"],
"text": item["text"], "text": item["text"],
"metadata": stringify_metadata(item["metadata"]), "metadata": process_metadata(item["metadata"]),
}, },
} }
for item in batch for item in batch
@@ -223,7 +223,7 @@ class OpenSearchClient(VectorDBBase):
"doc": { "doc": {
"vector": item["vector"], "vector": item["vector"],
"text": item["text"], "text": item["text"],
"metadata": stringify_metadata(item["metadata"]), "metadata": process_metadata(item["metadata"]),
}, },
"doc_as_upsert": True, "doc_as_upsert": True,
} }
@@ -27,7 +27,7 @@ from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
VectorDBBase, VectorDBBase,
VectorItem, VectorItem,
@@ -265,7 +265,7 @@ class PgvectorClient(VectorDBBase):
vector=vector, vector=vector,
collection_name=collection_name, collection_name=collection_name,
text=item["text"], text=item["text"],
vmetadata=stringify_metadata(item["metadata"]), vmetadata=process_metadata(item["metadata"]),
) )
new_items.append(new_chunk) new_items.append(new_chunk)
self.session.bulk_save_objects(new_items) self.session.bulk_save_objects(new_items)
@@ -323,7 +323,7 @@ class PgvectorClient(VectorDBBase):
if existing: if existing:
existing.vector = vector existing.vector = vector
existing.text = item["text"] existing.text = item["text"]
existing.vmetadata = stringify_metadata(item["metadata"]) existing.vmetadata = process_metadata(item["metadata"])
existing.collection_name = ( existing.collection_name = (
collection_name # Update collection_name if necessary collection_name # Update collection_name if necessary
) )
@@ -333,7 +333,7 @@ class PgvectorClient(VectorDBBase):
vector=vector, vector=vector,
collection_name=collection_name, collection_name=collection_name,
text=item["text"], text=item["text"],
vmetadata=stringify_metadata(item["metadata"]), vmetadata=process_metadata(item["metadata"]),
) )
self.session.add(new_chunk) self.session.add(new_chunk)
self.session.commit() self.session.commit()
@@ -32,7 +32,7 @@ from open_webui.config import (
PINECONE_CLOUD, PINECONE_CLOUD,
) )
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
@@ -185,7 +185,7 @@ class PineconeClient(VectorDBBase):
point = { point = {
"id": item["id"], "id": item["id"],
"values": item["vector"], "values": item["vector"],
"metadata": stringify_metadata(metadata), "metadata": process_metadata(metadata),
} }
points.append(point) points.append(point)
return points return points
@@ -105,6 +105,13 @@ class QdrantClient(VectorDBBase):
Returns: Returns:
tuple: (collection_name, tenant_id) tuple: (collection_name, tenant_id)
WARNING: This mapping relies on current Open WebUI naming conventions for
collection names. If Open WebUI changes how it generates collection names
(e.g., "user-memory-" prefix, "file-" prefix, web search patterns, or hash
formats), this mapping will break and route data to incorrect collections.
POTENTIALLY CAUSING HUGE DATA CORRUPTION, DATA CONSISTENCY ISSUES AND INCORRECT
DATA MAPPING INSIDE THE DATABASE.
""" """
# Check for user memory collections # Check for user memory collections
tenant_id = collection_name tenant_id = collection_name
@@ -1,4 +1,4 @@
from open_webui.retrieval.vector.utils import stringify_metadata from open_webui.retrieval.vector.utils import process_metadata
from open_webui.retrieval.vector.main import ( from open_webui.retrieval.vector.main import (
VectorDBBase, VectorDBBase,
VectorItem, VectorItem,
@@ -185,7 +185,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"] metadata["text"] = item["text"]
# Convert metadata to string format for consistency # Convert metadata to string format for consistency
metadata = stringify_metadata(metadata) metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys # Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"]) metadata = self._filter_metadata(metadata, item["id"])
@@ -256,7 +256,7 @@ class S3VectorClient(VectorDBBase):
metadata["text"] = item["text"] metadata["text"] = item["text"]
# Convert metadata to string format for consistency # Convert metadata to string format for consistency
metadata = stringify_metadata(metadata) metadata = process_metadata(metadata)
# Filter metadata to comply with S3 Vector API limit of 10 keys # Filter metadata to comply with S3 Vector API limit of 10 keys
metadata = self._filter_metadata(metadata, item["id"]) metadata = self._filter_metadata(metadata, item["id"])
+14 -3
View File
@@ -1,6 +1,10 @@
from open_webui.retrieval.vector.main import VectorDBBase from open_webui.retrieval.vector.main import VectorDBBase
from open_webui.retrieval.vector.type import VectorType from open_webui.retrieval.vector.type import VectorType
from open_webui.config import VECTOR_DB, ENABLE_QDRANT_MULTITENANCY_MODE from open_webui.config import (
VECTOR_DB,
ENABLE_QDRANT_MULTITENANCY_MODE,
ENABLE_MILVUS_MULTITENANCY_MODE,
)
class Vector: class Vector:
@@ -12,9 +16,16 @@ class Vector:
""" """
match vector_type: match vector_type:
case VectorType.MILVUS: case VectorType.MILVUS:
from open_webui.retrieval.vector.dbs.milvus import MilvusClient if ENABLE_MILVUS_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.milvus_multitenancy import (
MilvusClient,
)
return MilvusClient() return MilvusClient()
else:
from open_webui.retrieval.vector.dbs.milvus import MilvusClient
return MilvusClient()
case VectorType.QDRANT: case VectorType.QDRANT:
if ENABLE_QDRANT_MULTITENANCY_MODE: if ENABLE_QDRANT_MULTITENANCY_MODE:
from open_webui.retrieval.vector.dbs.qdrant_multitenancy import ( from open_webui.retrieval.vector.dbs.qdrant_multitenancy import (
+15 -1
View File
@@ -1,10 +1,24 @@
from datetime import datetime from datetime import datetime
KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"]
def stringify_metadata(
def filter_metadata(metadata: dict[str, any]) -> dict[str, any]:
metadata = {
key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE
}
return metadata
def process_metadata(
metadata: dict[str, any], metadata: dict[str, any],
) -> dict[str, any]: ) -> dict[str, any]:
for key, value in metadata.items(): for key, value in metadata.items():
# Remove large fields
if key in KEYS_TO_EXCLUDE:
del metadata[key]
# Convert non-serializable fields to strings
if ( if (
isinstance(value, datetime) isinstance(value, datetime)
or isinstance(value, list) or isinstance(value, list)
+2 -1
View File
@@ -75,7 +75,8 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]:
try: try:
if validate_url(u): if validate_url(u):
valid_urls.append(u) valid_urls.append(u)
except ValueError: except Exception as e:
log.debug(f"Invalid URL {u}: {str(e)}")
continue continue
return valid_urls return valid_urls
+14 -4
View File
@@ -3,6 +3,7 @@ import json
import logging import logging
import os import os
import uuid import uuid
import html
from functools import lru_cache from functools import lru_cache
from pydub import AudioSegment from pydub import AudioSegment
from pydub.silence import split_on_silence from pydub.silence import split_on_silence
@@ -153,6 +154,7 @@ def set_faster_whisper_model(model: str, auto_update: bool = False):
class TTSConfigForm(BaseModel): class TTSConfigForm(BaseModel):
OPENAI_API_BASE_URL: str OPENAI_API_BASE_URL: str
OPENAI_API_KEY: str OPENAI_API_KEY: str
OPENAI_PARAMS: Optional[dict] = None
API_KEY: str API_KEY: str
ENGINE: str ENGINE: str
MODEL: str MODEL: str
@@ -189,6 +191,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
"tts": { "tts": {
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL, "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY, "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
"OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
"API_KEY": request.app.state.config.TTS_API_KEY, "API_KEY": request.app.state.config.TTS_API_KEY,
"ENGINE": request.app.state.config.TTS_ENGINE, "ENGINE": request.app.state.config.TTS_ENGINE,
"MODEL": request.app.state.config.TTS_MODEL, "MODEL": request.app.state.config.TTS_MODEL,
@@ -221,6 +224,7 @@ async def update_audio_config(
): ):
request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL
request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY
request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS
request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY request.app.state.config.TTS_API_KEY = form_data.tts.API_KEY
request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE request.app.state.config.TTS_ENGINE = form_data.tts.ENGINE
request.app.state.config.TTS_MODEL = form_data.tts.MODEL request.app.state.config.TTS_MODEL = form_data.tts.MODEL
@@ -261,12 +265,13 @@ async def update_audio_config(
return { return {
"tts": { "tts": {
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
"API_KEY": request.app.state.config.TTS_API_KEY,
"ENGINE": request.app.state.config.TTS_ENGINE, "ENGINE": request.app.state.config.TTS_ENGINE,
"MODEL": request.app.state.config.TTS_MODEL, "MODEL": request.app.state.config.TTS_MODEL,
"VOICE": request.app.state.config.TTS_VOICE, "VOICE": request.app.state.config.TTS_VOICE,
"OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL,
"OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY,
"OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS,
"API_KEY": request.app.state.config.TTS_API_KEY,
"SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON,
"AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION,
"AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL,
@@ -336,6 +341,11 @@ async def speech(request: Request, user=Depends(get_verified_user)):
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
timeout=timeout, trust_env=True timeout=timeout, trust_env=True
) as session: ) as session:
payload = {
**payload,
**(request.app.state.config.TTS_OPENAI_PARAMS or {}),
}
r = await session.post( r = await session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
json=payload, json=payload,
@@ -458,7 +468,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
try: try:
data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}"> data = f"""<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="{locale}">
<voice name="{language}">{payload["input"]}</voice> <voice name="{language}">{html.escape(payload["input"])}</voice>
</speak>""" </speak>"""
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
+69 -76
View File
@@ -167,7 +167,7 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
class MessageUserResponse(MessageResponse): class MessageUserResponse(MessageResponse):
user: UserNameResponse pass
@router.get("/{id}/messages", response_model=list[MessageUserResponse]) @router.get("/{id}/messages", response_model=list[MessageUserResponse])
@@ -196,15 +196,17 @@ async def get_channel_messages(
user = Users.get_user_by_id(message.user_id) user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user users[message.user_id] = user
replies = Messages.get_replies_by_message_id(message.id) thread_replies = Messages.get_thread_replies_by_message_id(message.id)
latest_reply_at = replies[0].created_at if replies else None latest_thread_reply_at = (
thread_replies[0].created_at if thread_replies else None
)
messages.append( messages.append(
MessageUserResponse( MessageUserResponse(
**{ **{
**message.model_dump(), **message.model_dump(),
"reply_count": len(replies), "reply_count": len(thread_replies),
"latest_reply_at": latest_reply_at, "latest_reply_at": latest_thread_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id), "reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()), "user": UserNameResponse(**users[message.user_id].model_dump()),
} }
@@ -253,12 +255,26 @@ async def model_response_handler(request, channel, message, user):
mentions = extract_mentions(message.content) mentions = extract_mentions(message.content)
message_content = replace_mentions(message.content) message_content = replace_mentions(message.content)
model_mentions = {}
# check if the message is a reply to a message sent by a model
if (
message.reply_to_message
and message.reply_to_message.meta
and message.reply_to_message.meta.get("model_id", None)
):
model_id = message.reply_to_message.meta.get("model_id", None)
model_mentions[model_id] = {"id": model_id, "id_type": "M"}
# check if any of the mentions are models # check if any of the mentions are models
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"] for mention in mentions:
if mention["id_type"] == "M" and mention["id"] not in model_mentions:
model_mentions[mention["id"]] = mention
if not model_mentions: if not model_mentions:
return False return False
for mention in model_mentions: for mention in model_mentions.values():
model_id = mention["id"] model_id = mention["id"]
model = MODELS.get(model_id, None) model = MODELS.get(model_id, None)
@@ -324,11 +340,12 @@ async def model_response_handler(request, channel, message, user):
if file.get("type", "") == "image": if file.get("type", "") == "image":
images.append(file.get("url", "")) images.append(file.get("url", ""))
thread_history_string = "\n\n".join(thread_history)
system_message = { system_message = {
"role": "system", "role": "system",
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational." "content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational."
+ ( + (
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context." f"Here's the thread history:\n\n\n{thread_history_string}\n\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context."
if thread_history if thread_history
else "" else ""
), ),
@@ -368,19 +385,34 @@ async def model_response_handler(request, channel, message, user):
) )
if res: if res:
await update_message_by_id( if res.get("choices", []) and len(res["choices"]) > 0:
channel.id, await update_message_by_id(
response_message.id, channel.id,
MessageForm( response_message.id,
**{ MessageForm(
"content": res["choices"][0]["message"]["content"], **{
"meta": { "content": res["choices"][0]["message"]["content"],
"done": True, "meta": {
}, "done": True,
} },
), }
user, ),
) user,
)
elif res.get("error", None):
await update_message_by_id(
channel.id,
response_message.id,
MessageForm(
**{
"content": f"Error: {res['error']}",
"meta": {
"done": True,
},
}
),
user,
)
except Exception as e: except Exception as e:
log.info(e) log.info(e)
pass pass
@@ -406,31 +438,21 @@ async def new_message_handler(
try: try:
message = Messages.insert_new_message(form_data, channel.id, user.id) message = Messages.insert_new_message(form_data, channel.id, user.id)
if message: if message:
message = Messages.get_message_by_id(message.id)
event_data = { event_data = {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": message.id, "message_id": message.id,
"data": { "data": {
"type": "message", "type": "message",
"data": MessageUserResponse( "data": message.model_dump(),
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(
message.id
),
"user": UserNameResponse(**user.model_dump()),
}
).model_dump(),
}, },
"user": UserNameResponse(**user.model_dump()).model_dump(), "user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(), "channel": channel.model_dump(),
} }
await sio.emit( await sio.emit(
"channel-events", "events:channel",
event_data, event_data,
to=f"channel:{channel.id}", to=f"channel:{channel.id}",
) )
@@ -441,29 +463,22 @@ async def new_message_handler(
if parent_message: if parent_message:
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": parent_message.id, "message_id": parent_message.id,
"data": { "data": {
"type": "message:reply", "type": "message:reply",
"data": MessageUserResponse( "data": parent_message.model_dump(),
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
}, },
"user": UserNameResponse(**user.model_dump()).model_dump(), "user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(), "channel": channel.model_dump(),
}, },
to=f"channel:{channel.id}", to=f"channel:{channel.id}",
) )
return MessageModel(**message.model_dump()), channel return message, channel
else:
raise Exception("Error creating message")
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
raise HTTPException( raise HTTPException(
@@ -645,20 +660,13 @@ async def update_message_by_id(
if message: if message:
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": message.id, "message_id": message.id,
"data": { "data": {
"type": "message:update", "type": "message:update",
"data": MessageUserResponse( "data": message.model_dump(),
**{
**message.model_dump(),
"user": UserNameResponse(
**user.model_dump()
).model_dump(),
}
).model_dump(),
}, },
"user": UserNameResponse(**user.model_dump()).model_dump(), "user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(), "channel": channel.model_dump(),
@@ -716,7 +724,7 @@ async def add_reaction_to_message(
message = Messages.get_message_by_id(message_id) message = Messages.get_message_by_id(message_id)
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": message.id, "message_id": message.id,
@@ -724,9 +732,6 @@ async def add_reaction_to_message(
"type": "message:reaction:add", "type": "message:reaction:add",
"data": { "data": {
**message.model_dump(), **message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name, "name": form_data.name,
}, },
}, },
@@ -785,7 +790,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
message = Messages.get_message_by_id(message_id) message = Messages.get_message_by_id(message_id)
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": message.id, "message_id": message.id,
@@ -793,9 +798,6 @@ async def remove_reaction_by_id_and_user_id_and_name(
"type": "message:reaction:remove", "type": "message:reaction:remove",
"data": { "data": {
**message.model_dump(), **message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name, "name": form_data.name,
}, },
}, },
@@ -853,7 +855,7 @@ async def delete_message_by_id(
try: try:
Messages.delete_message_by_id(message_id) Messages.delete_message_by_id(message_id)
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": message.id, "message_id": message.id,
@@ -876,22 +878,13 @@ async def delete_message_by_id(
if parent_message: if parent_message:
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": channel.id, "channel_id": channel.id,
"message_id": parent_message.id, "message_id": parent_message.id,
"data": { "data": {
"type": "message:reply", "type": "message:reply",
"data": MessageUserResponse( "data": parent_message.model_dump(),
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
}, },
"user": UserNameResponse(**user.model_dump()).model_dump(), "user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(), "channel": channel.model_dump(),
+32
View File
@@ -218,6 +218,28 @@ async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user)
] ]
@router.get("/folder/{folder_id}/list")
async def get_chat_list_by_folder_id(
folder_id: str, page: Optional[int] = 1, user=Depends(get_verified_user)
):
try:
limit = 60
skip = (page - 1) * limit
return [
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder_id, user.id, skip=skip, limit=limit
)
]
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################ ############################
# GetPinnedChats # GetPinnedChats
############################ ############################
@@ -339,6 +361,16 @@ async def archive_all_chats(user=Depends(get_verified_user)):
return Chats.archive_all_chats_by_user_id(user.id) return Chats.archive_all_chats_by_user_id(user.id)
############################
# UnarchiveAllChats
############################
@router.post("/unarchive/all", response_model=bool)
async def unarchive_all_chats(user=Depends(get_verified_user)):
return Chats.unarchive_all_chats_by_user_id(user.id)
############################ ############################
# GetSharedChatById # GetSharedChatById
############################ ############################
+30 -29
View File
@@ -207,38 +207,39 @@ async def verify_tool_servers_config(
if form_data.type == "mcp": if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1": if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url) discovery_urls = get_discovery_urls(form_data.url)
async with aiohttp.ClientSession() as session: for discovery_url in discovery_urls:
async with session.get( log.debug(
discovery_urls[0] f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}"
) as oauth_server_metadata_response: )
if oauth_server_metadata_response.status != 200: async with aiohttp.ClientSession() as session:
raise HTTPException( async with session.get(
status_code=400, discovery_url
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}", ) as oauth_server_metadata_response:
) if oauth_server_metadata_response.status == 200:
try:
try: oauth_server_metadata = (
oauth_server_metadata = OAuthMetadata.model_validate( OAuthMetadata.model_validate(
await oauth_server_metadata_response.json() await oauth_server_metadata_response.json()
) )
return { )
"status": True, return {
"oauth_server_metadata": oauth_server_metadata.model_dump( "status": True,
mode="json" "oauth_server_metadata": oauth_server_metadata.model_dump(
), mode="json"
} ),
except Exception as e: }
log.info( except Exception as e:
f"Failed to parse OAuth 2.1 discovery document: {e}" log.info(
) f"Failed to parse OAuth 2.1 discovery document: {e}"
raise HTTPException( )
status_code=400, raise HTTPException(
detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_urls[0]}", status_code=400,
) detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_url}",
)
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}", detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}",
) )
else: else:
try: try:
+37 -29
View File
@@ -12,6 +12,7 @@ from open_webui.models.folders import (
FolderForm, FolderForm,
FolderUpdateForm, FolderUpdateForm,
FolderModel, FolderModel,
FolderNameIdResponse,
Folders, Folders,
) )
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
@@ -44,12 +45,20 @@ router = APIRouter()
############################ ############################
@router.get("/", response_model=list[FolderModel]) @router.get("/", response_model=list[FolderNameIdResponse])
async def get_folders(user=Depends(get_verified_user)): async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id) folders = Folders.get_folders_by_user_id(user.id)
# Verify folder data integrity # Verify folder data integrity
folder_list = []
for folder in folders: for folder in folders:
if folder.parent_id and not Folders.get_folder_by_id_and_user_id(
folder.parent_id, user.id
):
folder = Folders.update_folder_parent_id_by_id_and_user_id(
folder.id, user.id, None
)
if folder.data: if folder.data:
if "files" in folder.data: if "files" in folder.data:
valid_files = [] valid_files = []
@@ -73,20 +82,9 @@ async def get_folders(user=Depends(get_verified_user)):
folder.id, user.id, FolderUpdateForm(data=folder.data) folder.id, user.id, FolderUpdateForm(data=folder.data)
) )
return [ folder_list.append(FolderNameIdResponse(**folder.model_dump()))
{
**folder.model_dump(), return folder_list
"items": {
"chats": [
{"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(
folder.id, user.id
)
]
},
}
for folder in folders
]
############################ ############################
@@ -272,21 +270,31 @@ async def delete_folder_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folders = []
if folder: folders.append(Folders.get_folder_by_id_and_user_id(id, user.id))
try: while folders:
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id) folder = folders.pop()
for folder_id in folder_ids: if folder:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id) try:
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id)
for folder_id in folder_ids:
Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id)
return True
except Exception as e:
log.exception(e)
log.error(f"Error deleting folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
)
finally:
# Get all subfolders
subfolders = Folders.get_folders_by_parent_id_and_user_id(
folder.id, user.id
)
folders.extend(subfolders)
return True
except Exception as e:
log.exception(e)
log.error(f"Error deleting folder: {id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"),
)
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+6
View File
@@ -10,6 +10,7 @@ from open_webui.models.functions import (
FunctionForm, FunctionForm,
FunctionModel, FunctionModel,
FunctionResponse, FunctionResponse,
FunctionUserResponse,
FunctionWithValvesModel, FunctionWithValvesModel,
Functions, Functions,
) )
@@ -42,6 +43,11 @@ async def get_functions(user=Depends(get_verified_user)):
return Functions.get_functions() return Functions.get_functions()
@router.get("/list", response_model=list[FunctionUserResponse])
async def get_function_list(user=Depends(get_admin_user)):
return Functions.get_function_list()
############################ ############################
# ExportFunctions # ExportFunctions
############################ ############################
+57 -1
View File
@@ -1,6 +1,9 @@
from typing import Optional from typing import Optional
import io import io
import base64 import base64
import json
import asyncio
import logging
from open_webui.models.models import ( from open_webui.models.models import (
ModelForm, ModelForm,
@@ -12,7 +15,14 @@ from open_webui.models.models import (
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status, Response from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
status,
Response,
)
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
@@ -20,6 +30,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -93,6 +105,50 @@ async def export_models(user=Depends(get_admin_user)):
return Models.get_models() return Models.get_models()
############################
# ImportModels
############################
class ModelsImportForm(BaseModel):
models: list[dict]
@router.post("/import", response_model=bool)
async def import_models(
user: str = Depends(get_admin_user), form_data: ModelsImportForm = (...)
):
try:
data = form_data.models
if isinstance(data, list):
for model_data in data:
# Here, you can add logic to validate model_data if needed
model_id = model_data.get("id")
if model_id:
existing_model = Models.get_model_by_id(model_id)
if existing_model:
# Update existing model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
updated_model = ModelForm(
**{**existing_model.model_dump(), **model_data}
)
Models.update_model_by_id(model_id, updated_model)
else:
# Insert new model
model_data["meta"] = model_data.get("meta", {})
model_data["params"] = model_data.get("params", {})
new_model = ModelForm(**model_data)
Models.insert_new_model(user_id=user.id, form_data=new_model)
return True
else:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except Exception as e:
log.exception(e)
raise HTTPException(status_code=500, detail=str(e))
############################ ############################
# SyncModels # SyncModels
############################ ############################
+12
View File
@@ -180,6 +180,18 @@ async def update_note_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
) )
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_notes",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
try: try:
note = Notes.update_note_by_id(id, form_data) note = Notes.update_note_by_id(id, form_data)
await sio.emit( await sio.emit(
+4
View File
@@ -1020,6 +1020,10 @@ class GenerateEmbedForm(BaseModel):
options: Optional[dict] = None options: Optional[dict] = None
keep_alive: Optional[Union[int, str]] = None keep_alive: Optional[Union[int, str]] = None
model_config = ConfigDict(
extra="allow",
)
@router.post("/api/embed") @router.post("/api/embed")
@router.post("/api/embed/{url_idx}") @router.post("/api/embed/{url_idx}")
+3
View File
@@ -190,6 +190,9 @@ async def get_headers_and_cookies(
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
if config.get("headers") and isinstance(config.get("headers"), dict):
headers = {**headers, **config.get("headers")}
return headers, cookies return headers, cookies
+48 -54
View File
@@ -5,6 +5,7 @@ import os
import shutil import shutil
import asyncio import asyncio
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -70,6 +71,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.external import search_external from open_webui.retrieval.web.external import search_external
from open_webui.retrieval.utils import ( from open_webui.retrieval.utils import (
get_content_from_url,
get_embedding_function, get_embedding_function,
get_reranking_function, get_reranking_function,
get_model_path, get_model_path,
@@ -78,6 +80,7 @@ from open_webui.retrieval.utils import (
query_doc, query_doc,
query_doc_with_hybrid_search, query_doc_with_hybrid_search,
) )
from open_webui.retrieval.vector.utils import filter_metadata
from open_webui.utils.misc import ( from open_webui.utils.misc import (
calculate_sha256_string, calculate_sha256_string,
) )
@@ -188,6 +191,26 @@ def get_rf(
log.error(f"CrossEncoder: {e}") log.error(f"CrossEncoder: {e}")
raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error"))
# Safely adjust pad_token_id if missing as some models do not have this in config
try:
model_cfg = getattr(rf, "model", None)
if model_cfg and hasattr(model_cfg, "config"):
cfg = model_cfg.config
if getattr(cfg, "pad_token_id", None) is None:
# Fallback to eos_token_id when available
eos = getattr(cfg, "eos_token_id", None)
if eos is not None:
cfg.pad_token_id = eos
log.debug(
f"Missing pad_token_id detected; set to eos_token_id={eos}"
)
else:
log.warning(
"Neither pad_token_id nor eos_token_id present in model config"
)
except Exception as e2:
log.warning(f"Failed to adjust pad_token_id on CrossEncoder: {e2}")
return rf return rf
@@ -428,6 +451,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR, "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR, "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
@@ -589,6 +613,7 @@ class ConfigForm(BaseModel):
# Content extraction settings # Content extraction settings
CONTENT_EXTRACTION_ENGINE: Optional[str] = None CONTENT_EXTRACTION_ENGINE: Optional[str] = None
PDF_EXTRACT_IMAGES: Optional[bool] = None PDF_EXTRACT_IMAGES: Optional[bool] = None
DATALAB_MARKER_API_KEY: Optional[str] = None DATALAB_MARKER_API_KEY: Optional[str] = None
DATALAB_MARKER_API_BASE_URL: Optional[str] = None DATALAB_MARKER_API_BASE_URL: Optional[str] = None
DATALAB_MARKER_ADDITIONAL_CONFIG: Optional[str] = None DATALAB_MARKER_ADDITIONAL_CONFIG: Optional[str] = None
@@ -600,11 +625,13 @@ class ConfigForm(BaseModel):
DATALAB_MARKER_FORMAT_LINES: Optional[bool] = None DATALAB_MARKER_FORMAT_LINES: Optional[bool] = None
DATALAB_MARKER_USE_LLM: Optional[bool] = None DATALAB_MARKER_USE_LLM: Optional[bool] = None
DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None DATALAB_MARKER_OUTPUT_FORMAT: Optional[str] = None
EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None EXTERNAL_DOCUMENT_LOADER_URL: Optional[str] = None
EXTERNAL_DOCUMENT_LOADER_API_KEY: Optional[str] = None EXTERNAL_DOCUMENT_LOADER_API_KEY: Optional[str] = None
TIKA_SERVER_URL: Optional[str] = None TIKA_SERVER_URL: Optional[str] = None
DOCLING_SERVER_URL: Optional[str] = None DOCLING_SERVER_URL: Optional[str] = None
DOCLING_PARAMS: Optional[dict] = None
DOCLING_DO_OCR: Optional[bool] = None DOCLING_DO_OCR: Optional[bool] = None
DOCLING_FORCE_OCR: Optional[bool] = None DOCLING_FORCE_OCR: Optional[bool] = None
DOCLING_OCR_ENGINE: Optional[str] = None DOCLING_OCR_ENGINE: Optional[str] = None
@@ -781,6 +808,11 @@ async def update_rag_config(
if form_data.DOCLING_SERVER_URL is not None if form_data.DOCLING_SERVER_URL is not None
else request.app.state.config.DOCLING_SERVER_URL else request.app.state.config.DOCLING_SERVER_URL
) )
request.app.state.config.DOCLING_PARAMS = (
form_data.DOCLING_PARAMS
if form_data.DOCLING_PARAMS is not None
else request.app.state.config.DOCLING_PARAMS
)
request.app.state.config.DOCLING_DO_OCR = ( request.app.state.config.DOCLING_DO_OCR = (
form_data.DOCLING_DO_OCR form_data.DOCLING_DO_OCR
if form_data.DOCLING_DO_OCR is not None if form_data.DOCLING_DO_OCR is not None
@@ -1103,6 +1135,7 @@ async def update_rag_config(
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR, "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR, "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE, "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
@@ -1521,6 +1554,7 @@ def process_file(
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE, "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL, "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API, "picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
**request.app.state.config.DOCLING_PARAMS,
}, },
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES, PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
@@ -1535,7 +1569,7 @@ def process_file(
Document( Document(
page_content=doc.page_content, page_content=doc.page_content,
metadata={ metadata={
**doc.metadata, **filter_metadata(doc.metadata),
"name": file.filename, "name": file.filename,
"created_by": file.user_id, "created_by": file.user_id,
"file_id": file.id, "file_id": file.id,
@@ -1679,49 +1713,6 @@ def process_text(
@router.post("/process/youtube") @router.post("/process/youtube")
def process_youtube_video(
request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user)
):
try:
collection_name = form_data.collection_name
if not collection_name:
collection_name = calculate_sha256_string(form_data.url)[:63]
loader = YoutubeLoader(
form_data.url,
language=request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
proxy_url=request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
)
docs = loader.load()
content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {content}")
save_docs_to_vector_db(
request, docs, collection_name, overwrite=True, user=user
)
return {
"status": True,
"collection_name": collection_name,
"filename": form_data.url,
"file": {
"data": {
"content": content,
},
"meta": {
"name": form_data.url,
},
},
}
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT(e),
)
@router.post("/process/web") @router.post("/process/web")
def process_web( def process_web(
request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user) request: Request, form_data: ProcessUrlForm, user=Depends(get_verified_user)
@@ -1731,19 +1722,16 @@ def process_web(
if not collection_name: if not collection_name:
collection_name = calculate_sha256_string(form_data.url)[:63] collection_name = calculate_sha256_string(form_data.url)[:63]
loader = get_web_loader( content, docs = get_content_from_url(request, form_data.url)
form_data.url,
verify_ssl=request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
)
docs = loader.load()
content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {content}") log.debug(f"text_content: {content}")
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
save_docs_to_vector_db( save_docs_to_vector_db(
request, docs, collection_name, overwrite=True, user=user request,
docs,
collection_name,
overwrite=True,
user=user,
) )
else: else:
collection_name = None collection_name = None
@@ -2046,7 +2034,7 @@ async def process_web_search(
result_items = [] result_items = []
try: try:
logging.info( logging.debug(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
) )
@@ -2080,6 +2068,12 @@ async def process_web_search(
detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e), detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e),
) )
if len(urls) == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.DEFAULT("No results found from web search"),
)
try: try:
if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER: if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER:
search_results = [ search_results = [
+25 -9
View File
@@ -17,7 +17,11 @@ from open_webui.models.tools import (
ToolUserResponse, ToolUserResponse,
Tools, Tools,
) )
from open_webui.utils.plugin import load_tool_module_by_id, replace_imports from open_webui.utils.plugin import (
load_tool_module_by_id,
replace_imports,
get_tool_module_from_cache,
)
from open_webui.utils.tools import get_tool_specs from open_webui.utils.tools import get_tool_specs
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
@@ -35,6 +39,14 @@ log.setLevel(SRC_LOG_LEVELS["MAIN"])
router = APIRouter() router = APIRouter()
def get_tool_module(request, tool_id, load_from_db=True):
"""
Get the tool module by its ID.
"""
tool_module, _ = get_tool_module_from_cache(request, tool_id, load_from_db)
return tool_module
############################ ############################
# GetTools # GetTools
############################ ############################
@@ -42,15 +54,19 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse]) @router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)): async def get_tools(request: Request, user=Depends(get_verified_user)):
tools = [ tools = []
ToolUserResponse(
**{ # Local Tools
**tool.model_dump(), for tool in Tools.get_tools():
"has_user_valves": "class UserValves(BaseModel):" in tool.content, tool_module = get_tool_module(request, tool.id)
} tools.append(
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": hasattr(tool_module, "UserValves"),
}
)
) )
for tool in Tools.get_tools()
]
# OpenAPI Tool Servers # OpenAPI Tool Servers
for server in await get_tool_servers(request): for server in await get_tool_servers(request):
+1
View File
@@ -157,6 +157,7 @@ class SharingPermissions(BaseModel):
public_knowledge: bool = True public_knowledge: bool = True
public_prompts: bool = True public_prompts: bool = True
public_tools: bool = True public_tools: bool = True
public_notes: bool = True
class ChatPermissions(BaseModel): class ChatPermissions(BaseModel):
+31 -8
View File
@@ -356,7 +356,7 @@ async def join_note(sid, data):
await sio.enter_room(sid, f"note:{note.id}") await sio.enter_room(sid, f"note:{note.id}")
@sio.on("channel-events") @sio.on("events:channel")
async def channel_events(sid, data): async def channel_events(sid, data):
room = f"channel:{data['channel_id']}" room = f"channel:{data['channel_id']}"
participants = sio.manager.get_participants( participants = sio.manager.get_participants(
@@ -373,7 +373,7 @@ async def channel_events(sid, data):
if event_type == "typing": if event_type == "typing":
await sio.emit( await sio.emit(
"channel-events", "events:channel",
{ {
"channel_id": data["channel_id"], "channel_id": data["channel_id"],
"message_id": data.get("message_id", None), "message_id": data.get("message_id", None),
@@ -653,12 +653,15 @@ def get_event_emitter(request_info, update_db=True):
) )
) )
chat_id = request_info.get("chat_id", None)
message_id = request_info.get("message_id", None)
emit_tasks = [ emit_tasks = [
sio.emit( sio.emit(
"chat-events", "events",
{ {
"chat_id": request_info.get("chat_id", None), "chat_id": chat_id,
"message_id": request_info.get("message_id", None), "message_id": message_id,
"data": event_data, "data": event_data,
}, },
to=session_id, to=session_id,
@@ -667,8 +670,11 @@ def get_event_emitter(request_info, update_db=True):
] ]
await asyncio.gather(*emit_tasks) await asyncio.gather(*emit_tasks)
if (
if update_db: update_db
and message_id
and not request_info.get("chat_id", "").startswith("local:")
):
if "type" in event_data and event_data["type"] == "status": if "type" in event_data and event_data["type"] == "status":
Chats.add_message_status_to_chat_by_id_and_message_id( Chats.add_message_status_to_chat_by_id_and_message_id(
request_info["chat_id"], request_info["chat_id"],
@@ -705,6 +711,23 @@ def get_event_emitter(request_info, update_db=True):
}, },
) )
if "type" in event_data and event_data["type"] == "embeds":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
embeds = event_data.get("data", {}).get("embeds", [])
embeds.extend(message.get("embeds", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"embeds": embeds,
},
)
if "type" in event_data and event_data["type"] == "files": if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id( message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"], request_info["chat_id"],
@@ -747,7 +770,7 @@ def get_event_emitter(request_info, update_db=True):
def get_event_call(request_info): def get_event_call(request_info):
async def __event_caller__(event_data): async def __event_caller__(event_data):
response = await sio.call( response = await sio.call(
"chat-events", "events",
{ {
"chat_id": request_info.get("chat_id", None), "chat_id": request_info.get("chat_id", None),
"message_id": request_info.get("message_id", None), "message_id": request_info.get("message_id", None),
+4 -1
View File
@@ -164,7 +164,10 @@ async def stop_task(redis, task_id: str):
# Task successfully canceled # Task successfully canceled
return {"status": True, "message": f"Task {task_id} successfully stopped."} return {"status": True, "message": f"Task {task_id} successfully stopped."}
return {"status": False, "message": f"Failed to stop task {task_id}."} if task.cancelled() or task.done():
return {"status": True, "message": f"Task {task_id} successfully cancelled."}
return {"status": True, "message": f"Cancellation requested for {task_id}."}
async def stop_item_tasks(redis: Redis, item_id: str): async def stop_item_tasks(redis: Redis, item_id: str):
+14 -11
View File
@@ -6,7 +6,7 @@ import hmac
import hashlib import hashlib
import requests import requests
import os import os
import bcrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.hazmat.primitives.asymmetric import ed25519
@@ -38,11 +38,8 @@ from open_webui.env import (
from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status from fastapi import BackgroundTasks, Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from passlib.context import CryptContext
logging.getLogger("passlib").setLevel(logging.ERROR)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OAUTH"]) log.setLevel(SRC_LOG_LEVELS["OAUTH"])
@@ -155,19 +152,25 @@ def get_license_data(app, key):
bearer_security = HTTPBearer(auto_error=False) bearer_security = HTTPBearer(auto_error=False)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password): def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
return ( return (
pwd_context.verify(plain_password, hashed_password) if hashed_password else None bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
if hashed_password
else None
) )
def get_password_hash(password):
return pwd_context.hash(password)
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
payload = data.copy() payload = data.copy()
+5 -1
View File
@@ -80,6 +80,7 @@ async def generate_direct_chat_completion(
event_caller = get_event_call(metadata) event_caller = get_event_call(metadata)
channel = f"{user_id}:{session_id}:{request_id}" channel = f"{user_id}:{session_id}:{request_id}"
logging.info(f"WebSocket channel: {channel}")
if form_data.get("stream"): if form_data.get("stream"):
q = asyncio.Queue() q = asyncio.Queue()
@@ -121,7 +122,10 @@ async def generate_direct_chat_completion(
yield f"data: {json.dumps(data)}\n\n" yield f"data: {json.dumps(data)}\n\n"
elif isinstance(data, str): elif isinstance(data, str):
yield data if "data:" in data:
yield f"{data}\n\n"
else:
yield f"data: {data}\n\n"
except Exception as e: except Exception as e:
log.debug(f"Error in event generator: {e}") log.debug(f"Error in event generator: {e}")
pass pass
+405 -294
View File
@@ -40,7 +40,10 @@ from open_webui.routers.tasks import (
generate_image_prompt, generate_image_prompt,
generate_chat_tags, generate_chat_tags,
) )
from open_webui.routers.retrieval import process_web_search, SearchForm from open_webui.routers.retrieval import (
process_web_search,
SearchForm,
)
from open_webui.routers.images import ( from open_webui.routers.images import (
load_b64_image_data, load_b64_image_data,
image_generations, image_generations,
@@ -76,14 +79,17 @@ from open_webui.utils.task import (
) )
from open_webui.utils.misc import ( from open_webui.utils.misc import (
deep_update, deep_update,
extract_urls,
get_message_list, get_message_list,
add_or_update_system_message, add_or_update_system_message,
add_or_update_user_message, add_or_update_user_message,
get_last_user_message, get_last_user_message,
get_last_user_message_item,
get_last_assistant_message, get_last_assistant_message,
get_system_message, get_system_message,
prepend_to_first_user_message_content, prepend_to_first_user_message_content,
convert_logit_bias_input_to_json, convert_logit_bias_input_to_json,
get_content_from_message,
) )
from open_webui.utils.tools import get_tools from open_webui.utils.tools import get_tools
from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.plugin import load_function_module_by_id
@@ -133,6 +139,149 @@ DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")] DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
def process_tool_result(
request,
tool_function_name,
tool_result,
tool_type,
direct_tool=False,
metadata=None,
user=None,
):
tool_result_embeds = []
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get("Content-Disposition", "")
if "inline" in content_disposition:
content = tool_result.body.decode("utf-8", "replace")
tool_result_embeds.append(content)
if 200 <= tool_result.status_code < 300:
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.",
}
elif 500 <= tool_result.status_code < 600:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8", "replace")
elif (tool_type == "external" and isinstance(tool_result, tuple)) or (
direct_tool and isinstance(tool_result, list) and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
try:
if not isinstance(tool_response_headers, dict):
tool_response_headers = dict(tool_response_headers)
except Exception as e:
tool_response_headers = {}
log.debug(e)
if tool_response_headers and isinstance(tool_response_headers, dict):
content_disposition = tool_response_headers.get(
"Content-Disposition",
tool_response_headers.get("content-disposition", ""),
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type",
tool_response_headers.get("content-type", ""),
)
location = tool_response_headers.get(
"Location",
tool_response_headers.get("location", ""),
)
if "text/html" in content_type:
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
elif location:
tool_result_embeds.append(location)
tool_result = {
"status": "success",
"code": "ui_component",
"message": f"{tool_function_name}: Embedded UI result is active and visible to the user.",
}
tool_result_files = []
if isinstance(tool_result, list):
if tool_type == "mcp": # MCP
tool_response = []
for item in tool_result:
if isinstance(item, dict):
if item.get("type") == "text":
text = item.get("text", "")
if isinstance(text, str):
try:
text = json.loads(text)
except json.JSONDecodeError:
pass
tool_response.append(text)
elif item.get("type") in ["image", "audio"]:
file_url = get_file_url_from_base64(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get("chat_id", None),
"message_id": metadata.get("message_id", None),
"session_id": metadata.get("session_id", None),
"result": item,
},
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result = tool_response[0] if len(tool_response) == 1 else tool_response
else: # OpenAPI
for item in tool_result:
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if isinstance(tool_result, list):
tool_result = {"results": tool_result}
if isinstance(tool_result, dict) or isinstance(tool_result, list):
tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False)
return tool_result, tool_result_files, tool_result_embeds
async def chat_completion_tools_handler( async def chat_completion_tools_handler(
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]: ) -> tuple[dict, dict]:
@@ -140,7 +289,7 @@ async def chat_completion_tools_handler(
content = None content = None
if hasattr(response, "body_iterator"): if hasattr(response, "body_iterator"):
async for chunk in response.body_iterator: async for chunk in response.body_iterator:
data = json.loads(chunk.decode("utf-8")) data = json.loads(chunk.decode("utf-8", "replace"))
content = data["choices"][0]["message"]["content"] content = data["choices"][0]["message"]["content"]
# Cleanup any remaining background tasks if necessary # Cleanup any remaining background tasks if necessary
@@ -155,7 +304,7 @@ async def chat_completion_tools_handler(
recent_messages = messages[-4:] if len(messages) > 4 else messages recent_messages = messages[-4:] if len(messages) > 4 else messages
chat_history = "\n".join( chat_history = "\n".join(
f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\"" f"{message['role'].upper()}: \"\"\"{get_content_from_message(message)}\"\"\""
for message in recent_messages for message in recent_messages
) )
@@ -172,6 +321,7 @@ async def chat_completion_tools_handler(
} }
event_caller = extra_params["__event_call__"] event_caller = extra_params["__event_call__"]
event_emitter = extra_params["__event_emitter__"]
metadata = extra_params["__metadata__"] metadata = extra_params["__metadata__"]
task_model_id = get_task_model_id( task_model_id = get_task_model_id(
@@ -226,8 +376,14 @@ async def chat_completion_tools_handler(
tool_function_params = tool_call.get("parameters", {}) tool_function_params = tool_call.get("parameters", {})
tool = None
tool_type = ""
direct_tool = False
try: try:
tool = tools[tool_function_name] tool = tools[tool_function_name]
tool_type = tool.get("type", "")
direct_tool = tool.get("direct", False)
spec = tool.get("spec", {}) spec = tool.get("spec", {})
allowed_params = ( allowed_params = (
@@ -259,18 +415,46 @@ async def chat_completion_tools_handler(
except Exception as e: except Exception as e:
tool_result = str(e) tool_result = str(e)
tool_result_files = [] tool_result, tool_result_files, tool_result_embeds = (
if isinstance(tool_result, list): process_tool_result(
for item in tool_result: request,
# check if string tool_function_name,
if isinstance(item, str) and item.startswith("data:"): tool_result,
tool_result_files.append(item) tool_type,
tool_result.remove(item) direct_tool,
metadata,
user,
)
)
if isinstance(tool_result, dict) or isinstance(tool_result, list): if event_emitter:
tool_result = json.dumps(tool_result, indent=2) if tool_result_files:
await event_emitter(
{
"type": "files",
"data": {
"files": tool_result_files,
},
}
)
if isinstance(tool_result, str): if tool_result_embeds:
await event_emitter(
{
"type": "embeds",
"data": {
"embeds": tool_result_embeds,
},
}
)
print(
f"Tool {tool_function_name} result: {tool_result}",
tool_result_files,
tool_result_embeds,
)
if tool_result:
tool = tools[tool_function_name] tool = tools[tool_function_name]
tool_id = tool.get("tool_id", "") tool_id = tool.get("tool_id", "")
@@ -284,18 +468,19 @@ async def chat_completion_tools_handler(
sources.append( sources.append(
{ {
"source": { "source": {
"name": (f"TOOL:{tool_name}"), "name": (f"{tool_name}"),
}, },
"document": [tool_result], "document": [str(tool_result)],
"metadata": [ "metadata": [
{ {
"source": (f"TOOL:{tool_name}"), "source": (f"{tool_name}"),
"parameters": tool_function_params, "parameters": tool_function_params,
} }
], ],
"tool_result": True, "tool_result": True,
} }
) )
# Citation is not enabled for this tool # Citation is not enabled for this tool
body["messages"] = add_or_update_user_message( body["messages"] = add_or_update_user_message(
f"\nTool `{tool_name}` Output: {tool_result}", f"\nTool `{tool_name}` Output: {tool_result}",
@@ -642,7 +827,11 @@ async def chat_completion_files_handler(
if files := body.get("metadata", {}).get("files", None): if files := body.get("metadata", {}).get("files", None):
# Check if all files are in full context mode # Check if all files are in full context mode
all_full_context = all(item.get("context") == "full" for item in files) all_full_context = all(
item.get("context") == "full"
for item in files
if item.get("type") == "file"
)
queries = [] queries = []
if not all_full_context: if not all_full_context:
@@ -674,10 +863,6 @@ async def chat_completion_files_handler(
except: except:
pass pass
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
if not all_full_context:
await __event_emitter__( await __event_emitter__(
{ {
"type": "status", "type": "status",
@@ -689,6 +874,9 @@ async def chat_completion_files_handler(
} }
) )
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
try: try:
# Offload get_sources_from_items to a separate thread # Offload get_sources_from_items to a separate thread
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -727,7 +915,6 @@ async def chat_completion_files_handler(
log.debug(f"rag_contexts:sources: {sources}") log.debug(f"rag_contexts:sources: {sources}")
unique_ids = set() unique_ids = set()
for source in sources or []: for source in sources or []:
if not source or len(source.keys()) == 0: if not source or len(source.keys()) == 0:
continue continue
@@ -746,7 +933,6 @@ async def chat_completion_files_handler(
unique_ids.add(_id) unique_ids.add(_id)
sources_count = len(unique_ids) sources_count = len(unique_ids)
await __event_emitter__( await __event_emitter__(
{ {
"type": "status", "type": "status",
@@ -820,11 +1006,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.debug(f"form_data: {form_data}") log.debug(f"form_data: {form_data}")
system_message = get_system_message(form_data.get("messages", [])) system_message = get_system_message(form_data.get("messages", []))
if system_message: if system_message: # Chat Controls/User Settings
try: try:
form_data = apply_system_prompt_to_body( form_data = apply_system_prompt_to_body(
system_message.get("content"), form_data, metadata, user system_message.get("content"), form_data, metadata, user, replace=True
) ) # Required to handle system prompt variables
except: except:
pass pass
@@ -989,8 +1175,28 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tool_ids = form_data.pop("tool_ids", None) tool_ids = form_data.pop("tool_ids", None)
files = form_data.pop("files", None) files = form_data.pop("files", None)
# Remove files duplicates prompt = get_last_user_message(form_data["messages"])
# TODO: re-enable URL extraction from prompt
# urls = []
# if prompt and len(prompt or "") < 500 and (not files or len(files) == 0):
# urls = extract_urls(prompt)
if files: if files:
if not files:
files = []
for file_item in files:
if file_item.get("type", "file") == "folder":
# Get folder files
folder_id = file_item.get("id", None)
if folder_id:
folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id)
if folder and folder.data and "files" in folder.data:
files = [f for f in files if f.get("id", None) != folder_id]
files = [*files, *folder.data["files"]]
# files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
# Remove duplicate files based on their content
files = list({json.dumps(f, sort_keys=True): f for f in files}.values()) files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
metadata = { metadata = {
@@ -1010,7 +1216,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tools_dict = {} tools_dict = {}
mcp_clients = [] mcp_clients = {}
mcp_tools_dict = {} mcp_tools_dict = {}
if tool_ids: if tool_ids:
@@ -1071,35 +1277,38 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.error(f"Error getting OAuth token: {e}") log.error(f"Error getting OAuth token: {e}")
oauth_token = None oauth_token = None
mcp_client = MCPClient() mcp_clients[server_id] = MCPClient()
await mcp_client.connect( await mcp_clients[server_id].connect(
url=mcp_server_connection.get("url", ""), url=mcp_server_connection.get("url", ""),
headers=headers if headers else None, headers=headers if headers else None,
) )
tool_specs = await mcp_client.list_tool_specs() tool_specs = await mcp_clients[server_id].list_tool_specs()
for tool_spec in tool_specs: for tool_spec in tool_specs:
def make_tool_function(function_name): def make_tool_function(client, function_name):
async def tool_function(**kwargs): async def tool_function(**kwargs):
return await mcp_client.call_tool( return await client.call_tool(
function_name, function_name,
function_args=kwargs, function_args=kwargs,
) )
return tool_function return tool_function
tool_function = make_tool_function(tool_spec["name"]) tool_function = make_tool_function(
mcp_clients[server_id], tool_spec["name"]
)
mcp_tools_dict[tool_spec["name"]] = { mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = {
"spec": tool_spec, "spec": {
**tool_spec,
"name": f"{server_id}_{tool_spec['name']}",
},
"callable": tool_function, "callable": tool_function,
"type": "mcp", "type": "mcp",
"client": mcp_client, "client": mcp_clients[server_id],
"direct": False, "direct": False,
} }
mcp_clients.append(mcp_client)
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
continue continue
@@ -1140,7 +1349,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})} {"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values() for tool in tools_dict.values()
] ]
else: else:
# If the function calling is not native, then call the tools function calling handler # If the function calling is not native, then call the tools function calling handler
try: try:
@@ -1165,9 +1373,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
citation_idx_map = {} citation_idx_map = {}
for source in sources: for source in sources:
is_tool_result = source.get("tool_result", False) if "document" in source:
if "document" in source and not is_tool_result:
for document_text, document_metadata in zip( for document_text, document_metadata in zip(
source["document"], source["metadata"] source["document"], source["metadata"]
): ):
@@ -1188,8 +1394,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
) )
context_string = context_string.strip() context_string = context_string.strip()
prompt = get_last_user_message(form_data["messages"])
if prompt is None: if prompt is None:
raise Exception("No user message found") raise Exception("No user message found")
@@ -1235,10 +1439,13 @@ async def process_chat_response(
request, response, form_data, user, metadata, model, events, tasks request, response, form_data, user, metadata, model, events, tasks
): ):
async def background_tasks_handler(): async def background_tasks_handler():
messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"]) message = None
message = messages_map.get(metadata["message_id"]) if messages_map else None messages = []
if "chat_id" in metadata and not metadata["chat_id"].startswith("local:"):
messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"])
message = messages_map.get(metadata["message_id"]) if messages_map else None
if message:
message_list = get_message_list(messages_map, metadata["message_id"]) message_list = get_message_list(messages_map, metadata["message_id"])
# Remove details tags and files from the messages. # Remove details tags and files from the messages.
@@ -1271,7 +1478,14 @@ async def process_chat_response(
"content": content, "content": content,
} }
) )
else:
# Local temp chat, get the model and message from the form_data
message = get_last_user_message_item(form_data.get("messages", []))
messages = form_data.get("messages", [])
if message:
message["model"] = form_data.get("model")
if message and "model" in message:
if tasks and messages: if tasks and messages:
if ( if (
TASKS.FOLLOW_UP_GENERATION in tasks TASKS.FOLLOW_UP_GENERATION in tasks
@@ -1290,10 +1504,12 @@ async def process_chat_response(
if res and isinstance(res, dict): if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1: if len(res.get("choices", [])) == 1:
follow_ups_string = ( response_message = res.get("choices", [])[0].get(
res.get("choices", [])[0] "message", {}
.get("message", {}) )
.get("content", "")
follow_ups_string = response_message.get(
"content", response_message.get("reasoning_content", "")
) )
else: else:
follow_ups_string = "" follow_ups_string = ""
@@ -1307,15 +1523,6 @@ async def process_chat_response(
follow_ups = json.loads(follow_ups_string).get( follow_ups = json.loads(follow_ups_string).get(
"follow_ups", [] "follow_ups", []
) )
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"followUps": follow_ups,
},
)
await event_emitter( await event_emitter(
{ {
"type": "chat:message:follow_ups", "type": "chat:message:follow_ups",
@@ -1324,17 +1531,96 @@ async def process_chat_response(
}, },
} }
) )
if not metadata.get("chat_id", "").startswith("local:"):
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"followUps": follow_ups,
},
)
except Exception as e: except Exception as e:
pass pass
if TASKS.TITLE_GENERATION in tasks: if not metadata.get("chat_id", "").startswith(
user_message = get_last_user_message(messages) "local:"
if user_message and len(user_message) > 100: ): # Only update titles and tags for non-temp chats
user_message = user_message[:100] + "..." if (
TASKS.TITLE_GENERATION in tasks
and tasks[TASKS.TITLE_GENERATION]
):
user_message = get_last_user_message(messages)
if user_message and len(user_message) > 100:
user_message = user_message[:100] + "..."
if tasks[TASKS.TITLE_GENERATION]: if tasks[TASKS.TITLE_GENERATION]:
res = await generate_title( res = await generate_title(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1:
response_message = res.get("choices", [])[0].get(
"message", {}
)
title_string = response_message.get(
"content",
response_message.get(
"reasoning_content",
message.get("content", user_message),
),
)
else:
title_string = ""
title_string = title_string[
title_string.find("{") : title_string.rfind("}") + 1
]
try:
title = json.loads(title_string).get(
"title", user_message
)
except Exception as e:
title = ""
if not title:
title = messages[0].get("content", user_message)
Chats.update_chat_title_by_id(
metadata["chat_id"], title
)
await event_emitter(
{
"type": "chat:title",
"data": title,
}
)
elif len(messages) == 2:
title = messages[0].get("content", user_message)
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": message.get("content", user_message),
}
)
if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
res = await generate_chat_tags(
request, request,
{ {
"model": message["model"], "model": message["model"],
@@ -1346,89 +1632,35 @@ async def process_chat_response(
if res and isinstance(res, dict): if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1: if len(res.get("choices", [])) == 1:
title_string = ( response_message = res.get("choices", [])[0].get(
res.get("choices", [])[0] "message", {}
.get("message", {}) )
.get(
"content", message.get("content", user_message) tags_string = response_message.get(
) "content",
response_message.get("reasoning_content", ""),
) )
else: else:
title_string = "" tags_string = ""
title_string = title_string[ tags_string = tags_string[
title_string.find("{") : title_string.rfind("}") + 1 tags_string.find("{") : tags_string.rfind("}") + 1
] ]
try: try:
title = json.loads(title_string).get( tags = json.loads(tags_string).get("tags", [])
"title", user_message Chats.update_chat_tags_by_id(
metadata["chat_id"], tags, user
)
await event_emitter(
{
"type": "chat:tags",
"data": tags,
}
) )
except Exception as e: except Exception as e:
title = "" pass
if not title:
title = messages[0].get("content", user_message)
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": title,
}
)
elif len(messages) == 2:
title = messages[0].get("content", user_message)
Chats.update_chat_title_by_id(metadata["chat_id"], title)
await event_emitter(
{
"type": "chat:title",
"data": message.get("content", user_message),
}
)
if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
res = await generate_chat_tags(
request,
{
"model": message["model"],
"messages": messages,
"chat_id": metadata["chat_id"],
},
user,
)
if res and isinstance(res, dict):
if len(res.get("choices", [])) == 1:
tags_string = (
res.get("choices", [])[0]
.get("message", {})
.get("content", "")
)
else:
tags_string = ""
tags_string = tags_string[
tags_string.find("{") : tags_string.rfind("}") + 1
]
try:
tags = json.loads(tags_string).get("tags", [])
Chats.update_chat_tags_by_id(
metadata["chat_id"], tags, user
)
await event_emitter(
{
"type": "chat:tags",
"data": tags,
}
)
except Exception as e:
pass
event_emitter = None event_emitter = None
event_caller = None event_caller = None
@@ -1456,7 +1688,9 @@ async def process_chat_response(
response.body, bytes response.body, bytes
): ):
try: try:
response_data = json.loads(response.body.decode("utf-8")) response_data = json.loads(
response.body.decode("utf-8", "replace")
)
except json.JSONDecodeError: except json.JSONDecodeError:
response_data = { response_data = {
"error": {"detail": "Invalid JSON response"} "error": {"detail": "Invalid JSON response"}
@@ -2090,7 +2324,11 @@ async def process_chat_response(
last_delta_data = None last_delta_data = None
async for line in response.body_iterator: async for line in response.body_iterator:
line = line.decode("utf-8") if isinstance(line, bytes) else line line = (
line.decode("utf-8", "replace")
if isinstance(line, bytes)
else line
)
data = line data = line
# Skip empty lines # Skip empty lines
@@ -2436,7 +2674,9 @@ async def process_chat_response(
print("tool_call", tool_call) print("tool_call", tool_call)
tool_call_id = tool_call.get("id", "") tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get("name", "") tool_function_name = tool_call.get("function", {}).get(
"name", ""
)
tool_args = tool_call.get("function", {}).get("arguments", "{}") tool_args = tool_call.get("function", {}).get("arguments", "{}")
tool_function_params = {} tool_function_params = {}
@@ -2466,11 +2706,17 @@ async def process_chat_response(
) )
tool_result = None tool_result = None
tool = None
tool_type = None
direct_tool = False
if tool_name in tools: if tool_function_name in tools:
tool = tools[tool_name] tool = tools[tool_function_name]
spec = tool.get("spec", {}) spec = tool.get("spec", {})
tool_type = tool.get("type", "")
direct_tool = tool.get("direct", False)
try: try:
allowed_params = ( allowed_params = (
spec.get("parameters", {}) spec.get("parameters", {})
@@ -2484,13 +2730,13 @@ async def process_chat_response(
if k in allowed_params if k in allowed_params
} }
if tool.get("direct", False): if direct_tool:
tool_result = await event_caller( tool_result = await event_caller(
{ {
"type": "execute:tool", "type": "execute:tool",
"data": { "data": {
"id": str(uuid4()), "id": str(uuid4()),
"name": tool_name, "name": tool_function_name,
"params": tool_function_params, "params": tool_function_params,
"server": tool.get("server", {}), "server": tool.get("server", {}),
"session_id": metadata.get( "session_id": metadata.get(
@@ -2509,151 +2755,17 @@ async def process_chat_response(
except Exception as e: except Exception as e:
tool_result = str(e) tool_result = str(e)
tool_result_embeds = [] tool_result, tool_result_files, tool_result_embeds = (
if isinstance(tool_result, HTMLResponse): process_tool_result(
content_disposition = tool_result.headers.get( request,
"Content-Disposition", "" tool_function_name,
) tool_result,
if "inline" in content_disposition: tool_type,
content = tool_result.body.decode("utf-8") direct_tool,
tool_result_embeds.append(content) metadata,
user,
if 200 <= tool_result.status_code < 300:
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Client error {tool_result.status_code} from embedded UI result.",
}
elif 500 <= tool_result.status_code < 600:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8")
elif (
tool.get("type") == "external"
and isinstance(tool_result, tuple)
) or (
tool.get("direct", True)
and isinstance(tool_result, list)
and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
if tool_response_headers:
content_disposition = tool_response_headers.get(
"Content-Disposition",
tool_response_headers.get(
"content-disposition", ""
),
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type",
tool_response_headers.get("content-type", ""),
)
location = tool_response_headers.get(
"Location",
tool_response_headers.get("location", ""),
)
if "text/html" in content_type:
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
elif location:
tool_result_embeds.append(location)
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
tool_result_files = []
if isinstance(tool_result, list):
for item in tool_result:
# check if string
if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item)
if tool.get("type") == "mcp":
if isinstance(item, dict):
if (
item.get("type") == "image"
or item.get("type") == "audio"
):
file_url = get_file_url_from_base64(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get(
"chat_id", None
),
"message_id": metadata.get(
"message_id", None
),
"session_id": metadata.get(
"session_id", None
),
"result": item,
},
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result.remove(item)
if tool_result_files:
if not isinstance(tool_result, list):
tool_result = [
tool_result,
]
for file in tool_result_files:
tool_result.append(
{
"type": file.get("type", "data"),
"content": "Result is being displayed as a file.",
}
)
if isinstance(tool_result, dict) or isinstance(
tool_result, list
):
tool_result = json.dumps(
tool_result, indent=2, ensure_ascii=False
) )
)
results.append( results.append(
{ {
@@ -2673,7 +2785,6 @@ async def process_chat_response(
) )
content_blocks[-1]["results"] = results content_blocks[-1]["results"] = results
content_blocks.append( content_blocks.append(
{ {
"type": "text", "type": "text",
+16 -7
View File
@@ -136,6 +136,14 @@ def update_message_content(message: dict, content: str, append: bool = True) ->
return message return message
def replace_system_message_content(content: str, messages: list[dict]) -> dict:
for message in messages:
if message["role"] == "system":
message["content"] = content
break
return messages
def add_or_update_system_message( def add_or_update_system_message(
content: str, messages: list[dict], append: bool = False content: str, messages: list[dict], append: bool = False
): ):
@@ -391,17 +399,10 @@ def parse_ollama_modelfile(model_text):
"top_k": int, "top_k": int,
"top_p": float, "top_p": float,
"num_keep": int, "num_keep": int,
"typical_p": float,
"presence_penalty": float, "presence_penalty": float,
"frequency_penalty": float, "frequency_penalty": float,
"penalize_newline": bool,
"numa": bool,
"num_batch": int, "num_batch": int,
"num_gpu": int, "num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"f16_kv": bool,
"vocab_only": bool,
"use_mmap": bool, "use_mmap": bool,
"use_mlock": bool, "use_mlock": bool,
"num_thread": int, "num_thread": int,
@@ -530,3 +531,11 @@ def throttle(interval: float = 10.0):
return wrapper return wrapper
return decorator return decorator
def extract_urls(text: str) -> list[str]:
# Regex pattern to match URLs
url_pattern = re.compile(
r"(https?://[^\s]+)", re.IGNORECASE
) # Matches http and https URLs
return url_pattern.findall(text)
+1
View File
@@ -263,6 +263,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
"icon": function.meta.manifest.get("icon_url", None) "icon": function.meta.manifest.get("icon_url", None)
or getattr(module, "icon_url", None) or getattr(module, "icon_url", None)
or getattr(module, "icon", None), or getattr(module, "icon", None),
"has_user_valves": hasattr(module, "UserValves"),
} }
] ]
+73 -25
View File
@@ -62,6 +62,7 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_COOKIE_SECURE,
ENABLE_OAUTH_ID_TOKEN_COOKIE, ENABLE_OAUTH_ID_TOKEN_COOKIE,
ENABLE_OAUTH_EMAIL_FALLBACK,
OAUTH_CLIENT_INFO_ENCRYPTION_KEY, OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
) )
from open_webui.utils.misc import parse_duration from open_webui.utils.misc import parse_duration
@@ -82,6 +83,8 @@ class OAuthClientInformationFull(OAuthClientMetadata):
client_id_issued_at: int | None = None client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None client_secret_expires_at: int | None = None
server_metadata: Optional[OAuthMetadata] = None # Fetched from the OAuth server
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
@@ -198,13 +201,25 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
def get_discovery_urls(server_url) -> list[str]: def get_discovery_urls(server_url) -> list[str]:
urls = []
parsed, base_url = get_parsed_and_base_url(server_url) parsed, base_url = get_parsed_and_base_url(server_url)
urls.append( urls = [
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server") urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server"),
) urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"),
urls.append(urllib.parse.urljoin(base_url, "/.well-known/openid-configuration")) ]
if parsed.path and parsed.path != "/":
urls.append(
urllib.parse.urljoin(
base_url,
f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}",
)
)
urls.append(
urllib.parse.urljoin(
base_url, f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
)
)
return urls return urls
@@ -284,6 +299,7 @@ async def get_oauth_client_info_with_dynamic_client_registration(
{ {
**registration_response_json, **registration_response_json,
**{"issuer": oauth_server_metadata_url}, **{"issuer": oauth_server_metadata_url},
**{"server_metadata": oauth_server_metadata},
} }
) )
log.info( log.info(
@@ -319,20 +335,34 @@ class OAuthClientManager:
self.clients = {} self.clients = {}
def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull):
self.clients[client_id] = { kwargs = {
"client": self.oauth.register( "name": client_id,
name=client_id, "client_id": oauth_client_info.client_id,
client_id=oauth_client_info.client_id, "client_secret": oauth_client_info.client_secret,
client_secret=oauth_client_info.client_secret, "client_kwargs": (
client_kwargs=( {"scope": oauth_client_info.scope} if oauth_client_info.scope else {}
{"scope": oauth_client_info.scope}
if oauth_client_info.scope
else {}
),
server_metadata_url=(
oauth_client_info.issuer if oauth_client_info.issuer else None
),
), ),
"server_metadata_url": (
oauth_client_info.issuer if oauth_client_info.issuer else None
),
}
if (
oauth_client_info.server_metadata
and oauth_client_info.server_metadata.code_challenge_methods_supported
):
if (
isinstance(
oauth_client_info.server_metadata.code_challenge_methods_supported,
list,
)
and "S256"
in oauth_client_info.server_metadata.code_challenge_methods_supported
):
kwargs["code_challenge_method"] = "S256"
self.clients[client_id] = {
"client": self.oauth.register(**kwargs),
"client_info": oauth_client_info, "client_info": oauth_client_info,
} }
return self.clients[client_id] return self.clients[client_id]
@@ -355,8 +385,8 @@ class OAuthClientManager:
if client_id in self.clients: if client_id in self.clients:
client = self.clients[client_id] client = self.clients[client_id]
return ( return (
client.server_metadata_url client._server_metadata_url
if hasattr(client, "server_metadata_url") if hasattr(client, "_server_metadata_url")
else None else None
) )
return None return None
@@ -548,7 +578,17 @@ class OAuthClientManager:
error_message = None error_message = None
try: try:
token = await client.authorize_access_token(request) client_info = self.get_client_info(client_id)
token_params = {}
if (
client_info
and hasattr(client_info, "client_id")
and hasattr(client_info, "client_secret")
):
token_params["client_id"] = client_info.client_id
token_params["client_secret"] = client_info.client_secret
token = await client.authorize_access_token(request, **token_params)
if token: if token:
try: try:
# Add timestamp for tracking # Add timestamp for tracking
@@ -603,8 +643,14 @@ class OAuthManager:
self.app = app self.app = app
self._clients = {} self._clients = {}
for _, provider_config in OAUTH_PROVIDERS.items():
provider_config["register"](self.oauth) for name, provider_config in OAUTH_PROVIDERS.items():
if "register" not in provider_config:
log.error(f"OAuth provider {name} missing register function")
continue
client = provider_config["register"](self.oauth)
self._clients[name] = client
def get_client(self, provider_name): def get_client(self, provider_name):
if provider_name not in self._clients: if provider_name not in self._clients:
@@ -615,8 +661,8 @@ class OAuthManager:
if provider_name in self._clients: if provider_name in self._clients:
client = self._clients[provider_name] client = self._clients[provider_name]
return ( return (
client.server_metadata_url client._server_metadata_url
if hasattr(client, "server_metadata_url") if hasattr(client, "_server_metadata_url")
else None else None
) )
return None return None
@@ -1135,6 +1181,8 @@ class OAuthManager:
except Exception as e: except Exception as e:
log.warning(f"Error fetching GitHub email: {e}") log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
elif ENABLE_OAUTH_EMAIL_FALLBACK:
email = f"{provider_sub}.local"
else: else:
log.warning(f"OAuth callback failed, email is missing: {user_data}") log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+15 -10
View File
@@ -2,6 +2,7 @@ from open_webui.utils.task import prompt_template, prompt_variables_template
from open_webui.utils.misc import ( from open_webui.utils.misc import (
deep_update, deep_update,
add_or_update_system_message, add_or_update_system_message,
replace_system_message_content,
) )
from typing import Callable, Optional from typing import Callable, Optional
@@ -10,7 +11,11 @@ import json
# inplace function: form_data is modified # inplace function: form_data is modified
def apply_system_prompt_to_body( def apply_system_prompt_to_body(
system: Optional[str], form_data: dict, metadata: Optional[dict] = None, user=None system: Optional[str],
form_data: dict,
metadata: Optional[dict] = None,
user=None,
replace: bool = False,
) -> dict: ) -> dict:
if not system: if not system:
return form_data return form_data
@@ -24,9 +29,15 @@ def apply_system_prompt_to_body(
# Legacy (API Usage) # Legacy (API Usage)
system = prompt_template(system, user) system = prompt_template(system, user)
form_data["messages"] = add_or_update_system_message( if replace:
system, form_data.get("messages", []) form_data["messages"] = replace_system_message_content(
) system, form_data.get("messages", [])
)
else:
form_data["messages"] = add_or_update_system_message(
system, form_data.get("messages", [])
)
return form_data return form_data
@@ -153,17 +164,11 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
"repeat_last_n": int, "repeat_last_n": int,
"top_k": int, "top_k": int,
"min_p": float, "min_p": float,
"typical_p": float,
"repeat_penalty": float, "repeat_penalty": float,
"presence_penalty": float, "presence_penalty": float,
"frequency_penalty": float, "frequency_penalty": float,
"penalize_newline": bool,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
"numa": bool,
"num_gpu": int, "num_gpu": int,
"main_gpu": int,
"low_vram": bool,
"vocab_only": bool,
"use_mmap": bool, "use_mmap": bool,
"use_mlock": bool, "use_mlock": bool,
"num_thread": int, "num_thread": int,
+42
View File
@@ -166,6 +166,48 @@ def load_function_module_by_id(function_id: str, content: str | None = None):
os.unlink(temp_file.name) os.unlink(temp_file.name)
def get_tool_module_from_cache(request, tool_id, load_from_db=True):
if load_from_db:
# Always load from the database by default
tool = Tools.get_tool_by_id(tool_id)
if not tool:
raise Exception(f"Tool not found: {tool_id}")
content = tool.content
new_content = replace_imports(content)
if new_content != content:
content = new_content
# Update the tool content in the database
Tools.update_tool_by_id(tool_id, {"content": content})
if (
hasattr(request.app.state, "TOOL_CONTENTS")
and tool_id in request.app.state.TOOL_CONTENTS
) and (
hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS
):
if request.app.state.TOOL_CONTENTS[tool_id] == content:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id, content)
else:
if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS:
return request.app.state.TOOLS[tool_id], None
tool_module, frontmatter = load_tool_module_by_id(tool_id)
if not hasattr(request.app.state, "TOOLS"):
request.app.state.TOOLS = {}
if not hasattr(request.app.state, "TOOL_CONTENTS"):
request.app.state.TOOL_CONTENTS = {}
request.app.state.TOOLS[tool_id] = tool_module
request.app.state.TOOL_CONTENTS[tool_id] = content
return tool_module, frontmatter
def get_function_module_from_cache(request, function_id, load_from_db=True): def get_function_module_from_cache(request, function_id, load_from_db=True):
if load_from_db: if load_from_db:
# Always load from the database by default # Always load from the database by default
+37 -22
View File
@@ -588,28 +588,20 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
error = str(err) error = str(err)
raise Exception(error) raise Exception(error)
data = { log.debug(f"Fetched data: {res}")
"openapi": res, return res
"info": res.get("info", {}),
"specs": convert_openapi_to_tool_payload(res),
}
log.info(f"Fetched data: {data}")
return data
async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]: async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index # Prepare list of enabled servers along with their original index
tasks = []
server_entries = [] server_entries = []
for idx, server in enumerate(servers): for idx, server in enumerate(servers):
if ( if (
server.get("config", {}).get("enable") server.get("config", {}).get("enable")
and server.get("type", "openapi") == "openapi" and server.get("type", "openapi") == "openapi"
): ):
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
full_url = get_tool_server_url(server.get("url"), openapi_path)
info = server.get("info", {}) info = server.get("info", {})
auth_type = server.get("auth_type", "bearer") auth_type = server.get("auth_type", "bearer")
@@ -625,12 +617,34 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
if not id: if not id:
id = str(idx) id = str(idx)
server_entries.append((id, idx, server, full_url, info, token)) server_url = server.get("url")
spec_type = server.get("spec_type", "url")
# Create async tasks to fetch data # Create async tasks to fetch data
tasks = [ task = None
get_tool_server_data(token, url) for (_, _, _, url, _, token) in server_entries if spec_type == "url":
] # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json")
spec_url = get_tool_server_url(server_url, openapi_path)
# Fetch from URL
task = get_tool_server_data(token, spec_url)
elif spec_type == "json" and server.get("spec", ""):
# Use provided JSON spec
spec_json = None
try:
spec_json = json.loads(server.get("spec", ""))
except Exception as e:
log.error(f"Error parsing JSON spec for tool server {id}: {e}")
if spec_json:
task = asyncio.sleep(
0,
result=spec_json,
)
if task:
tasks.append(task)
server_entries.append((id, idx, server, server_url, info, token))
# Execute tasks concurrently # Execute tasks concurrently
responses = await asyncio.gather(*tasks, return_exceptions=True) responses = await asyncio.gather(*tasks, return_exceptions=True)
@@ -642,8 +656,13 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
log.error(f"Failed to connect to {url} OpenAPI tool server") log.error(f"Failed to connect to {url} OpenAPI tool server")
continue continue
openapi_data = response.get("openapi", {}) response = {
"openapi": response,
"info": response.get("info", {}),
"specs": convert_openapi_to_tool_payload(response),
}
openapi_data = response.get("openapi", {})
if info and isinstance(openapi_data, dict): if info and isinstance(openapi_data, dict):
openapi_data["info"] = openapi_data.get("info", {}) openapi_data["info"] = openapi_data.get("info", {})
@@ -729,10 +748,6 @@ async def execute_tool_server(
if operation.get("requestBody", {}).get("content"): if operation.get("requestBody", {}).get("content"):
if params: if params:
body_params = params body_params = params
else:
raise Exception(
f"Request body expected for operation '{name}' but none found."
)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
+42 -45
View File
@@ -1,14 +1,13 @@
fastapi==0.115.7 fastapi==0.118.0
uvicorn[standard]==0.35.0 uvicorn[standard]==0.37.0
pydantic==2.11.7 pydantic==2.11.9
python-multipart==0.0.20 python-multipart==0.0.20
itsdangerous==2.2.0 itsdangerous==2.2.0
python-socketio==5.13.0 python-socketio==5.13.0
python-jose==3.4.0 python-jose==3.4.0
passlib[bcrypt]==1.7.4
cryptography cryptography
bcrypt==4.3.0 bcrypt==5.0.0
argon2-cffi==25.1.0 argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1 PyJWT[crypto]==2.10.1
authlib==1.6.3 authlib==1.6.3
@@ -30,14 +29,6 @@ peewee-migrate==1.12.2
pycrdt==0.12.25 pycrdt==0.12.25
redis redis
pymongo
psycopg2-binary==2.9.10
pgvector==0.4.1
PyMySQL==1.1.1
boto3==1.40.5
APScheduler==3.10.4 APScheduler==3.10.4
RestrictedPython==8.0 RestrictedPython==8.0
@@ -57,25 +48,15 @@ langchain==0.3.27
langchain-community==0.3.29 langchain-community==0.3.29
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==1.0.20 chromadb==1.1.0
opensearch-py==2.8.0 opensearch-py==2.8.0
pymilvus==2.5.0
qdrant-client==1.14.3
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==9.1.0
pinecone==6.0.2
oracledb==3.2.0
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
transformers transformers
sentence-transformers==5.1.1 sentence-transformers==5.1.1
accelerate accelerate
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1 einops==0.8.1
colbert-ai==0.2.21
ftfy==6.2.3 ftfy==6.2.3
pypdf==6.0.0 pypdf==6.0.0
fpdf2==2.8.2 fpdf2==2.8.2
@@ -84,7 +65,7 @@ docx2txt==0.8
python-pptx==1.0.2 python-pptx==1.0.2
unstructured==0.16.17 unstructured==0.16.17
nltk==3.9.1 nltk==3.9.1
Markdown==3.8.2 Markdown==3.9
pypandoc==1.15 pypandoc==1.15
pandas==2.2.3 pandas==2.2.3
openpyxl==3.1.5 openpyxl==3.1.5
@@ -105,7 +86,7 @@ onnxruntime==1.20.1
faster-whisper==1.1.1 faster-whisper==1.1.1
black==25.1.0 black==25.9.0
youtube-transcript-api==1.2.2 youtube-transcript-api==1.2.2
pytube==15.0.0 pytube==15.0.0
@@ -117,11 +98,6 @@ google-api-python-client
google-auth-httplib2 google-auth-httplib2
google-auth-oauthlib google-auth-oauthlib
## Tests
docker~=7.1.0
pytest~=8.4.1
pytest-docker~=3.1.1
googleapis-common-protos==1.70.0 googleapis-common-protos==1.70.0
google-cloud-storage==2.19.0 google-cloud-storage==2.19.0
@@ -129,24 +105,45 @@ azure-identity==1.25.0
azure-storage-blob==12.24.1 azure-storage-blob==12.24.1
pymongo
psycopg2-binary==2.9.10
pgvector==0.4.1
PyMySQL==1.1.1
boto3==1.40.5
pymilvus==2.6.2
qdrant-client==1.14.3
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==9.1.0
pinecone==6.0.2
oracledb==3.2.0
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
colbert-ai==0.2.21
## Tests
docker~=7.1.0
pytest~=8.4.1
pytest-docker~=3.1.1
## LDAP ## LDAP
ldap3==2.9.1 ldap3==2.9.1
## Firecrawl ## Firecrawl
firecrawl-py==1.12.0 firecrawl-py==1.12.0
# Sougou API SDK(Tencentcloud SDK)
tencentcloud-sdk-python==3.0.1336
## Trace ## Trace
opentelemetry-api==1.36.0 opentelemetry-api==1.37.0
opentelemetry-sdk==1.36.0 opentelemetry-sdk==1.37.0
opentelemetry-exporter-otlp==1.36.0 opentelemetry-exporter-otlp==1.37.0
opentelemetry-instrumentation==0.57b0 opentelemetry-instrumentation==0.58b0
opentelemetry-instrumentation-fastapi==0.57b0 opentelemetry-instrumentation-fastapi==0.58b0
opentelemetry-instrumentation-sqlalchemy==0.57b0 opentelemetry-instrumentation-sqlalchemy==0.58b0
opentelemetry-instrumentation-redis==0.57b0 opentelemetry-instrumentation-redis==0.58b0
opentelemetry-instrumentation-requests==0.57b0 opentelemetry-instrumentation-requests==0.58b0
opentelemetry-instrumentation-logging==0.57b0 opentelemetry-instrumentation-logging==0.58b0
opentelemetry-instrumentation-httpx==0.57b0 opentelemetry-instrumentation-httpx==0.58b0
opentelemetry-instrumentation-aiohttp-client==0.57b0 opentelemetry-instrumentation-aiohttp-client==0.58b0
+14 -1
View File
@@ -70,5 +70,18 @@ if [ -n "$SPACE_ID" ]; then
fi fi
PYTHON_CMD=$(command -v python3 || command -v python) PYTHON_CMD=$(command -v python3 || command -v python)
UVICORN_WORKERS="${UVICORN_WORKERS:-1}"
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}" # If script is called with arguments, use them; otherwise use default workers
if [ "$#" -gt 0 ]; then
ARGS=("$@")
else
ARGS=(--workers "$UVICORN_WORKERS")
fi
# Run uvicorn
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \
--host "$HOST" \
--port "$PORT" \
--forwarded-allow-ips '*' \
"${ARGS[@]}"
+679 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.31", "version": "0.6.33",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.31", "version": "0.6.33",
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.5.0", "@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
@@ -93,6 +93,8 @@
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
"undici": "^7.3.0", "undici": "^7.3.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vega": "^6.2.0",
"vega-lite": "^6.4.1",
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"y-prosemirror": "^1.3.7", "y-prosemirror": "^1.3.7",
"yaml": "^2.7.1", "yaml": "^2.7.1",
@@ -5592,6 +5594,99 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
"integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clone": { "node_modules/clone": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
@@ -6346,6 +6441,36 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-geo-projection": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
"integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
"license": "ISC",
"dependencies": {
"commander": "7",
"d3-array": "1 - 3",
"d3-geo": "1.12.0 - 3"
},
"bin": {
"geo2svg": "bin/geo2svg.js",
"geograticule": "bin/geograticule.js",
"geoproject": "bin/geoproject.js",
"geoquantize": "bin/geoquantize.js",
"geostitch": "bin/geostitch.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo-projection/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-hierarchy": { "node_modules/d3-hierarchy": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
@@ -7038,6 +7163,15 @@
"@esbuild/win32-x64": "0.25.1" "@esbuild/win32-x64": "0.25.1"
} }
}, },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -7750,6 +7884,27 @@
"resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz", "resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz",
"integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==" "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A=="
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-func-name": { "node_modules/get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@@ -8763,6 +8918,12 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true "dev": true
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json-stringify-safe": { "node_modules/json-stringify-safe": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -12770,6 +12931,26 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"license": "ISC",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/topojson-client/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -13047,6 +13228,417 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vega": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/vega/-/vega-6.2.0.tgz",
"integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-crossfilter": "~5.1.0",
"vega-dataflow": "~6.1.0",
"vega-encode": "~5.1.0",
"vega-event-selector": "~4.0.0",
"vega-expression": "~6.1.0",
"vega-force": "~5.1.0",
"vega-format": "~2.1.0",
"vega-functions": "~6.1.0",
"vega-geo": "~5.1.0",
"vega-hierarchy": "~5.1.0",
"vega-label": "~2.1.0",
"vega-loader": "~5.1.0",
"vega-parser": "~7.1.0",
"vega-projection": "~2.1.0",
"vega-regression": "~2.1.0",
"vega-runtime": "~7.1.0",
"vega-scale": "~8.1.0",
"vega-scenegraph": "~5.1.0",
"vega-statistics": "~2.0.0",
"vega-time": "~3.1.0",
"vega-transforms": "~5.1.0",
"vega-typings": "~2.1.0",
"vega-util": "~2.1.0",
"vega-view": "~6.1.0",
"vega-view-transforms": "~5.1.0",
"vega-voronoi": "~5.1.0",
"vega-wordcloud": "~5.1.0"
},
"funding": {
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
}
},
"node_modules/vega-canvas": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-2.0.0.tgz",
"integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==",
"license": "BSD-3-Clause"
},
"node_modules/vega-crossfilter": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz",
"integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-dataflow": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz",
"integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-format": "^2.1.0",
"vega-loader": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-encode": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-encode/-/vega-encode-5.1.0.tgz",
"integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-interpolate": "^3.0.1",
"vega-dataflow": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-event-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz",
"integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==",
"license": "BSD-3-Clause"
},
"node_modules/vega-expression": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-6.1.0.tgz",
"integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/estree": "^1.0.8",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-expression/node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/vega-force": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-force/-/vega-force-5.1.0.tgz",
"integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-force": "^3.0.0",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vega-format/-/vega-format-2.1.0.tgz",
"integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-format": "^3.1.0",
"d3-time-format": "^4.1.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-functions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.0.tgz",
"integrity": "sha512-yooEbWt0FWMBNoohwLsl25lEh08WsWabTXbbS+q0IXZzWSpX4Cyi45+q7IFyy/2L4oaIfGIIV14dgn3srQQcGA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-geo": "^3.1.1",
"vega-dataflow": "^6.1.0",
"vega-expression": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-scenegraph": "^5.1.0",
"vega-selections": "^6.1.0",
"vega-statistics": "^2.0.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-geo": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-geo/-/vega-geo-5.1.0.tgz",
"integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-geo": "^3.1.1",
"vega-canvas": "^2.0.0",
"vega-dataflow": "^6.1.0",
"vega-projection": "^2.1.0",
"vega-statistics": "^2.0.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-hierarchy": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz",
"integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-hierarchy": "^3.1.2",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-label": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vega-label/-/vega-label-2.1.0.tgz",
"integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-canvas": "^2.0.0",
"vega-dataflow": "^6.1.0",
"vega-scenegraph": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-lite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.1.tgz",
"integrity": "sha512-KO3ybHNouRK4A0al/+2fN9UqgTEfxrd/ntGLY933Hg5UOYotDVQdshR3zn7OfXwQ7uj0W96Vfa5R+QxO8am3IQ==",
"license": "BSD-3-Clause",
"dependencies": {
"json-stringify-pretty-compact": "~4.0.0",
"tslib": "~2.8.1",
"vega-event-selector": "~4.0.0",
"vega-expression": "~6.1.0",
"vega-util": "~2.1.0",
"yargs": "~18.0.0"
},
"bin": {
"vl2pdf": "bin/vl2pdf",
"vl2png": "bin/vl2png",
"vl2svg": "bin/vl2svg",
"vl2vg": "bin/vl2vg"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://app.hubspot.com/payments/GyPC972GD9Rt"
},
"peerDependencies": {
"vega": "^6.0.0"
}
},
"node_modules/vega-loader": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-loader/-/vega-loader-5.1.0.tgz",
"integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dsv": "^3.0.1",
"topojson-client": "^3.1.0",
"vega-format": "^2.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-7.1.0.tgz",
"integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-dataflow": "^6.1.0",
"vega-event-selector": "^4.0.0",
"vega-functions": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-projection": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vega-projection/-/vega-projection-2.1.0.tgz",
"integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-geo": "^3.1.1",
"d3-geo-projection": "^4.0.0",
"vega-scale": "^8.1.0"
}
},
"node_modules/vega-regression": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vega-regression/-/vega-regression-2.1.0.tgz",
"integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"vega-dataflow": "^6.1.0",
"vega-statistics": "^2.0.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-runtime": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/vega-runtime/-/vega-runtime-7.1.0.tgz",
"integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-scale": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-8.1.0.tgz",
"integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-scenegraph": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz",
"integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "^3.1.0",
"d3-shape": "^3.2.0",
"vega-canvas": "^2.0.0",
"vega-loader": "^5.1.0",
"vega-scale": "^8.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-selections": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.0.tgz",
"integrity": "sha512-WaHM7D7ghHceEfMsgFeaZnDToWL0mgCFtStVOobNh/OJLh0CL7yNKeKQBqRXJv2Lx74dPNf6nj08+52ytWfW7g==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "3.2.4",
"vega-expression": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-statistics": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-2.0.0.tgz",
"integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4"
}
},
"node_modules/vega-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vega-time/-/vega-time-3.1.0.tgz",
"integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-transforms": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-5.1.0.tgz",
"integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"vega-dataflow": "^6.1.0",
"vega-statistics": "^2.0.0",
"vega-time": "^3.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-typings": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-2.1.0.tgz",
"integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/geojson": "7946.0.16",
"vega-event-selector": "^4.0.0",
"vega-expression": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-util": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vega-util/-/vega-util-2.1.0.tgz",
"integrity": "sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==",
"license": "BSD-3-Clause"
},
"node_modules/vega-view": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/vega-view/-/vega-view-6.1.0.tgz",
"integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "^3.2.4",
"d3-timer": "^3.0.1",
"vega-dataflow": "^6.1.0",
"vega-format": "^2.1.0",
"vega-functions": "^6.1.0",
"vega-runtime": "^7.1.0",
"vega-scenegraph": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-view-transforms": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz",
"integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-dataflow": "^6.1.0",
"vega-scenegraph": "^5.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-voronoi": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz",
"integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-delaunay": "^6.0.4",
"vega-dataflow": "^6.1.0",
"vega-util": "^2.1.0"
}
},
"node_modules/vega-wordcloud": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz",
"integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==",
"license": "BSD-3-Clause",
"dependencies": {
"vega-canvas": "^2.0.0",
"vega-dataflow": "^6.1.0",
"vega-scale": "^8.1.0",
"vega-statistics": "^2.0.0",
"vega-util": "^2.1.0"
}
},
"node_modules/verror": { "node_modules/verror": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
@@ -14209,6 +14801,15 @@
"yjs": "^13.0.0" "yjs": "^13.0.0"
} }
}, },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -14230,6 +14831,82 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",
"integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.31", "version": "0.6.33",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@@ -137,6 +137,8 @@
"turndown-plugin-gfm": "^1.0.2", "turndown-plugin-gfm": "^1.0.2",
"undici": "^7.3.0", "undici": "^7.3.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vega": "^6.2.0",
"vega-lite": "^6.4.1",
"vite-plugin-static-copy": "^2.2.0", "vite-plugin-static-copy": "^2.2.0",
"y-prosemirror": "^1.3.7", "y-prosemirror": "^1.3.7",
"yaml": "^2.7.1", "yaml": "^2.7.1",
+11 -17
View File
@@ -6,17 +6,16 @@ authors = [
] ]
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastapi==0.115.7", "fastapi==0.118.0",
"uvicorn[standard]==0.35.0", "uvicorn[standard]==0.37.0",
"pydantic==2.11.7", "pydantic==2.11.9",
"python-multipart==0.0.20", "python-multipart==0.0.20",
"itsdangerous==2.2.0", "itsdangerous==2.2.0",
"python-socketio==5.13.0", "python-socketio==5.13.0",
"python-jose==3.4.0", "python-jose==3.4.0",
"passlib[bcrypt]==1.7.4",
"cryptography", "cryptography",
"bcrypt==4.3.0", "bcrypt==5.0.0",
"argon2-cffi==25.1.0", "argon2-cffi==25.1.0",
"PyJWT[crypto]==2.10.1", "PyJWT[crypto]==2.10.1",
"authlib==1.6.3", "authlib==1.6.3",
@@ -76,7 +75,7 @@ dependencies = [
"python-pptx==1.0.2", "python-pptx==1.0.2",
"unstructured==0.16.17", "unstructured==0.16.17",
"nltk==3.9.1", "nltk==3.9.1",
"Markdown==3.8.2", "Markdown==3.9",
"pypandoc==1.15", "pypandoc==1.15",
"pandas==2.2.3", "pandas==2.2.3",
"openpyxl==3.1.5", "openpyxl==3.1.5",
@@ -96,8 +95,8 @@ dependencies = [
"onnxruntime==1.20.1", "onnxruntime==1.20.1",
"faster-whisper==1.1.1", "faster-whisper==1.1.1",
"black==25.1.0", "black==25.9.0",
"youtube-transcript-api==1.1.0", "youtube-transcript-api==1.2.2",
"pytube==15.0.0", "pytube==15.0.0",
"pydub", "pydub",
@@ -107,8 +106,6 @@ dependencies = [
"google-auth-httplib2", "google-auth-httplib2",
"google-auth-oauthlib", "google-auth-oauthlib",
"googleapis-common-protos==1.70.0", "googleapis-common-protos==1.70.0",
"google-cloud-storage==2.19.0", "google-cloud-storage==2.19.0",
@@ -116,12 +113,6 @@ dependencies = [
"azure-storage-blob==12.24.1", "azure-storage-blob==12.24.1",
"ldap3==2.9.1", "ldap3==2.9.1",
"firecrawl-py==1.12.0",
"tencentcloud-sdk-python==3.0.1336",
"oracledb>=3.2.0",
] ]
readme = "README.md" readme = "README.md"
requires-python = ">= 3.11, < 3.13.0a1" requires-python = ">= 3.11, < 3.13.0a1"
@@ -155,11 +146,14 @@ all = [
"elasticsearch==9.1.0", "elasticsearch==9.1.0",
"qdrant-client==1.14.3", "qdrant-client==1.14.3",
"pymilvus==2.5.0", "pymilvus==2.6.2",
"pinecone==6.0.2", "pinecone==6.0.2",
"oracledb==3.2.0", "oracledb==3.2.0",
"colbert-ai==0.2.21", "colbert-ai==0.2.21",
"firecrawl-py==1.12.0",
"tencentcloud-sdk-python==3.0.1336",
] ]
[project.scripts] [project.scripts]
+1
View File
@@ -248,6 +248,7 @@ export const getChannelThreadMessages = async (
}; };
type MessageForm = { type MessageForm = {
reply_to_id?: string;
parent_id?: string; parent_id?: string;
content: string; content: string;
data?: object; data?: object;
+71
View File
@@ -33,6 +33,38 @@ export const createNewChat = async (token: string, chat: object, folderId: strin
return res; return res;
}; };
export const unarchiveAllChats = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/unarchive/all`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const importChat = async ( export const importChat = async (
token: string, token: string,
chat: object, chat: object,
@@ -327,6 +359,45 @@ export const getChatsByFolderId = async (token: string, folderId: string) => {
return res; return res;
}; };
export const getChatListByFolderId = async (token: string, folderId: string, page: number = 1) => {
let error = null;
const searchParams = new URLSearchParams();
if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(
`${WEBUI_API_BASE_URL}/chats/folder/${folderId}/list?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAllArchivedChats = async (token: string) => { export const getAllArchivedChats = async (token: string) => {
let error = null; let error = null;
+1 -1
View File
@@ -23,7 +23,7 @@ export const uploadFile = async (token: string, file: File, metadata?: object |
return res.json(); return res.json();
}) })
.catch((err) => { .catch((err) => {
error = err.detail; error = err.detail || err.message;
console.error(err); console.error(err);
return null; return null;
}); });
+31
View File
@@ -62,6 +62,37 @@ export const getFunctions = async (token: string = '') => {
return res; return res;
}; };
export const getFunctionList = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/functions/list`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const loadFunctionByUrl = async (token: string = '', url: string) => { export const loadFunctionByUrl = async (token: string = '', url: string) => {
let error = null; let error = null;
+30 -19
View File
@@ -337,14 +337,8 @@ export const getToolServerData = async (token: string, url: string) => {
throw error; throw error;
} }
const data = { console.log(res);
openapi: res, return res;
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
console.log(data);
return data;
}; };
export const getToolServersData = async (servers: object[]) => { export const getToolServersData = async (servers: object[]) => {
@@ -356,6 +350,7 @@ export const getToolServersData = async (servers: object[]) => {
let error = null; let error = null;
let toolServerToken = null; let toolServerToken = null;
const auth_type = server?.auth_type ?? 'bearer'; const auth_type = server?.auth_type ?? 'bearer';
if (auth_type === 'bearer') { if (auth_type === 'bearer') {
toolServerToken = server?.key; toolServerToken = server?.key;
@@ -365,18 +360,34 @@ export const getToolServersData = async (servers: object[]) => {
toolServerToken = localStorage.token; toolServerToken = localStorage.token;
} }
const data = await getToolServerData( let res = null;
toolServerToken, const specType = server?.spec_type ?? 'url';
(server?.path ?? '').includes('://')
? server?.path if (specType === 'url') {
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}` res = await getToolServerData(
).catch((err) => { toolServerToken,
error = err; (server?.path ?? '').includes('://')
return null; ? server?.path
}); : `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
).catch((err) => {
error = err;
return null;
});
} else if ((specType === 'json' && server?.spec) ?? null) {
try {
res = JSON.parse(server?.spec);
} catch (e) {
error = 'Failed to parse JSON spec';
}
}
if (res) {
const { openapi, info, specs } = {
openapi: res,
info: res.info,
specs: convertOpenApiToToolPayload(res)
};
if (data) {
const { openapi, info, specs } = data;
return { return {
url: server?.url, url: server?.url,
openapi: openapi, openapi: openapi,
+28
View File
@@ -31,6 +31,34 @@ export const getModels = async (token: string = '') => {
return res; return res;
}; };
export const importModels = async (token: string, models: object[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/models/import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ models: models })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getBaseModels = async (token: string = '') => { export const getBaseModels = async (token: string = '') => {
let error = null; let error = null;
+64 -1
View File
@@ -17,6 +17,7 @@
import Tags from './common/Tags.svelte'; import Tags from './common/Tags.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import Textarea from './common/Textarea.svelte';
export let onSubmit: Function = () => {}; export let onSubmit: Function = () => {};
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
@@ -42,6 +43,8 @@
let enable = true; let enable = true;
let apiVersion = ''; let apiVersion = '';
let headers = '';
let tags = []; let tags = [];
let modelId = ''; let modelId = '';
@@ -69,6 +72,19 @@
// remove trailing slash from url // remove trailing slash from url
url = url.replace(/\/$/, ''); url = url.replace(/\/$/, '');
if (headers) {
try {
const _headers = JSON.parse(headers);
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
throw new Error('Headers must be a valid JSON object');
}
headers = JSON.stringify(_headers, null, 2);
} catch (error) {
toast.error($i18n.t('Headers must be a valid JSON object'));
return;
}
}
const res = await verifyOpenAIConnection( const res = await verifyOpenAIConnection(
localStorage.token, localStorage.token,
{ {
@@ -77,7 +93,8 @@
config: { config: {
auth_type, auth_type,
azure: azure, azure: azure,
api_version: apiVersion api_version: apiVersion,
headers: JSON.parse(headers)
} }
}, },
direct direct
@@ -136,6 +153,19 @@
} }
} }
if (headers) {
try {
const _headers = JSON.parse(headers);
if (typeof _headers !== 'object' || Array.isArray(_headers)) {
throw new Error('Headers must be a valid JSON object');
}
headers = JSON.stringify(_headers, null, 2);
} catch (error) {
toast.error($i18n.t('Headers must be a valid JSON object'));
return;
}
}
// remove trailing slash from url // remove trailing slash from url
url = url.replace(/\/$/, ''); url = url.replace(/\/$/, '');
@@ -149,6 +179,7 @@
model_ids: modelIds, model_ids: modelIds,
connection_type: connectionType, connection_type: connectionType,
auth_type, auth_type,
headers: headers ? JSON.parse(headers) : undefined,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}) ...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
} }
}; };
@@ -172,6 +203,9 @@
key = connection.key; key = connection.key;
auth_type = connection.config.auth_type ?? 'bearer'; auth_type = connection.config.auth_type ?? 'bearer';
headers = connection.config?.headers
? JSON.stringify(connection.config.headers, null, 2)
: '';
enable = connection.config?.enable ?? true; enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? []; tags = connection.config?.tags ?? [];
@@ -376,6 +410,35 @@
</div> </div>
</div> </div>
{#if !ollama && !direct}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="headers-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Headers')}</label
>
<div class="flex-1">
<Tooltip
content={$i18n.t(
'Enter additional headers in JSON format (e.g. {{\'{{"X-Custom-Header": "value"}}\'}})'
)}
>
<Textarea
className="w-full text-sm outline-hidden"
bind:value={headers}
placeholder={$i18n.t('Enter additional headers in JSON format')}
required={false}
minSize={30}
/>
</Tooltip>
</div>
</div>
</div>
{/if}
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<label <label
+266 -72
View File
@@ -1,4 +1,9 @@
<script lang="ts"> <script lang="ts">
import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -27,11 +32,16 @@
export let direct = false; export let direct = false;
export let connection = null; export let connection = null;
let url = ''; let inputElement = null;
let path = 'openapi.json';
let type = 'openapi'; // 'openapi', 'mcp' let type = 'openapi'; // 'openapi', 'mcp'
let url = '';
let spec_type = 'url'; // 'url', 'json'
let spec = ''; // used when spec_type is 'json'
let path = 'openapi.json';
let auth_type = 'bearer'; let auth_type = 'bearer';
let key = ''; let key = '';
@@ -88,9 +98,16 @@
return; return;
} }
if (path === '') { if (['openapi', ''].includes(type)) {
toast.error($i18n.t('Please enter a valid path')); if (spec_type === 'json' && spec === '') {
return; toast.error($i18n.t('Please enter a valid JSON spec'));
return;
}
if (spec_type === 'url' && path === '') {
toast.error($i18n.t('Please enter a valid path'));
return;
}
} }
if (direct) { if (direct) {
@@ -132,6 +149,84 @@
} }
}; };
const importHandler = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const json = event.target.result;
console.log('importHandler', json);
try {
let data = JSON.parse(json);
// validate data
if (Array.isArray(data)) {
if (data.length === 0) {
toast.error($i18n.t('Please select a valid JSON file'));
return;
}
data = data[0];
}
if (data.type) type = data.type;
if (data.url) url = data.url;
if (data.spec_type) spec_type = data.spec_type;
if (data.spec) spec = data.spec;
if (data.path) path = data.path;
if (data.auth_type) auth_type = data.auth_type;
if (data.key) key = data.key;
if (data.info) {
id = data.info.id ?? '';
name = data.info.name ?? '';
description = data.info.description ?? '';
}
if (data.config) {
enable = data.config.enable ?? true;
accessControl = data.config.access_control ?? {};
}
toast.success($i18n.t('Import successful'));
} catch (error) {
toast.error($i18n.t('Please select a valid JSON file'));
}
};
reader.readAsText(file);
};
const exportHandler = async () => {
// export current connection as json file
const json = JSON.stringify([
{
type,
url,
spec_type,
spec,
path,
auth_type,
key,
info: {
id: id,
name: name,
description: description
}
}
]);
const blob = new Blob([json], {
type: 'application/json'
});
saveAs(blob, `tool-server-${id || name || 'export'}.json`);
};
const submitHandler = async () => { const submitHandler = async () => {
loading = true; loading = true;
@@ -149,10 +244,26 @@
return; return;
} }
// validate spec
if (spec_type === 'json') {
try {
const specJSON = JSON.parse(spec);
spec = JSON.stringify(specJSON, null, 2);
} catch (e) {
toast.error($i18n.t('Please enter a valid JSON spec'));
loading = false;
return;
}
}
const connection = { const connection = {
url,
path,
type, type,
url,
spec_type,
spec,
path,
auth_type, auth_type,
key, key,
config: { config: {
@@ -173,9 +284,12 @@
show = false; show = false;
// reset form // reset form
url = '';
path = 'openapi.json';
type = 'openapi'; type = 'openapi';
url = '';
spec_type = 'url';
spec = '';
path = 'openapi.json';
key = ''; key = '';
auth_type = 'bearer'; auth_type = 'bearer';
@@ -191,10 +305,13 @@
const init = () => { const init = () => {
if (connection) { if (connection) {
type = connection?.type ?? 'openapi';
url = connection.url; url = connection.url;
spec_type = connection?.spec_type ?? 'url';
spec = connection?.spec ?? '';
path = connection?.path ?? 'openapi.json'; path = connection?.path ?? 'openapi.json';
type = connection?.type ?? 'openapi';
auth_type = connection?.auth_type ?? 'bearer'; auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? ''; key = connection?.key ?? '';
@@ -227,19 +344,47 @@
{$i18n.t('Add Connection')} {$i18n.t('Add Connection')}
{/if} {/if}
</h1> </h1>
<button
class="self-center" <div class="flex items-center gap-3">
aria-label={$i18n.t('Close Configure Connection Modal')} <div class="flex gap-1.5 text-xs justify-end">
on:click={() => { <button
show = false; class=" hover:underline"
}} type="button"
> on:click={() => {
<XMark className={'size-5'} /> inputElement?.click();
</button> }}
>
{$i18n.t('Import')}
</button>
<button class=" hover:underline" type="button" on:click={exportHandler}>
{$i18n.t('Export')}
</button>
</div>
<button
class="self-center"
aria-label={$i18n.t('Close Configure Connection Modal')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
</div> </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 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"> <div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<input
bind:this={inputElement}
type="file"
hidden
accept=".json"
on:change={(e) => {
importHandler(e);
}}
/>
<form <form
class="flex flex-col w-full" class="flex flex-col w-full"
on:submit={(e) => { on:submit={(e) => {
@@ -326,35 +471,81 @@
<Switch bind:state={enable} /> <Switch bind:state={enable} />
</Tooltip> </Tooltip>
</div> </div>
{#if ['', 'openapi'].includes(type)}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{/if}
</div> </div>
</div> </div>
{#if ['', 'openapi'].includes(type)} {#if ['', 'openapi'].includes(type)}
<div <div class="flex gap-2 mt-2">
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} <div class="flex flex-col w-full">
> <div class="flex justify-between items-center mb-0.5">
{$i18n.t(`WebUI will make requests to "{{url}}"`, { <div class="flex gap-2 items-center">
url: path.includes('://') <div
? path for="select-bearer-or-session"
: `${url}${path.startsWith('/') ? '' : '/'}${path}` class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
})} >
{$i18n.t('OpenAPI Spec')}
</div>
</div>
</div>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={spec_type}
>
<option value="url">{$i18n.t('URL')}</option>
<option value="json">{$i18n.t('JSON')}</option>
</select>
</div>
<div class="flex flex-1 items-center">
{#if spec_type === 'url'}
<div class="flex-1 flex items-center">
<label for="url-or-path" class="sr-only"
>{$i18n.t('openapi.json URL or Path')}</label
>
<input
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
id="url-or-path"
bind:value={path}
placeholder={$i18n.t('openapi.json URL or Path')}
autocomplete="off"
required
/>
</div>
{:else if spec_type === 'json'}
<div
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
<textarea
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
bind:value={spec}
placeholder={$i18n.t('JSON Spec')}
autocomplete="off"
required
rows="5"
/>
</div>
{/if}
</div>
</div>
{#if ['', 'url'].includes(spec_type)}
<div
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
url: path.includes('://')
? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
</div>
</div> </div>
{/if} {/if}
@@ -566,35 +757,38 @@
</div> </div>
{/if} {/if}
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5"> <div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
{#if edit} <div></div>
<button <div class="flex gap-1.5">
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center" {#if edit}
type="button" <button
on:click={() => { class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
onDelete(); type="button"
show = false; on:click={() => {
}} onDelete();
> show = false;
{$i18n.t('Delete')} }}
</button> >
{/if} {$i18n.t('Delete')}
</button>
<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} {/if}
</button>
<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> </form>
</div> </div>
+467 -431
View File
@@ -3,7 +3,7 @@
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
import { WEBUI_NAME, config, functions, models, settings } from '$lib/stores'; import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
import { onMount, getContext, tick } from 'svelte'; import { onMount, getContext, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -12,6 +12,7 @@
deleteFunctionById, deleteFunctionById,
exportFunctions, exportFunctions,
getFunctionById, getFunctionById,
getFunctionList,
getFunctions, getFunctions,
loadFunctionByUrl, loadFunctionByUrl,
toggleFunctionById, toggleFunctionById,
@@ -36,6 +37,10 @@
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte'; import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
import ImportModal from '../ImportModal.svelte'; import ImportModal from '../ImportModal.svelte';
import ViewSelector from '../workspace/common/ViewSelector.svelte';
import TagSelector from '../workspace/common/TagSelector.svelte';
import { capitalizeFirstLetter } from '$lib/utils';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -44,12 +49,16 @@
let functionsImportInputElement: HTMLInputElement; let functionsImportInputElement: HTMLInputElement;
let importFiles; let importFiles;
let tagsContainerElement: HTMLDivElement;
let viewOption = '';
let query = '';
let selectedTag = '';
let selectedType = '';
let showImportModal = false; let showImportModal = false;
let showConfirm = false; let showConfirm = false;
let query = '';
let selectedType = 'all';
let showManifestModal = false; let showManifestModal = false;
let showValvesModal = false; let showValvesModal = false;
@@ -57,17 +66,33 @@
let showDeleteConfirm = false; let showDeleteConfirm = false;
let loaded = false;
let functions = null;
let filteredItems = []; let filteredItems = [];
$: filteredItems = $functions
.filter(
(f) =>
(selectedType !== 'all' ? f.type === selectedType : true) &&
(query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase()))
)
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
$: if (
functions &&
query !== undefined &&
selectedType !== undefined &&
viewOption !== undefined
) {
setFilteredItems();
}
const setFilteredItems = () => {
filteredItems = functions
.filter(
(f) =>
(selectedType !== '' ? f.type === selectedType : true) &&
(query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase())) &&
(viewOption === '' ||
(viewOption === 'created' && f.user_id === $user?.id) ||
(viewOption === 'shared' && f.user_id !== $user?.id))
)
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
};
const shareHandler = async (func) => { const shareHandler = async (func) => {
const item = await getFunctionById(localStorage.token, func.id).catch((error) => { const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
@@ -134,7 +159,7 @@
if (res) { if (res) {
toast.success($i18n.t('Function deleted successfully')); toast.success($i18n.t('Function deleted successfully'));
functions.set(await getFunctions(localStorage.token)); _functions.set(await getFunctions(localStorage.token));
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
@@ -162,7 +187,7 @@
: toast.success($i18n.t('Function is now globally disabled')); : toast.success($i18n.t('Function is now globally disabled'));
} }
functions.set(await getFunctions(localStorage.token)); _functions.set(await getFunctions(localStorage.token));
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
@@ -174,7 +199,16 @@
} }
}; };
onMount(() => { onMount(async () => {
viewOption = localStorage?.workspaceViewOption || '';
functions = await getFunctionList(localStorage.token).catch((error) => {
toast.error(`${error}`);
return [];
});
await tick();
loaded = true;
const onKeyDown = (event) => { const onKeyDown = (event) => {
if (event.key === 'Shift') { if (event.key === 'Shift') {
shiftKey = true; shiftKey = true;
@@ -222,424 +256,388 @@
}} }}
/> />
<div class="flex flex-col mt-1.5 mb-0.5 px-[16px]"> {#if loaded}
<div class="flex justify-between items-center mb-1"> <div class="px-4.5 w-full">
<div class="flex md:self-center text-xl items-center font-medium px-0.5"> <div class="flex flex-col gap-1 px-1 mt-2.5 mb-2">
{$i18n.t('Functions')} <div class="flex justify-between items-center mb-1 w-full">
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" /> <input
<span class="text-base font-lg text-gray-500 dark:text-gray-300">{filteredItems.length}</span> id="documents-import-input"
</div> bind:this={functionsImportInputElement}
</div> bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<div class=" flex w-full space-x-2"> <div class="flex justify-between items-center w-full">
<div class="flex flex-1"> <div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div class=" self-center ml-1 mr-3"> <div>
<Search className="size-3.5" /> {$i18n.t('Functions')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{filteredItems.length}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
{#if $user?.role === 'admin'}
<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={() => {
functionsImportInputElement.click();
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Import')}
</div>
</button>
{#if functions.length}
<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 () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
</button>
{/if}
{/if}
<AddFunctionMenu
createHandler={() => {
goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
}}
>
<div
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"
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Function')}</div>
</div>
</AddFunctionMenu>
</div>
</div>
</div> </div>
<input </div>
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Functions')}
/>
{#if query} <div
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent"> class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100 dark:border-gray-850"
<button >
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition" <div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
on:click={() => { <div class="flex flex-1">
query = ''; <div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Functions')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
<div
class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
<ViewSelector
bind:value={viewOption}
onChange={async (value) => {
localStorage.workspaceViewOption = value;
await tick();
}} }}
> />
<XMark className="size-3" strokeWidth="2" />
</button> <TagSelector
bind:value={selectedType}
items={[
{ value: 'pipe', label: $i18n.t('Pipe') },
{ value: 'filter', label: $i18n.t('Filter') },
{ value: 'action', label: $i18n.t('Action') }
]}
/>
</div>
</div>
{#if (filteredItems ?? []).length !== 0}
<div class="px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<Tooltip content={func.id} placement="top-start">
<div class=" flex items-center gap-1.5">
<div
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
<div class="line-clamp-1 text-sm">
{func.name}
</div>
{#if func?.meta?.manifest?.version}
<div class=" text-gray-500 text-xs font-medium shrink-0">
v{func?.meta?.manifest?.version ?? ''}
</div>
{/if}
</div>
</Tooltip>
<div class="flex gap-1.5 px-1">
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={func?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
</div>
</div>
</div>
</div>
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
deleteHandler(func);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if func?.meta?.manifest?.funding_url ?? false}
<Tooltip content={$i18n.t('Support')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showManifestModal = true;
}}
>
<Heart />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<FunctionMenu
{func}
editHandler={() => {
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
}}
cloneHandler={() => {
cloneHandler(func);
}}
exportHandler={() => {
exportHandler(func);
}}
deleteHandler={async () => {
selectedFunction = func;
showDeleteConfirm = true;
}}
toggleGlobalHandler={() => {
if (['filter', 'action'].includes(func.type)) {
toggleGlobalHandler(func);
}
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</FunctionMenu>
{/if}
<div class=" self-center mx-1">
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={func.is_active}
on:change={async (e) => {
toggleFunctionById(localStorage.token, func.id);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
</Tooltip>
</div>
</div>
</div>
{/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 functions found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
<div> <!-- <div class=" text-gray-500 text-xs mt-1 mb-2">
<AddFunctionMenu
createHandler={() => {
goto('/admin/functions/create');
}}
importFromLinkHandler={() => {
showImportModal = true;
}}
>
<div
class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
>
<Plus className="size-3.5" />
</div>
</AddFunctionMenu>
</div>
</div>
<div class=" flex w-full">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent"
>
<button
class="min-w-fit p-1.5 {selectedType === 'all'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'all';
}}>{$i18n.t('All')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'pipe'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'pipe';
}}>{$i18n.t('Pipe')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'filter'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'filter';
}}>{$i18n.t('Filter')}</button
>
<button
class="min-w-fit p-1.5 {selectedType === 'action'
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
on:click={() => {
selectedType = 'action';
}}>{$i18n.t('Action')}</button
>
</div>
</div>
</div>
<div class="mb-5 px-[16px]">
{#each filteredItems as func (func.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
>
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5">
<div
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
{func.type}
</div>
{#if func?.meta?.manifest?.version}
<div
class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
>
v{func?.meta?.manifest?.version ?? ''}
</div>
{/if}
<div class=" line-clamp-1">
{func.name}
</div>
</div>
<div class="flex gap-1.5 px-1">
<div class=" text-gray-500 text-xs font-medium shrink-0">{func.id}</div>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
{func.meta.description}
</div>
</div>
</div>
</div>
</a>
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
deleteHandler(func);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
{#if func?.meta?.manifest?.funding_url ?? false}
<Tooltip content={$i18n.t('Support')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showManifestModal = true;
}}
>
<Heart />
</button>
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
selectedFunction = func;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
<FunctionMenu
{func}
editHandler={() => {
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
}}
cloneHandler={() => {
cloneHandler(func);
}}
exportHandler={() => {
exportHandler(func);
}}
deleteHandler={async () => {
selectedFunction = func;
showDeleteConfirm = true;
}}
toggleGlobalHandler={() => {
if (['filter', 'action'].includes(func.type)) {
toggleGlobalHandler(func);
}
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</FunctionMenu>
{/if}
<div class=" self-center mx-1">
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch
bind:state={func.is_active}
on:change={async (e) => {
toggleFunctionById(localStorage.token, func.id);
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
</Tooltip>
</div>
</div>
</div>
{/each}
</div>
<!-- <div class=" text-gray-500 text-xs mt-1 mb-2">
ⓘ {$i18n.t( ⓘ {$i18n.t(
'Admins have access to all tools at all times; users need tools assigned per model in the workspace.' 'Admins have access to all tools at all times; users need tools assigned per model in the workspace.'
)} )}
</div> --> </div> -->
<div class=" flex justify-end w-full mb-2 px-[16px]"> {#if $config?.features.enable_community_sharing}
<div class="flex space-x-2"> <div class=" my-16">
<input <div class=" text-xl font-medium mb-1 line-clamp-1">
id="documents-import-input" {$i18n.t('Made by Open WebUI Community')}
bind:this={functionsImportInputElement} </div>
bind:files={importFiles}
type="file"
accept=".json"
hidden
on:change={() => {
console.log(importFiles);
showConfirm = true;
}}
/>
<button <a
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" class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
on:click={() => { href="https://openwebui.com/functions"
functionsImportInputElement.click(); target="_blank"
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Functions')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
> >
<path <div class=" self-center">
fill-rule="evenodd" <div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
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" <div class=" text-sm line-clamp-1">
clip-rule="evenodd" {$i18n.t('Discover, download, and explore custom functions')}
/> </div>
</svg> </div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div> </div>
</button>
{#if $functions.length}
<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 () => {
const _functions = await exportFunctions(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_functions) {
let blob = new Blob([JSON.stringify(_functions)], {
type: 'application/json'
});
saveAs(blob, `functions-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Export Functions')} ({$functions.length})
</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<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>
{/if} {/if}
</div> </div>
</div>
{#if $config?.features.enable_community_sharing} <DeleteConfirmDialog
<div class=" my-16 px-[16px]"> bind:show={showDeleteConfirm}
<div class=" text-xl font-medium mb-1 line-clamp-1"> title={$i18n.t('Delete function?')}
{$i18n.t('Made by Open WebUI Community')} on:confirm={() => {
deleteHandler(selectedFunction);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
</div> </div>
</DeleteConfirmDialog>
<a <ManifestModal bind:show={showManifestModal} manifest={selectedFunction?.meta?.manifest ?? {}} />
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition" <ValvesModal
href="https://openwebui.com/functions" bind:show={showValvesModal}
target="_blank" type="function"
> id={selectedFunction?.id ?? null}
<div class=" self-center"> on:save={async () => {
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div> await tick();
<div class=" text-sm line-clamp-1">
{$i18n.t('Discover, download, and explore custom functions')}
</div>
</div>
<div>
<div>
<ChevronRight />
</div>
</div>
</a>
</div>
{/if}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete function?')}
on:confirm={() => {
deleteHandler(selectedFunction);
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
</div>
</DeleteConfirmDialog>
<ManifestModal bind:show={showManifestModal} manifest={selectedFunction?.meta?.manifest ?? {}} />
<ValvesModal
bind:show={showValvesModal}
type="function"
id={selectedFunction?.id ?? null}
on:save={async () => {
await tick();
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
}}
/>
<ConfirmDialog
bind:show={showConfirm}
on:confirm={() => {
const reader = new FileReader();
reader.onload = async (event) => {
const _functions = JSON.parse(event.target.result);
console.log(_functions);
for (let func of _functions) {
if ('function' in func) {
// Required for Community JSON import
func = func.function;
}
const res = await createNewFunction(localStorage.token, func).catch((error) => {
toast.error(`${error}`);
return null;
});
}
toast.success($i18n.t('Functions imported successfully'));
functions.set(await getFunctions(localStorage.token));
models.set( models.set(
await getModels( await getModels(
localStorage.token, localStorage.token,
@@ -648,25 +646,63 @@
true true
) )
); );
}; }}
/>
reader.readAsText(importFiles[0]); <ConfirmDialog
}} bind:show={showConfirm}
> on:confirm={() => {
<div class="text-sm text-gray-500"> const reader = new FileReader();
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3"> reader.onload = async (event) => {
<div>{$i18n.t('Please carefully review the following warnings:')}</div> const _functions = JSON.parse(event.target.result);
console.log(_functions);
<ul class=" mt-1 list-disc pl-4 text-xs"> for (let func of _functions) {
<li>{$i18n.t('Functions allow arbitrary code execution.')}</li> if ('function' in func) {
<li>{$i18n.t('Do not install functions from sources you do not fully trust.')}</li> // Required for Community JSON import
</ul> func = func.function;
</div> }
<div class="my-3"> const res = await createNewFunction(localStorage.token, func).catch((error) => {
{$i18n.t( toast.error(`${error}`);
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.' return null;
)} });
}
toast.success($i18n.t('Functions imported successfully'));
functions.set(await getFunctions(localStorage.token));
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null),
false,
true
)
);
};
reader.readAsText(importFiles[0]);
}}
>
<div class="text-sm text-gray-500">
<div class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-lg px-4 py-3">
<div>{$i18n.t('Please carefully review the following warnings:')}</div>
<ul class=" mt-1 list-disc pl-4 text-xs">
<li>{$i18n.t('Functions allow arbitrary code execution.')}</li>
<li>{$i18n.t('Do not install functions from sources you do not fully trust.')}</li>
</ul>
</div>
<div class="my-3">
{$i18n.t(
'I acknowledge that I have read and I understand the implications of my action. I am aware of the risks associated with executing arbitrary code and I have verified the trustworthiness of the source.'
)}
</div>
</div> </div>
</ConfirmDialog>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div> </div>
</ConfirmDialog> {/if}
@@ -41,27 +41,27 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary" class="w-full max-w-[190px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2} sideOffset={6}
side="bottom" side="bottom"
align="start" align="start"
transition={flyAndScale} transition={flyAndScale}
> >
<button <button
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" 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 w-full"
on:click={async () => { on:click={async () => {
createHandler(); createHandler();
show = false; show = false;
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<PencilSolid /> <Pencil />
</div> </div>
<div class=" self-center truncate">{$i18n.t('New Function')}</div> <div class=" self-center truncate">{$i18n.t('New Function')}</div>
</button> </button>
<button <button
class="flex rounded-md py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition" 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 w-full"
on:click={async () => { on:click={async () => {
importFromLinkHandler(); importFromLinkHandler();
show = false; show = false;
+31 -2
View File
@@ -19,6 +19,7 @@
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next'; import type { i18n as i18nType } from 'i18next';
import Textarea from '$lib/components/common/Textarea.svelte';
const i18n = getContext<Writable<i18nType>>('i18n'); const i18n = getContext<Writable<i18nType>>('i18n');
@@ -31,6 +32,7 @@
let TTS_ENGINE = ''; let TTS_ENGINE = '';
let TTS_MODEL = ''; let TTS_MODEL = '';
let TTS_VOICE = ''; let TTS_VOICE = '';
let TTS_OPENAI_PARAMS = '';
let TTS_SPLIT_ON: TTS_RESPONSE_SPLIT = TTS_RESPONSE_SPLIT.PUNCTUATION; let TTS_SPLIT_ON: TTS_RESPONSE_SPLIT = TTS_RESPONSE_SPLIT.PUNCTUATION;
let TTS_AZURE_SPEECH_REGION = ''; let TTS_AZURE_SPEECH_REGION = '';
let TTS_AZURE_SPEECH_BASE_URL = ''; let TTS_AZURE_SPEECH_BASE_URL = '';
@@ -98,18 +100,28 @@
}; };
const updateConfigHandler = async () => { const updateConfigHandler = async () => {
let openaiParams = {};
try {
openaiParams = TTS_OPENAI_PARAMS ? JSON.parse(TTS_OPENAI_PARAMS) : {};
TTS_OPENAI_PARAMS = JSON.stringify(openaiParams, null, 2);
} catch (e) {
toast.error($i18n.t('Invalid JSON format for Parameters'));
return;
}
const res = await updateAudioConfig(localStorage.token, { const res = await updateAudioConfig(localStorage.token, {
tts: { tts: {
OPENAI_API_BASE_URL: TTS_OPENAI_API_BASE_URL, OPENAI_API_BASE_URL: TTS_OPENAI_API_BASE_URL,
OPENAI_API_KEY: TTS_OPENAI_API_KEY, OPENAI_API_KEY: TTS_OPENAI_API_KEY,
OPENAI_PARAMS: openaiParams,
API_KEY: TTS_API_KEY, API_KEY: TTS_API_KEY,
ENGINE: TTS_ENGINE, ENGINE: TTS_ENGINE,
MODEL: TTS_MODEL, MODEL: TTS_MODEL,
VOICE: TTS_VOICE, VOICE: TTS_VOICE,
SPLIT_ON: TTS_SPLIT_ON,
AZURE_SPEECH_REGION: TTS_AZURE_SPEECH_REGION, AZURE_SPEECH_REGION: TTS_AZURE_SPEECH_REGION,
AZURE_SPEECH_BASE_URL: TTS_AZURE_SPEECH_BASE_URL, AZURE_SPEECH_BASE_URL: TTS_AZURE_SPEECH_BASE_URL,
AZURE_SPEECH_OUTPUT_FORMAT: TTS_AZURE_SPEECH_OUTPUT_FORMAT AZURE_SPEECH_OUTPUT_FORMAT: TTS_AZURE_SPEECH_OUTPUT_FORMAT,
SPLIT_ON: TTS_SPLIT_ON
}, },
stt: { stt: {
OPENAI_API_BASE_URL: STT_OPENAI_API_BASE_URL, OPENAI_API_BASE_URL: STT_OPENAI_API_BASE_URL,
@@ -146,6 +158,7 @@
console.log(res); console.log(res);
TTS_OPENAI_API_BASE_URL = res.tts.OPENAI_API_BASE_URL; TTS_OPENAI_API_BASE_URL = res.tts.OPENAI_API_BASE_URL;
TTS_OPENAI_API_KEY = res.tts.OPENAI_API_KEY; TTS_OPENAI_API_KEY = res.tts.OPENAI_API_KEY;
TTS_OPENAI_PARAMS = JSON.stringify(res?.tts?.OPENAI_PARAMS ?? '', null, 2);
TTS_API_KEY = res.tts.API_KEY; TTS_API_KEY = res.tts.API_KEY;
TTS_ENGINE = res.tts.ENGINE; TTS_ENGINE = res.tts.ENGINE;
@@ -612,6 +625,22 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
<div class="w-full">
<div class=" mb-1.5 text-xs font-medium">{$i18n.t('Additional Parameters')}</div>
<div class="flex w-full">
<div class="flex-1">
<Textarea
className="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
bind:value={TTS_OPENAI_PARAMS}
placeholder={$i18n.t('Enter additional parameters in JSON format')}
minSize={100}
/>
</div>
</div>
</div>
</div>
{:else if TTS_ENGINE === 'elevenlabs'} {:else if TTS_ENGINE === 'elevenlabs'}
<div class=" flex gap-2"> <div class=" flex gap-2">
<div class="w-full"> <div class="w-full">
@@ -714,6 +714,21 @@
</div> </div>
{/if} {/if}
{/if} {/if}
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('Parameters')}
</Tooltip>
</div>
<div class="">
<Textarea
bind:value={RAGConfig.DOCLING_PARAMETERS}
placeholder={$i18n.t('Enter additional parameters in JSON format')}
minSize={100}
/>
</div>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'} {:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
<div class="my-0.5 flex gap-2 pr-2"> <div class="my-0.5 flex gap-2 pr-2">
<input <input
@@ -34,7 +34,7 @@
<div class="w-full flex flex-col"> <div class="w-full flex flex-col">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="shrink-0 line-clamp-1"> <div class=" line-clamp-1">
{model.name} {model.name}
</div> </div>
</div> </div>
+26 -30
View File
@@ -12,7 +12,8 @@
deleteAllModels, deleteAllModels,
getBaseModels, getBaseModels,
toggleModelById, toggleModelById,
updateModelById updateModelById,
importModels
} from '$lib/apis/models'; } from '$lib/apis/models';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -40,6 +41,7 @@
let shiftKey = false; let shiftKey = false;
let modelsImportInProgress = false;
let importFiles; let importFiles;
let modelsImportInputElement: HTMLInputElement; let modelsImportInputElement: HTMLInputElement;
@@ -464,47 +466,41 @@
accept=".json" accept=".json"
hidden hidden
on:change={() => { on:change={() => {
console.log(importFiles); if (importFiles.length > 0) {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const models = JSON.parse(String(event.target.result));
modelsImportInProgress = true;
const res = await importModels(localStorage.token, models);
modelsImportInProgress = false;
let reader = new FileReader(); if (res) {
reader.onload = async (event) => { toast.success($i18n.t('Models imported successfully'));
let savedModels = JSON.parse(event.target.result); await init();
console.log(savedModels); } else {
toast.error($i18n.t('Failed to import models'));
for (const model of savedModels) {
if (Object.keys(model).includes('base_model_id')) {
if (model.base_model_id === null) {
upsertModelHandler(model);
}
} else {
if (model?.info ?? false) {
if (model.info.base_model_id === null) {
upsertModelHandler(model.info);
}
} }
} catch (e) {
toast.error($i18n.t('Invalid JSON file'));
console.error(e);
} }
} };
reader.readAsText(importFiles[0]);
await _models.set( }
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
init();
};
reader.readAsText(importFiles[0]);
}} }}
/> />
<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" 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={() => { on:click={() => {
modelsImportInputElement.click(); modelsImportInputElement.click();
}} }}
> >
{#if modelsImportInProgress}
<Spinner className="size-3" />
{/if}
<div class=" self-center mr-2 font-medium line-clamp-1"> <div class=" self-center mr-2 font-medium line-clamp-1">
{$i18n.t('Import Presets')} {$i18n.t('Import Presets')}
</div> </div>
@@ -36,11 +36,13 @@
let updateModelId = null; let updateModelId = null;
let updateProgress = null; let updateProgress = null;
let updateModelsControllers = {};
let updateCancelled = false;
let showExperimentalOllama = false; let showExperimentalOllama = false;
const MAX_PARALLEL_DOWNLOADS = 3; const MAX_PARALLEL_DOWNLOADS = 3;
let modelTransferring = false; let modelLoading = false;
let modelTag = ''; let modelTag = '';
let createModelLoading = false; let createModelLoading = false;
@@ -65,17 +67,31 @@
let deleteModelTag = ''; let deleteModelTag = '';
const updateModelsHandler = async () => { const updateModelsHandler = async () => {
updateCancelled = false;
toast.info('Checking for model updates...');
for (const model of ollamaModels) { for (const model of ollamaModels) {
if (updateCancelled) {
break;
}
console.debug(model); console.debug(model);
updateModelId = model.id; updateModelId = model.id;
const [res, controller] = await pullModel(localStorage.token, model.id, urlIdx).catch( const [res, controller] = await pullModel(localStorage.token, model.id, urlIdx).catch(
(error) => { (error) => {
toast.error(`${error}`); if (error.name !== 'AbortError') {
return null; toast.error(`${error}`);
}
return [null, null];
} }
); );
updateModelsControllers = {
...updateModelsControllers,
[model.id]: controller
};
if (res) { if (res) {
const reader = res.body const reader = res.body
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
@@ -108,19 +124,28 @@
} else { } else {
updateProgress = 100; updateProgress = 100;
} }
} else {
toast.success(data.status);
} }
} }
} }
} }
} catch (err) { } catch (err) {
console.error(err); if (err.name !== 'AbortError') {
console.error(err);
}
break;
} }
} }
} }
delete updateModelsControllers[model.id];
updateModelsControllers = { ...updateModelsControllers };
} }
if (updateCancelled) {
toast.info('Model update cancelled');
} else {
toast.success('All models are up to date');
}
updateModelId = null; updateModelId = null;
updateProgress = null; updateProgress = null;
}; };
@@ -143,10 +168,13 @@
return; return;
} }
modelLoading = true;
const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, urlIdx).catch( const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, urlIdx).catch(
(error) => { (error) => {
toast.error(`${error}`); if (error.name !== 'AbortError') {
return null; toast.error(`${error}`);
}
return [null, null];
} }
); );
@@ -202,8 +230,6 @@
} }
}); });
} else { } else {
toast.success(data.status);
MODEL_DOWNLOAD_POOL.set({ MODEL_DOWNLOAD_POOL.set({
...$MODEL_DOWNLOAD_POOL, ...$MODEL_DOWNLOAD_POOL,
[sanitizedModelTag]: { [sanitizedModelTag]: {
@@ -216,19 +242,23 @@
} }
} }
} catch (err) { } catch (err) {
console.error(err); if (err.name !== 'AbortError') {
if (typeof err !== 'string') { console.error(err);
err = err.message; if (typeof err !== 'string') {
} err = err.message;
}
toast.error(`${err}`); toast.error(`${err}`);
// opts.callback({ success: false, error, modelName: opts.modelName }); // opts.callback({ success: false, error, modelName: opts.modelName });
} else {
break;
}
} }
} }
console.log($MODEL_DOWNLOAD_POOL[sanitizedModelTag]); console.log($MODEL_DOWNLOAD_POOL[sanitizedModelTag]);
if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) { if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]?.done) {
toast.success( toast.success(
$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, { $i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
modelName: sanitizedModelTag modelName: sanitizedModelTag
@@ -253,11 +283,11 @@
} }
modelTag = ''; modelTag = '';
modelTransferring = false; modelLoading = false;
}; };
const uploadModelHandler = async () => { const uploadModelHandler = async () => {
modelTransferring = true; modelLoading = true;
let uploaded = false; let uploaded = false;
let fileResponse = null; let fileResponse = null;
@@ -396,7 +426,7 @@
modelUploadInputElement.value = ''; modelUploadInputElement.value = '';
} }
modelInputFile = null; modelInputFile = null;
modelTransferring = false; modelLoading = false;
uploadProgress = null; uploadProgress = null;
models.set( models.set(
@@ -425,6 +455,14 @@
); );
}; };
const cancelUpdateModelHandler = async (model: string) => {
const controller = updateModelsControllers[model];
if (controller) {
controller.abort();
updateCancelled = true;
}
};
const cancelModelPullHandler = async (model: string) => { const cancelModelPullHandler = async (model: string) => {
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model]; const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
if (abortController) { if (abortController) {
@@ -605,59 +643,61 @@
bind:value={modelTag} bind:value={modelTag}
/> />
</div> </div>
<button <Tooltip content={$i18n.t('Pull Model')} placement="top">
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition" <button
on:click={() => { class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
pullModelHandler(); on:click={() => {
}} pullModelHandler();
disabled={modelTransferring} }}
> disabled={modelLoading || modelTag.trim() === ''}
{#if modelTransferring} >
<div class="self-center"> {#if modelLoading}
<svg <div class="self-center">
class=" w-4 h-4" <svg
viewBox="0 0 24 24" class=" w-4 h-4"
fill="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" fill="currentColor"
> xmlns="http://www.w3.org/2000/svg"
<style> >
.spinner_ajPY { <style>
transform-origin: center; .spinner_ajPY {
animation: spinner_AtaB 0.75s infinite linear; transform-origin: center;
} animation: spinner_AtaB 0.75s infinite linear;
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
} }
}
</style> @keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path <path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
opacity=".25"
/> />
<path <path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
class="spinner_ajPY"
/> />
</svg> </svg>
</div> {/if}
{:else} </button>
<svg </Tooltip>
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
</div> </div>
<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500"> <div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
@@ -670,8 +710,35 @@
</div> </div>
{#if updateModelId} {#if updateModelId}
<div class="text-xs"> <div class="text-xs flex justify-between items-center">
Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''} <div>Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''}</div>
<Tooltip content={$i18n.t('Cancel')}>
<button
class="text-gray-800 dark:text-gray-100"
on:click={() => {
cancelUpdateModelHandler(updateModelId);
}}
>
<svg
class="w-4 h-4 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18 17.94 6M18 18 6.06 6"
/>
</svg>
</button>
</Tooltip>
</div> </div>
{/if} {/if}
@@ -754,25 +821,28 @@
{/each} {/each}
</select> </select>
</div> </div>
<button <Tooltip content={$i18n.t('Delete Model')} placement="top">
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition" <button
on:click={() => { class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
showModelDeleteConfirm = true; on:click={() => {
}} showModelDeleteConfirm = true;
> }}
<svg disabled={deleteModelTag === ''}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
> >
<path <svg
fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z" viewBox="0 0 16 16"
clip-rule="evenodd" fill="currentColor"
/> class="w-4 h-4"
</svg> >
</button> <path
fill-rule="evenodd"
d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div> </div>
</div> </div>
@@ -799,27 +869,31 @@
</div> </div>
<div class="flex self-start"> <div class="flex self-start">
<button <Tooltip content={$i18n.t('Create Model')} placement="top">
class="px-2.5 py-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed" <button
on:click={() => { class="px-2.5 py-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed"
createModelHandler(); on:click={() => {
}} createModelHandler();
disabled={createModelLoading} }}
> disabled={createModelLoading ||
<svg createModelName.trim() === '' ||
xmlns="http://www.w3.org/2000/svg" createModelObject.trim() === ''}
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
> >
<path <svg
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z" xmlns="http://www.w3.org/2000/svg"
/> viewBox="0 0 16 16"
<path fill="currentColor"
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z" class="size-4"
/> >
</svg> <path
</button> d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
</button>
</Tooltip>
</div> </div>
</div> </div>
@@ -936,57 +1010,59 @@
</div> </div>
{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')} {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
<button <Tooltip content={$i18n.t('Upload Model')} placement="top">
class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition" <button
type="submit" class="px-2.5 bg-gray-50 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
disabled={modelTransferring} type="submit"
> disabled={modelLoading}
{#if modelTransferring} >
<div class="self-center"> {#if modelLoading}
<svg <div class="self-center">
class=" w-4 h-4" <svg
viewBox="0 0 24 24" class=" w-4 h-4"
fill="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" fill="currentColor"
> xmlns="http://www.w3.org/2000/svg"
<style> >
.spinner_ajPY { <style>
transform-origin: center; .spinner_ajPY {
animation: spinner_AtaB 0.75s infinite linear; transform-origin: center;
} animation: spinner_AtaB 0.75s infinite linear;
@keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
} }
}
</style> @keyframes spinner_AtaB {
100% {
transform: rotate(360deg);
}
}
</style>
<path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
opacity=".25"
/>
<path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
class="spinner_ajPY"
/>
</svg>
</div>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path <path
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
opacity=".25"
/> />
<path <path
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
class="spinner_ajPY"
/> />
</svg> </svg>
</div> {/if}
{:else} </button>
<svg </Tooltip>
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
/>
<path
d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
/>
</svg>
{/if}
</button>
{/if} {/if}
</div> </div>
@@ -83,7 +83,7 @@
</Tooltip> </Tooltip>
</div> </div>
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1">
{#each servers as server, idx} {#each servers as server, idx}
<Connection <Connection
bind:connection={server} bind:connection={server}
+15 -52
View File
@@ -19,10 +19,9 @@
import Search from '$lib/components/icons/Search.svelte'; import Search from '$lib/components/icons/Search.svelte';
import User from '$lib/components/icons/User.svelte'; import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte'; import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './Groups/EditGroupModal.svelte'; import EditGroupModal from './Groups/EditGroupModal.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte'; import Pencil from '$lib/components/icons/Pencil.svelte';
import GroupItem from './Groups/GroupItem.svelte'; import GroupItem from './Groups/GroupItem.svelte';
import AddGroupModal from './Groups/AddGroupModal.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups'; import { createNewGroup, getGroups } from '$lib/apis/groups';
import { import {
getUserDefaultPermissions, getUserDefaultPermissions,
@@ -51,50 +50,9 @@
}); });
let search = ''; let search = '';
let defaultPermissions = { let defaultPermissions = {};
workspace: {
models: false,
knowledge: false,
prompts: false,
tools: false
},
sharing: {
public_models: false,
public_knowledge: false,
public_prompts: false,
public_tools: false
},
chat: {
controls: true,
valves: true,
system_prompt: true,
params: true,
file_upload: true,
delete: true,
delete_message: true,
continue_response: true,
regenerate_response: true,
rate_response: true,
edit: true,
share: true,
export: true,
stt: true,
tts: true,
call: true,
multiple_models: true,
temporary: true,
temporary_enforced: false
},
features: {
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true,
notes: true
}
};
let showCreateGroupModal = false; let showAddGroupModal = false;
let showDefaultPermissionsModal = false; let showDefaultPermissionsModal = false;
const setGroups = async () => { const setGroups = async () => {
@@ -145,15 +103,20 @@
total = res.total; total = res.total;
} }
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token); defaultPermissions = await getUserDefaultPermissions(localStorage.token);
await setGroups();
loaded = true; loaded = true;
}); });
</script> </script>
{#if loaded} {#if loaded}
<AddGroupModal bind:show={showCreateGroupModal} onSubmit={addGroupHandler} /> <EditGroupModal
bind:show={showAddGroupModal}
edit={false}
permissions={defaultPermissions}
onSubmit={addGroupHandler}
/>
<div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between"> <div class="mt-0.5 mb-2 gap-1 flex flex-col md:flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5"> <div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Groups')} {$i18n.t('Groups')}
@@ -180,7 +143,7 @@
<button <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-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"
on:click={() => { on:click={() => {
showCreateGroupModal = !showCreateGroupModal; showAddGroupModal = !showAddGroupModal;
}} }}
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
@@ -207,7 +170,7 @@
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" 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')} aria-label={$i18n.t('Create Group')}
on:click={() => { on:click={() => {
showCreateGroupModal = true; showAddGroupModal = true;
}} }}
> >
{$i18n.t('Create Group')} {$i18n.t('Create Group')}
@@ -226,7 +189,7 @@
{#each filteredGroups as group} {#each filteredGroups as group}
<div class="my-2"> <div class="my-2">
<GroupItem {group} {users} {setGroups} /> <GroupItem {group} {users} {setGroups} {defaultPermissions} />
</div> </div>
{/each} {/each}
</div> </div>
@@ -234,7 +197,7 @@
<hr class="mb-2 border-gray-100 dark:border-gray-850" /> <hr class="mb-2 border-gray-100 dark:border-gray-850" />
<GroupModal <EditGroupModal
bind:show={showDefaultPermissionsModal} bind:show={showDefaultPermissionsModal}
tabs={['permissions']} tabs={['permissions']}
bind:permissions={defaultPermissions} bind:permissions={defaultPermissions}
@@ -1,116 +0,0 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let onSubmit: Function = () => {};
export let show = false;
let name = '';
let description = '';
let userIds = [];
let loading = false;
const submitHandler = async () => {
loading = true;
const group = {
name,
description
};
await onSubmit(group);
loading = false;
show = false;
name = '';
description = '';
userIds = [];
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Add User Group')}
</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">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="px-1 flex flex-col w-full">
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={name}
placeholder={$i18n.t('Group Name')}
autocomplete="off"
required
/>
</div>
</div>
</div>
<div class="flex flex-col w-full mt-2">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<Textarea
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden resize-none"
rows={2}
bind:value={description}
placeholder={$i18n.t('Group Description')}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<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('Create')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
@@ -21,6 +21,7 @@
export let users = []; export let users = [];
export let group = null; export let group = null;
export let defaultPermissions = {};
export let custom = true; export let custom = true;
@@ -230,7 +231,7 @@
{#if selectedTab == 'general'} {#if selectedTab == 'general'}
<Display bind:name bind:description /> <Display bind:name bind:description />
{:else if selectedTab == 'permissions'} {:else if selectedTab == 'permissions'}
<Permissions bind:permissions /> <Permissions bind:permissions {defaultPermissions} />
{:else if selectedTab == 'users'} {:else if selectedTab == 'users'}
<Users bind:userIds {users} /> <Users bind:userIds {users} />
{/if} {/if}
@@ -17,6 +17,7 @@
name: 'Admins', name: 'Admins',
user_ids: [1, 2, 3] user_ids: [1, 2, 3]
}; };
export let defaultPermissions = {};
export let setGroups = () => {}; export let setGroups = () => {};
@@ -59,6 +60,7 @@
edit edit
{users} {users}
{group} {group}
{defaultPermissions}
onSubmit={updateHandler} onSubmit={updateHandler}
onDelete={deleteHandler} onDelete={deleteHandler}
/> />
@@ -6,7 +6,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
// Default values for permissions // Default values for permissions
const defaultPermissions = { const DEFAULT_PERMISSIONS = {
workspace: { workspace: {
models: false, models: false,
knowledge: false, knowledge: false,
@@ -17,7 +17,8 @@
public_models: false, public_models: false,
public_knowledge: false, public_knowledge: false,
public_prompts: false, public_prompts: false,
public_tools: false public_tools: false,
public_notes: false
}, },
chat: { chat: {
controls: true, controls: true,
@@ -50,10 +51,11 @@
}; };
export let permissions = {}; export let permissions = {};
export let defaultPermissions = {};
// Reactive statement to ensure all fields are present in `permissions` // Reactive statement to ensure all fields are present in `permissions`
$: { $: {
permissions = fillMissingProperties(permissions, defaultPermissions); permissions = fillMissingProperties(permissions, DEFAULT_PERMISSIONS);
} }
function fillMissingProperties(obj: any, defaults: any) { function fillMissingProperties(obj: any, defaults: any) {
@@ -68,140 +70,70 @@
} }
onMount(() => { onMount(() => {
permissions = fillMissingProperties(permissions, defaultPermissions); permissions = fillMissingProperties(permissions, DEFAULT_PERMISSIONS);
}); });
</script> </script>
<div> <div class="space-y-2">
<!-- <div> <!-- {$i18n.t('Default Model')}
<div class=" mb-2 text-sm font-medium">{$i18n.t('Model Permissions')}</div> {$i18n.t('Model Filtering')}
{$i18n.t('Model Permissions')}
<div class="mb-2"> {$i18n.t('No model IDs')} -->
<div class="flex justify-between items-center text-xs pr-2">
<div class=" text-xs font-medium">{$i18n.t('Model Filtering')}</div>
<Switch bind:state={permissions.model.filter} />
</div>
</div>
{#if permissions.model.filter}
<div class="mb-2">
<div class=" space-y-1.5">
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div class="text-xs text-gray-500">{$i18n.t('Model IDs')}</div>
</div>
{#if model_ids.length > 0}
<div class="flex flex-col">
{#each model_ids as modelId, modelIdx}
<div class=" flex gap-2 w-full justify-between items-center">
<div class=" text-sm flex-1 rounded-lg">
{modelId}
</div>
<div class="shrink-0">
<button
type="button"
on:click={() => {
model_ids = model_ids.filter((_, idx) => idx !== modelIdx);
}}
>
<Minus strokeWidth="2" className="size-3.5" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<div class="text-gray-500 text-xs text-center py-2 px-10">
{$i18n.t('No model IDs')}
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 mt-2.5 mb-1 w-full" />
<div class="flex items-center">
<select
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}
>
<option value="">{$i18n.t('Select a model')}</option>
{#each $models.filter((m) => m?.owned_by !== 'arena') as model}
<option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
<div>
<button
type="button"
on:click={() => {
if (selectedModelId && !permissions.model.model_ids.includes(selectedModelId)) {
permissions.model.model_ids = [...permissions.model.model_ids, selectedModelId];
selectedModelId = '';
}
}}
>
<Plus className="size-3.5" strokeWidth="2" />
</button>
</div>
</div>
</div>
{/if}
<div class=" space-y-1 mb-3">
<div class="">
<div class="flex justify-between items-center text-xs">
<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
</div>
</div>
<div class="flex-1 mr-2">
<select
class="w-full bg-transparent outline-hidden py-0.5 text-sm"
bind:value={permissions.model.default_id}
placeholder={$i18n.t('Select a model')}
>
<option value="" disabled selected>{$i18n.t('Select a model')}</option>
{#each permissions.model.filter ? $models.filter( (model) => filterModelIds.includes(model.id) ) : $models.filter((model) => model.id) as model}
<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
{/each}
</select>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> -->
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Workspace Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Models Access')} <div class=" self-center text-xs font-medium">
{$i18n.t('Models Access')}
</div>
<Switch bind:state={permissions.workspace.models} />
</div> </div>
<Switch bind:state={permissions.workspace.models} /> {#if defaultPermissions?.workspace?.models && !permissions.workspace.models}
<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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Knowledge Access')} <div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Access')}
</div>
<Switch bind:state={permissions.workspace.knowledge} />
</div> </div>
<Switch bind:state={permissions.workspace.knowledge} /> {#if defaultPermissions?.workspace?.knowledge && !permissions.workspace.knowledge}
<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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Prompts Access')} <div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Access')}
</div>
<Switch bind:state={permissions.workspace.prompts} />
</div> </div>
<Switch bind:state={permissions.workspace.prompts} /> {#if defaultPermissions?.workspace?.prompts && !permissions.workspace.prompts}
<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>
<div class=" "> <div class="flex flex-col w-full">
<Tooltip <Tooltip
className=" flex w-full justify-between my-2 pr-2" className="flex w-full justify-between my-1"
content={$i18n.t( content={$i18n.t(
'Warning: Enabling this will allow users to upload arbitrary code on the server.' 'Warning: Enabling this will allow users to upload arbitrary code on the server.'
)} )}
@@ -212,247 +144,499 @@
</div> </div>
<Switch bind:state={permissions.workspace.tools} /> <Switch bind:state={permissions.workspace.tools} />
</Tooltip> </Tooltip>
{#if defaultPermissions?.workspace?.tools && !permissions.workspace.tools}
<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>
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850" />
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Sharing Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Sharing Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Models Public Sharing')} <div class=" self-center text-xs font-medium">
{$i18n.t('Models Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_models} />
</div> </div>
<Switch bind:state={permissions.sharing.public_models} /> {#if defaultPermissions?.sharing?.public_models && !permissions.sharing.public_models}
<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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Knowledge Public Sharing')} <div class=" self-center text-xs font-medium">
{$i18n.t('Knowledge Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_knowledge} />
</div> </div>
<Switch bind:state={permissions.sharing.public_knowledge} /> {#if defaultPermissions?.sharing?.public_knowledge && !permissions.sharing.public_knowledge}
<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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Prompts Public Sharing')} <div class=" self-center text-xs font-medium">
{$i18n.t('Prompts Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_prompts} />
</div> </div>
<Switch bind:state={permissions.sharing.public_prompts} /> {#if defaultPermissions?.sharing?.public_prompts && !permissions.sharing.public_prompts}
<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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Tools Public Sharing')} <div class=" self-center text-xs font-medium">
{$i18n.t('Tools Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_tools} />
</div> </div>
<Switch bind:state={permissions.sharing.public_tools} /> {#if defaultPermissions?.sharing?.public_tools && !permissions.sharing.public_tools}
<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">
{$i18n.t('Notes Public Sharing')}
</div>
<Switch bind:state={permissions.sharing.public_notes} />
</div>
{#if defaultPermissions?.sharing?.public_notes && !permissions.sharing.public_notes}
<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>
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850" />
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow File Upload')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
</div>
<Switch bind:state={permissions.chat.file_upload} />
</div> </div>
{#if defaultPermissions?.chat?.file_upload && !permissions.chat.file_upload}
<Switch bind:state={permissions.chat.file_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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Controls')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div> </div>
{#if defaultPermissions?.chat?.controls && !permissions.chat.controls}
<Switch bind:state={permissions.chat.controls} /> <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>
{#if permissions.chat.controls} {#if permissions.chat.controls}
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Valves')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Valves')}
</div>
<Switch bind:state={permissions.chat.valves} />
</div> </div>
{#if defaultPermissions?.chat?.valves && !permissions.chat.valves}
<Switch bind:state={permissions.chat.valves} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat System Prompt')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat System Prompt')}
</div>
<Switch bind:state={permissions.chat.system_prompt} />
</div> </div>
{#if defaultPermissions?.chat?.system_prompt && !permissions.chat.system_prompt}
<Switch bind:state={permissions.chat.system_prompt} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Params')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Params')}
</div>
<Switch bind:state={permissions.chat.params} />
</div> </div>
{#if defaultPermissions?.chat?.params && !permissions.chat.params}
<Switch bind:state={permissions.chat.params} /> <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>
{/if} {/if}
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Edit')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
</div>
<Switch bind:state={permissions.chat.edit} />
</div> </div>
{#if defaultPermissions?.chat?.edit && !permissions.chat.edit}
<Switch bind:state={permissions.chat.edit} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Delete')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
</div>
<Switch bind:state={permissions.chat.delete} />
</div> </div>
{#if defaultPermissions?.chat?.delete && !permissions.chat.delete}
<Switch bind:state={permissions.chat.delete} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Delete Messages')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Delete Messages')}
</div>
<Switch bind:state={permissions.chat.delete_message} />
</div> </div>
{#if defaultPermissions?.chat?.delete_message && !permissions.chat.delete_message}
<Switch bind:state={permissions.chat.delete_message} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Continue Response')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Continue Response')}
</div>
<Switch bind:state={permissions.chat.continue_response} />
</div> </div>
{#if defaultPermissions?.chat?.continue_response && !permissions.chat.continue_response}
<Switch bind:state={permissions.chat.continue_response} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Regenerate Response')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Regenerate Response')}
</div>
<Switch bind:state={permissions.chat.regenerate_response} />
</div> </div>
{#if defaultPermissions?.chat?.regenerate_response && !permissions.chat.regenerate_response}
<Switch bind:state={permissions.chat.regenerate_response} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Rate Response')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Rate Response')}
</div>
<Switch bind:state={permissions.chat.rate_response} />
</div> </div>
{#if defaultPermissions?.chat?.rate_response && !permissions.chat.rate_response}
<Switch bind:state={permissions.chat.rate_response} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Share')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Share')}
</div>
<Switch bind:state={permissions.chat.share} />
</div> </div>
{#if defaultPermissions?.chat?.share && !permissions.chat.share}
<Switch bind:state={permissions.chat.share} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Chat Export')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Export')}
</div>
<Switch bind:state={permissions.chat.export} />
</div> </div>
{#if defaultPermissions?.chat?.export && !permissions.chat.export}
<Switch bind:state={permissions.chat.export} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Speech to Text')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Speech to Text')}
</div>
<Switch bind:state={permissions.chat.stt} />
</div> </div>
{#if defaultPermissions?.chat?.stt && !permissions.chat.stt}
<Switch bind:state={permissions.chat.stt} /> <div>
</div> <div class="text-xs text-gray-500">
<div class=" flex w-full justify-between my-2 pr-2"> {$i18n.t('This is a default user permission and will remain enabled.')}
<div class=" self-center text-xs font-medium"> </div>
{$i18n.t('Allow Text to Speech')} </div>
</div> {/if}
<Switch bind:state={permissions.chat.tts} />
</div> </div>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Call')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Text to Speech')}
</div>
<Switch bind:state={permissions.chat.tts} />
</div> </div>
{#if defaultPermissions?.chat?.tts && !permissions.chat.tts}
<Switch bind:state={permissions.chat.call} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Multiple Models in Chat')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Call')}
</div>
<Switch bind:state={permissions.chat.call} />
</div> </div>
{#if defaultPermissions?.chat?.call && !permissions.chat.call}
<Switch bind:state={permissions.chat.multiple_models} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Allow Temporary Chat')} <div class=" self-center text-xs font-medium">
{$i18n.t('Allow Multiple Models in Chat')}
</div>
<Switch bind:state={permissions.chat.multiple_models} />
</div> </div>
{#if defaultPermissions?.chat?.multiple_models && !permissions.chat.multiple_models}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
<Switch bind:state={permissions.chat.temporary} /> <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 Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary} />
</div>
{#if defaultPermissions?.chat?.temporary && !permissions.chat.temporary}
<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>
{#if permissions.chat.temporary} {#if permissions.chat.temporary}
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Enforce Temporary Chat')} <div class=" self-center text-xs font-medium">
{$i18n.t('Enforce Temporary Chat')}
</div>
<Switch bind:state={permissions.chat.temporary_enforced} />
</div> </div>
{#if defaultPermissions?.chat?.temporary_enforced && !permissions.chat.temporary_enforced}
<Switch bind:state={permissions.chat.temporary_enforced} /> <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>
{/if} {/if}
</div> </div>
<hr class=" border-gray-100 dark:border-gray-850 my-2" /> <hr class=" border-gray-100 dark:border-gray-850" />
<div> <div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div> <div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Direct Tool Servers')} <div class=" self-center text-xs font-medium">
{$i18n.t('Direct Tool Servers')}
</div>
<Switch bind:state={permissions.features.direct_tool_servers} />
</div> </div>
{#if defaultPermissions?.features?.direct_tool_servers && !permissions.features.direct_tool_servers}
<Switch bind:state={permissions.features.direct_tool_servers} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Web Search')} <div class=" self-center text-xs font-medium">
{$i18n.t('Web Search')}
</div>
<Switch bind:state={permissions.features.web_search} />
</div> </div>
{#if defaultPermissions?.features?.web_search && !permissions.features.web_search}
<Switch bind:state={permissions.features.web_search} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Image Generation')} <div class=" self-center text-xs font-medium">
{$i18n.t('Image Generation')}
</div>
<Switch bind:state={permissions.features.image_generation} />
</div> </div>
{#if defaultPermissions?.features?.image_generation && !permissions.features.image_generation}
<Switch bind:state={permissions.features.image_generation} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Code Interpreter')} <div class=" self-center text-xs font-medium">
{$i18n.t('Code Interpreter')}
</div>
<Switch bind:state={permissions.features.code_interpreter} />
</div> </div>
{#if defaultPermissions?.features?.code_interpreter && !permissions.features.code_interpreter}
<Switch bind:state={permissions.features.code_interpreter} /> <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>
<div class=" flex w-full justify-between my-2 pr-2"> <div class="flex flex-col w-full">
<div class=" self-center text-xs font-medium"> <div class="flex w-full justify-between my-1">
{$i18n.t('Notes')} <div class=" self-center text-xs font-medium">
{$i18n.t('Notes')}
</div>
<Switch bind:state={permissions.features.notes} />
</div> </div>
{#if defaultPermissions?.features?.notes && !permissions.features.notes}
<Switch bind:state={permissions.features.notes} /> <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>
</div> </div>
</div> </div>
@@ -75,10 +75,10 @@
/> />
</div> </div>
<div class="flex w-full items-center justify-between"> <div class="flex w-full items-center justify-between overflow-hidden">
<Tooltip content={user.email} placement="top-start"> <Tooltip content={user.email} placement="top-start">
<div class="flex"> <div class="flex">
<div class=" font-medium self-center">{user.name}</div> <div class=" font-medium self-center truncate">{user.name}</div>
</div> </div>
</Tooltip> </Tooltip>
+4 -30
View File
@@ -339,30 +339,6 @@
</div> </div>
</th> </th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if orderBy === 'oauth_sub'}
<span class="font-normal"
>{#if direction === '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 text-right" /> <th scope="col" class="px-2.5 py-2 text-right" />
</tr> </tr>
</thead> </thead>
@@ -383,10 +359,10 @@
/> />
</button> </button>
</td> </td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white w-max"> <td class="px-3 py-1 font-medium text-gray-900 dark:text-white max-w-48">
<div class="flex flex-row w-max"> <div class="flex items-center">
<img <img
class=" rounded-full w-6 h-6 object-cover mr-2.5" class="rounded-full w-6 h-6 object-cover mr-2.5 flex-shrink-0"
src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) || src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') || user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
user.profile_image_url.startsWith('data:') user.profile_image_url.startsWith('data:')
@@ -395,7 +371,7 @@
alt="user" alt="user"
/> />
<div class=" font-medium self-center">{user.name}</div> <div class="font-medium truncate">{user.name}</div>
</div> </div>
</td> </td>
<td class=" px-3 py-1"> {user.email} </td> <td class=" px-3 py-1"> {user.email} </td>
@@ -408,8 +384,6 @@
{dayjs(user.created_at * 1000).format('LL')} {dayjs(user.created_at * 1000).format('LL')}
</td> </td>
<td class=" px-3 py-1"> {user.oauth_sub ?? ''} </td>
<td class="px-3 py-1 text-right"> <td class="px-3 py-1 text-right">
<div class="flex justify-end w-full"> <div class="flex justify-end w-full">
{#if $config.features.enable_admin_chat_access && user.role !== 'admin'} {#if $config.features.enable_admin_chat_access && user.role !== 'admin'}
@@ -12,6 +12,7 @@
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import UserProfileImage from '$lib/components/chat/Settings/Account/UserProfileImage.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -83,110 +84,122 @@
submitHandler(); submitHandler();
}} }}
> >
<div class=" flex items-center rounded-md px-5 py-2 w-full"> <div class=" px-5 pt-3 pb-5 w-full">
<div class=" self-center mr-5"> <div class="flex self-center w-full">
<img <div class=" self-start h-full mr-6">
src={selectedUser.profile_image_url} <UserProfileImage
class=" max-w-[55px] object-cover rounded-full" imageClassName="size-14"
alt="User profile" bind:profileImageUrl={_user.profile_image_url}
/> user={_user}
</div> />
<div>
<div class=" self-center capitalize font-semibold">{selectedUser.name}</div>
<div class="text-xs text-gray-500">
{$i18n.t('Created at')}
{dayjs(selectedUser.created_at * 1000).format('LL')}
</div> </div>
</div>
</div>
<div class=" px-5 pt-3 pb-5"> <div class=" flex-1">
<div class=" flex flex-col space-y-1.5"> <div class="overflow-hidden w-ful mb-2">
<div class="flex flex-col w-full"> <div class=" self-center capitalize font-medium truncate">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div> {selectedUser.name}
</div>
<div class="flex-1"> <div class="text-xs text-gray-500">
<select {$i18n.t('Created at')}
class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden" {dayjs(selectedUser.created_at * 1000).format('LL')}
bind:value={_user.role} </div>
disabled={_user.id == sessionUser.id}
required
>
<option value="admin">{$i18n.t('Admin')}</option>
<option value="user">{$i18n.t('User')}</option>
<option value="pending">{$i18n.t('Pending')}</option>
</select>
</div> </div>
</div>
{#if userGroups} <div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full text-sm"> {#if (userGroups ?? []).length > 0}
<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div> <div class="flex flex-col w-full text-sm">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('User Groups')}</div>
{#if userGroups.length} <div class="flex flex-wrap gap-1 my-0.5 -mx-1">
<div class="flex flex-wrap gap-1 my-0.5 -mx-1"> {#each userGroups as userGroup}
{#each userGroups as userGroup} <span
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs"> class="px-1.5 py-0.5 rounded-xl bg-gray-100 dark:bg-gray-850 text-xs"
<a
href={'/admin/users/groups?id=' + userGroup.id}
on:click|preventDefault={() =>
goto('/admin/users/groups?id=' + userGroup.id)}
> >
{userGroup.name} <a
</a> href={'/admin/users/groups?id=' + userGroup.id}
</span> on:click|preventDefault={() =>
{/each} goto('/admin/users/groups?id=' + userGroup.id)}
>
{userGroup.name}
</a>
</span>
{/each}
</div>
</div> </div>
{:else}
<span>-</span>
{/if} {/if}
</div>
{/if}
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
<div class="flex-1"> <div class="flex-1">
<input <select
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden" class="w-full dark:bg-gray-900 text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email" bind:value={_user.role}
bind:value={_user.email} disabled={_user.id == sessionUser.id}
placeholder={$i18n.t('Enter Your Email')} required
autocomplete="off" >
required <option value="admin">{$i18n.t('Admin')}</option>
/> <option value="user">{$i18n.t('User')}</option>
</div> <option value="pending">{$i18n.t('Pending')}</option>
</div> </select>
</div>
</div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1"> <div class="flex-1">
<input <input
class="w-full text-sm bg-transparent outline-hidden" class="w-full text-sm bg-transparent outline-hidden"
type="text" type="text"
bind:value={_user.name} bind:value={_user.name}
placeholder={$i18n.t('Enter Your Name')} placeholder={$i18n.t('Enter Your Name')}
autocomplete="off" autocomplete="off"
required required
/> />
</div> </div>
</div> </div>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div> <div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1"> <div class="flex-1">
<SensitiveInput <input
class="w-full text-sm bg-transparent outline-hidden" class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="password" type="email"
placeholder={$i18n.t('Enter New Password')} bind:value={_user.email}
bind:value={_user.password} placeholder={$i18n.t('Enter Your Email')}
autocomplete="new-password" autocomplete="off"
required={false} required
/> />
</div>
</div>
{#if _user?.oauth_sub}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('OAuth ID')}</div>
<div class="flex-1 text-sm break-all mb-1">
{_user.oauth_sub ?? ''}
</div>
</div>
{/if}
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
<div class="flex-1">
<SensitiveInput
class="w-full text-sm bg-transparent outline-hidden"
type="password"
placeholder={$i18n.t('Enter New Password')}
bind:value={_user.password}
autocomplete="new-password"
required={false}
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -107,7 +107,9 @@
bind:query bind:query
bind:orderBy bind:orderBy
bind:direction bind:direction
title={$i18n.t("{{user}}'s Chats", { user: user.name })} title={$i18n.t("{{user}}'s Chats", {
user: user.name.length > 32 ? `${user.name.slice(0, 32)}...` : user.name
})}
emptyPlaceholder={$i18n.t('No chats found for this user.')} emptyPlaceholder={$i18n.t('No chats found for this user.')}
shareUrl={true} shareUrl={true}
{chatList} {chatList}
+24 -10
View File
@@ -20,12 +20,14 @@
let scrollEnd = true; let scrollEnd = true;
let messagesContainerElement = null; let messagesContainerElement = null;
let chatInputElement = null;
let top = false; let top = false;
let channel = null; let channel = null;
let messages = null; let messages = null;
let replyToMessage = null;
let threadId = null; let threadId = null;
let typingUsers = []; let typingUsers = [];
@@ -141,20 +143,24 @@
return; return;
} }
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch( const res = await sendMessage(localStorage.token, id, {
(error) => { content: content,
toast.error(`${error}`); data: data,
return null; reply_to_id: replyToMessage?.id ?? null
} }).catch((error) => {
); toast.error(`${error}`);
return null;
});
if (res) { if (res) {
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight; messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
} }
replyToMessage = null;
}; };
const onChange = async () => { const onChange = async () => {
$socket?.emit('channel-events', { $socket?.emit('events:channel', {
channel_id: id, channel_id: id,
message_id: null, message_id: null,
data: { data: {
@@ -174,7 +180,7 @@
chatId.set(''); chatId.set('');
} }
$socket?.on('channel-events', channelEventHandler); $socket?.on('events:channel', channelEventHandler);
mediaQuery = window.matchMedia('(min-width: 1024px)'); mediaQuery = window.matchMedia('(min-width: 1024px)');
@@ -191,7 +197,7 @@
}); });
onDestroy(() => { onDestroy(() => {
$socket?.off('channel-events', channelEventHandler); $socket?.off('events:channel', channelEventHandler);
}); });
</script> </script>
@@ -222,8 +228,14 @@
{#key id} {#key id}
<Messages <Messages
{channel} {channel}
{messages}
{top} {top}
{messages}
{replyToMessage}
onReply={async (message) => {
replyToMessage = message;
await tick();
chatInputElement?.focus();
}}
onThread={(id) => { onThread={(id) => {
threadId = id; threadId = id;
}} }}
@@ -250,6 +262,8 @@
<div class=" pb-[1rem] px-2.5"> <div class=" pb-[1rem] px-2.5">
<MessageInput <MessageInput
id="root" id="root"
bind:chatInputElement
bind:replyToMessage
{typingUsers} {typingUsers}
userSuggestions={true} userSuggestions={true}
channelSuggestions={true} channelSuggestions={true}
+43 -11
View File
@@ -23,20 +23,23 @@
import { getSessionUser } from '$lib/apis/auths'; import { getSessionUser } from '$lib/apis/auths';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import RichTextInput from '../common/RichTextInput.svelte'; import RichTextInput from '../common/RichTextInput.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import InputMenu from './MessageInput/InputMenu.svelte';
import { uploadFile } from '$lib/apis/files';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import FileItem from '../common/FileItem.svelte'; import FileItem from '../common/FileItem.svelte';
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte'; import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import MentionList from './MessageInput/MentionList.svelte'; import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte'; import Skeleton from '../chat/Messages/Skeleton.svelte';
import XMark from '../icons/XMark.svelte';
export let placeholder = $i18n.t('Type here...'); export let placeholder = $i18n.t('Type here...');
@@ -60,6 +63,8 @@
export let userSuggestions = false; export let userSuggestions = false;
export let channelSuggestions = false; export let channelSuggestions = false;
export let replyToMessage = null;
export let typingUsersClassName = 'from-white dark:from-gray-900'; export let typingUsersClassName = 'from-white dark:from-gray-900';
let loaded = false; let loaded = false;
@@ -773,6 +778,32 @@
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100" class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'} dir={$settings?.chatDirection ?? 'auto'}
> >
{#if replyToMessage !== null}
<div class="px-3 pt-3 text-left w-full flex flex-col z-10">
<div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm">
<div class="translate-y-[0.5px]">
<span class=""
>{$i18n.t('Replying to {{NAME}}', {
NAME: replyToMessage?.meta?.model_name ?? replyToMessage.user.name
})}</span
>
</div>
</div>
<div>
<button
class="flex items-center dark:text-gray-500"
on:click={() => {
replyToMessage = null;
}}
>
<XMark />
</button>
</div>
</div>
</div>
{/if}
{#if files.length > 0} {#if files.length > 0}
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2"> <div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx} {#each files as file, fileIdx}
@@ -845,12 +876,12 @@
richText={$settings?.richTextInput ?? true} richText={$settings?.richTextInput ?? true}
showFormattingToolbar={$settings?.showFormattingToolbar ?? false} showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile || !$mobile &&
!( !(
'ontouchstart' in window || 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 navigator.msMaxTouchPoints > 0
))} )}
largeTextAsFile={$settings?.largeTextAsFile ?? false} largeTextAsFile={$settings?.largeTextAsFile ?? false}
floatingMenuPlacement={'top-start'} floatingMenuPlacement={'top-start'}
{suggestions} {suggestions}
@@ -890,6 +921,7 @@
if (e.key === 'Escape') { if (e.key === 'Escape') {
console.info('Escape'); console.info('Escape');
replyToMessage = null;
} }
}} }}
on:paste={async (e) => { on:paste={async (e) => {
+8 -1
View File
@@ -23,10 +23,12 @@
export let id = null; export let id = null;
export let channel = null; export let channel = null;
export let messages = []; export let messages = [];
export let replyToMessage = null;
export let top = false; export let top = false;
export let thread = false; export let thread = false;
export let onLoad: Function = () => {}; export let onLoad: Function = () => {};
export let onReply: Function = () => {};
export let onThread: Function = () => {}; export let onThread: Function = () => {};
let messagesLoading = false; let messagesLoading = false;
@@ -94,10 +96,12 @@
<Message <Message
{message} {message}
{thread} {thread}
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access} disabled={!channel?.write_access}
showUserProfile={messageIdx === 0 || showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id || messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id} messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id ||
message?.reply_to_message}
onDelete={() => { onDelete={() => {
messages = messages.filter((m) => m.id !== message.id); messages = messages.filter((m) => m.id !== message.id);
@@ -123,6 +127,9 @@
return null; return null;
}); });
}} }}
onReply={(message) => {
onReply(message);
}}
onThread={(id) => { onThread={(id) => {
onThread(id); onThread(id);
}} }}
@@ -13,8 +13,9 @@
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n'); const i18n = getContext<Writable<i18nType>>('i18n');
import { settings, user, shortCodesToEmojis } from '$lib/stores'; import { formatDate } from '$lib/utils';
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
@@ -32,18 +33,20 @@
import FaceSmile from '$lib/components/icons/FaceSmile.svelte'; import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte'; import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils';
import Emoji from '$lib/components/common/Emoji.svelte'; import Emoji from '$lib/components/common/Emoji.svelte';
import { t } from 'i18next';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte';
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
export let thread = false; export let thread = false;
export let replyToMessage = false;
export let disabled = false; export let disabled = false;
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
export let onEdit: Function = () => {}; export let onEdit: Function = () => {};
export let onReply: Function = () => {};
export let onThread: Function = () => {}; export let onThread: Function = () => {};
export let onReaction: Function = () => {}; export let onReaction: Function = () => {};
@@ -65,9 +68,15 @@
{#if message} {#if message}
<div <div
id="message-{message.id}"
class="flex flex-col justify-between px-5 {showUserProfile class="flex flex-col justify-between px-5 {showUserProfile
? 'pt-1.5 pb-0.5' ? 'pt-1.5 pb-0.5'
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative" : ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {replyToMessage
? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4'
: ''} {(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
$user?.id
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
: ''}"
> >
{#if !edit && !disabled} {#if !edit && !disabled}
<div <div
@@ -95,6 +104,17 @@
</Tooltip> </Tooltip>
</EmojiPicker> </EmojiPicker>
<Tooltip content={$i18n.t('Reply')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-0.5"
on:click={() => {
onReply(message);
}}
>
<ArrowUpLeftAlt className="size-5" />
</button>
</Tooltip>
{#if !thread} {#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}> <Tooltip content={$i18n.t('Reply in Thread')}>
<button <button
@@ -134,6 +154,56 @@
</div> </div>
{/if} {/if}
{#if message?.reply_to_message?.user}
<div class="relative text-xs mb-1">
<div
class="absolute h-3 w-7 left-[18px] top-2 rounded-tl-lg border-t-2 border-l-2 border-gray-300 dark:border-gray-500 z-0"
></div>
<button
class="ml-12 flex items-center space-x-2 relative z-0"
on:click={() => {
const messageElement = document.getElementById(
`message-${message.reply_to_message.id}`
);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
messageElement.classList.add('highlight');
setTimeout(() => {
messageElement.classList.remove('highlight');
}, 2000);
return;
}
}}
>
{#if message?.reply_to_message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.reply_to_message.meta.model_id}`}
alt={message.reply_to_message.meta.model_name ??
message.reply_to_message.meta.model_id}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{:else}
<img
src={message.reply_to_message.user?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={message.reply_to_message.user?.name ?? $i18n.t('Unknown User')}
class="size-4 ml-0.5 rounded-full object-cover"
/>
{/if}
<div class="shrink-0">
{message?.reply_to_message.meta?.model_name ??
message?.reply_to_message.user?.name ??
$i18n.t('Unknown User')}
</div>
<div class="italic text-sm text-gray-500 dark:text-gray-400 line-clamp-1 w-full flex-1">
<Markdown id={`${message.id}-reply-to`} content={message?.reply_to_message?.content} />
</div>
</button>
</div>
{/if}
<div <div
class=" flex w-full message-{message.id}" class=" flex w-full message-{message.id}"
id="message-{message.id}" id="message-{message.id}"
@@ -151,7 +221,7 @@
<ProfilePreview user={message.user}> <ProfilePreview user={message.user}>
<ProfileImage <ProfileImage
src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`} src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
className={'size-8 translate-y-1 ml-0.5'} className={'size-8 ml-0.5'}
/> />
</ProfilePreview> </ProfilePreview>
{/if} {/if}
@@ -348,3 +418,18 @@
</div> </div>
</div> </div>
{/if} {/if}
<style>
.highlight {
animation: highlightAnimation 2s ease-in-out;
}
@keyframes highlightAnimation {
0% {
background-color: rgba(0, 60, 255, 0.1);
}
100% {
background-color: transparent;
}
}
</style>
+21 -6
View File
@@ -22,11 +22,14 @@
let messages = null; let messages = null;
let top = false; let top = false;
let messagesContainerElement = null;
let chatInputElement = null;
let replyToMessage = null;
let typingUsers = []; let typingUsers = [];
let typingUsersTimeout = {}; let typingUsersTimeout = {};
let messagesContainerElement = null;
$: if (threadId) { $: if (threadId) {
initHandler(); initHandler();
} }
@@ -128,16 +131,19 @@
const res = await sendMessage(localStorage.token, channel.id, { const res = await sendMessage(localStorage.token, channel.id, {
parent_id: threadId, parent_id: threadId,
reply_to_id: replyToMessage?.id ?? null,
content: content, content: content,
data: data data: data
}).catch((error) => { }).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);
return null; return null;
}); });
replyToMessage = null;
}; };
const onChange = async () => { const onChange = async () => {
$socket?.emit('channel-events', { $socket?.emit('events:channel', {
channel_id: channel.id, channel_id: channel.id,
message_id: threadId, message_id: threadId,
data: { data: {
@@ -150,11 +156,11 @@
}; };
onMount(() => { onMount(() => {
$socket?.on('channel-events', channelEventHandler); $socket?.on('events:channel', channelEventHandler);
}); });
onDestroy(() => { onDestroy(() => {
$socket?.off('channel-events', channelEventHandler); $socket?.off('events:channel', channelEventHandler);
}); });
</script> </script>
@@ -180,9 +186,16 @@
<Messages <Messages
id={threadId} id={threadId}
{channel} {channel}
{messages}
{top} {top}
{messages}
{replyToMessage}
thread={true} thread={true}
onReply={async (message) => {
replyToMessage = message;
await tick();
chatInputElement?.focus();
}}
onLoad={async () => { onLoad={async () => {
const newMessages = await getChannelThreadMessages( const newMessages = await getChannelThreadMessages(
localStorage.token, localStorage.token,
@@ -207,6 +220,8 @@
<div class=" pb-[1rem] px-2.5 w-full"> <div class=" pb-[1rem] px-2.5 w-full">
<MessageInput <MessageInput
bind:replyToMessage
bind:chatInputElement
id={threadId} id={threadId}
disabled={!channel?.write_access} disabled={!channel?.write_access}
placeholder={!channel?.write_access placeholder={!channel?.write_access
+77 -25
View File
@@ -38,7 +38,8 @@
toolServers, toolServers,
functions, functions,
selectedFolder, selectedFolder,
pinnedChats pinnedChats,
showEmbeds
} from '$lib/stores'; } from '$lib/stores';
import { import {
convertMessagesToHistory, convertMessagesToHistory,
@@ -90,6 +91,7 @@
import Sidebar from '../icons/Sidebar.svelte'; import Sidebar from '../icons/Sidebar.svelte';
import { getFunctions } from '$lib/apis/functions'; import { getFunctions } from '$lib/apis/functions';
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import { updateFolderById } from '$lib/apis/folders';
export let chatIdProp = ''; export let chatIdProp = '';
@@ -218,10 +220,15 @@
} }
const saveSessionSelectedModels = () => { const saveSessionSelectedModels = () => {
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) { const selectedModelsString = JSON.stringify(selectedModels);
if (
selectedModels.length === 0 ||
(selectedModels.length === 1 && selectedModels[0] === '') ||
sessionStorage.selectedModels === selectedModelsString
) {
return; return;
} }
sessionStorage.selectedModels = JSON.stringify(selectedModels); sessionStorage.selectedModels = selectedModelsString;
console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels); console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels);
}; };
@@ -293,7 +300,7 @@
} }
}; };
const showMessage = async (message) => { const showMessage = async (message, ignoreSettings = false) => {
await tick(); await tick();
const _chatId = JSON.parse(JSON.stringify($chatId)); const _chatId = JSON.parse(JSON.stringify($chatId));
@@ -319,7 +326,7 @@
await tick(); await tick();
await tick(); await tick();
if ($settings?.scrollOnBranchChange ?? true) { if (($settings?.scrollOnBranchChange ?? true) || ignoreSettings) {
const messageElement = document.getElementById(`message-${message.id}`); const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) { if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' }); messageElement.scrollIntoView({ behavior: 'smooth' });
@@ -362,6 +369,8 @@
message.content = data.content; message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') { } else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files; message.files = data.files;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
} else if (type === 'chat:message:error') { } else if (type === 'chat:message:error') {
message.error = data.error; message.error = data.error;
} else if (type === 'chat:message:follow_ups') { } else if (type === 'chat:message:follow_ups') {
@@ -496,12 +505,33 @@
} }
}; };
const savedModelIds = async () => {
if (
$selectedFolder &&
selectedModels.filter((modelId) => modelId !== '').length > 0 &&
JSON.stringify($selectedFolder?.data?.model_ids) !== JSON.stringify(selectedModels)
) {
const res = await updateFolderById(localStorage.token, $selectedFolder.id, {
data: {
model_ids: selectedModels
}
});
}
};
$: if (selectedModels !== null) {
savedModelIds();
}
let pageSubscribe = null; let pageSubscribe = null;
let showControlsSubscribe = null;
let selectedFolderSubscribe = null;
onMount(async () => { onMount(async () => {
loading = true; loading = true;
console.log('mounted'); console.log('mounted');
window.addEventListener('message', onMessageHandler); window.addEventListener('message', onMessageHandler);
$socket?.on('chat-events', chatEventHandler); $socket?.on('events', chatEventHandler);
pageSubscribe = page.subscribe(async (p) => { pageSubscribe = page.subscribe(async (p) => {
if (p.url.pathname === '/') { if (p.url.pathname === '/') {
@@ -545,7 +575,7 @@
} catch (e) {} } catch (e) {}
} }
showControls.subscribe(async (value) => { showControlsSubscribe = showControls.subscribe(async (value) => {
if (controlPane && !$mobile) { if (controlPane && !$mobile) {
try { try {
if (value) { if (value) {
@@ -562,20 +592,36 @@
showCallOverlay.set(false); showCallOverlay.set(false);
showOverview.set(false); showOverview.set(false);
showArtifacts.set(false); showArtifacts.set(false);
showEmbeds.set(false);
}
});
selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
if (
folder?.data?.model_ids &&
JSON.stringify(selectedModels) !== JSON.stringify(folder.data.model_ids)
) {
selectedModels = folder.data.model_ids;
console.log('Set selectedModels from folder data:', selectedModels);
} }
}); });
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
chatInput?.focus(); chatInput?.focus();
chats.subscribe(() => {});
}); });
onDestroy(() => { onDestroy(() => {
pageSubscribe(); try {
chatIdUnsubscriber?.(); pageSubscribe();
window.removeEventListener('message', onMessageHandler); showControlsSubscribe();
$socket?.off('chat-events', chatEventHandler); selectedFolderSubscribe();
chatIdUnsubscriber?.();
window.removeEventListener('message', onMessageHandler);
$socket?.off('events', chatEventHandler);
} catch (e) {
console.error(e);
}
}); });
// File upload functions // File upload functions
@@ -776,6 +822,7 @@
////////////////////////// //////////////////////////
const initNewChat = async () => { const initNewChat = async () => {
console.log('initNewChat');
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) { if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true); await temporaryChatEnabled.set(true);
} }
@@ -826,17 +873,22 @@
$models.map((m) => m.id).includes(modelId) $models.map((m) => m.id).includes(modelId)
); );
} else { } else {
if (sessionStorage.selectedModels) { if ($selectedFolder?.data?.model_ids) {
selectedModels = JSON.parse(sessionStorage.selectedModels); selectedModels = $selectedFolder?.data?.model_ids;
sessionStorage.removeItem('selectedModels');
} else { } else {
if ($settings?.models) { if (sessionStorage.selectedModels) {
selectedModels = $settings?.models; selectedModels = JSON.parse(sessionStorage.selectedModels);
} else if ($config?.default_models) { sessionStorage.removeItem('selectedModels');
console.log($config?.default_models.split(',') ?? ''); } else {
selectedModels = $config?.default_models.split(','); if ($settings?.models) {
selectedModels = $settings?.models;
} else if ($config?.default_models) {
console.log($config?.default_models.split(',') ?? '');
selectedModels = $config?.default_models.split(',');
}
} }
} }
selectedModels = selectedModels.filter((modelId) => availableModels.includes(modelId)); selectedModels = selectedModels.filter((modelId) => availableModels.includes(modelId));
} }
@@ -1495,7 +1547,7 @@
chatFiles.push( chatFiles.push(
..._files.filter((item) => ..._files.filter((item) =>
['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type) ['doc', 'text', 'file', 'note', 'chat', 'folder', 'collection'].includes(item.type)
) )
); );
chatFiles = chatFiles.filter( chatFiles = chatFiles.filter(
@@ -2155,8 +2207,8 @@
selectedFolder.set(null); selectedFolder.set(null);
} else { } else {
_chatId = 'local'; _chatId = `local:${$socket?.id}`; // Use socket id for temporary chat
await chatId.set('local'); await chatId.set(_chatId);
} }
await tick(); await tick();
@@ -2230,7 +2282,7 @@
<svelte:head> <svelte:head>
<title> <title>
{$chatTitle {$settings.showChatTitleInTab !== false && $chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}` ? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`} : `${$WEBUI_NAME}`}
</title> </title>
+137 -123
View File
@@ -4,15 +4,20 @@
import { Pane, PaneResizer } from 'paneforge'; import { Pane, PaneResizer } from 'paneforge';
import { onDestroy, onMount, tick } from 'svelte'; import { onDestroy, onMount, tick } from 'svelte';
import { mobile, showControls, showCallOverlay, showOverview, showArtifacts } from '$lib/stores'; import {
mobile,
showControls,
showCallOverlay,
showOverview,
showArtifacts,
showEmbeds
} from '$lib/stores';
import Modal from '../common/Modal.svelte';
import Controls from './Controls/Controls.svelte'; import Controls from './Controls/Controls.svelte';
import CallOverlay from './MessageInput/CallOverlay.svelte'; import CallOverlay from './MessageInput/CallOverlay.svelte';
import Drawer from '../common/Drawer.svelte'; import Drawer from '../common/Drawer.svelte';
import Overview from './Overview.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Artifacts from './Artifacts.svelte'; import Artifacts from './Artifacts.svelte';
import Embeds from './ChatControls/Embeds.svelte';
export let history; export let history;
export let models = []; export let models = [];
@@ -134,6 +139,7 @@
showControls.set(false); showControls.set(false);
showOverview.set(false); showOverview.set(false);
showArtifacts.set(false); showArtifacts.set(false);
showEmbeds.set(false);
if ($showCallOverlay) { if ($showCallOverlay) {
showCallOverlay.set(false); showCallOverlay.set(false);
@@ -145,24 +151,113 @@
} }
</script> </script>
<SvelteFlowProvider> {#if !largeScreen}
{#if !largeScreen} {#if $showControls}
{#if $showControls} <Drawer
<Drawer show={$showControls}
show={$showControls} onClose={() => {
onClose={() => { showControls.set(false);
showControls.set(false); }}
}} >
<div
class=" {$showCallOverlay || $showOverview || $showArtifacts || $showEmbeds
? ' h-screen w-full'
: 'px-4 py-3'} h-full"
> >
{#if $showCallOverlay}
<div
class=" h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
>
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close={() => {
showControls.set(false);
}}
/>
</div>
{:else if $showEmbeds}
<Embeds />
{:else if $showArtifacts}
<Artifacts {history} />
{:else if $showOverview}
{#await import('./Overview.svelte') then { default: Overview }}
<Overview
{history}
onNodeClick={(e) => {
const node = e.node;
showMessage(node.data.message, true);
}}
onClose={() => {
showControls.set(false);
}}
/>
{/await}
{:else}
<Controls
on:close={() => {
showControls.set(false);
}}
{models}
bind:chatFiles
bind:params
/>
{/if}
</div>
</Drawer>
{/if}
{:else}
<!-- if $showControls -->
{#if $showControls}
<PaneResizer
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</PaneResizer>
{/if}
<Pane
bind:pane
defaultSize={0}
onResize={(size) => {
if ($showControls && pane.isExpanded()) {
if (size < minSize) {
pane.resize(minSize);
}
if (size < minSize) {
localStorage.chatControlsSize = 0;
} else {
// save the size in pixels to localStorage
const container = document.getElementById('chat-container');
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
}
}
}}
onCollapse={() => {
showControls.set(false);
}}
collapsible={true}
class=" z-10 bg-white dark:bg-gray-850"
>
{#if $showControls}
<div class="flex max-h-full min-h-full">
<div <div
class=" {$showCallOverlay || $showOverview || $showArtifacts class="w-full {($showOverview || $showArtifacts || $showEmbeds) && !$showCallOverlay
? ' h-screen w-full' ? ' '
: 'px-6 py-4'} h-full" : 'px-4 py-3 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
id="controls-container"
> >
{#if $showCallOverlay} {#if $showCallOverlay}
<div <div class="w-full h-full flex justify-center">
class=" h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
>
<CallOverlay <CallOverlay
bind:files bind:files
{submitPrompt} {submitPrompt}
@@ -175,18 +270,29 @@
}} }}
/> />
</div> </div>
{:else if $showEmbeds}
<Embeds overlay={dragged} />
{:else if $showArtifacts} {:else if $showArtifacts}
<Artifacts {history} /> <Artifacts {history} overlay={dragged} />
{:else if $showOverview} {:else if $showOverview}
<Overview {#await import('./Overview.svelte') then { default: Overview }}
{history} <Overview
on:nodeclick={(e) => { {history}
showMessage(e.detail.node.data.message); onNodeClick={(e) => {
}} const node = e.node;
on:close={() => { if (node?.data?.message?.favorite) {
showControls.set(false); history.messages[node.data.message.id].favorite = true;
}} } else {
/> history.messages[node.data.message.id].favorite = null;
}
showMessage(node.data.message, true);
}}
onClose={() => {
showControls.set(false);
}}
/>
{/await}
{:else} {:else}
<Controls <Controls
on:close={() => { on:close={() => {
@@ -198,99 +304,7 @@
/> />
{/if} {/if}
</div> </div>
</Drawer> </div>
{/if} {/if}
{:else} </Pane>
<!-- if $showControls --> {/if}
{#if $showControls}
<PaneResizer
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</PaneResizer>
{/if}
<Pane
bind:pane
defaultSize={0}
onResize={(size) => {
if ($showControls && pane.isExpanded()) {
if (size < minSize) {
pane.resize(minSize);
}
if (size < minSize) {
localStorage.chatControlsSize = 0;
} else {
// save the size in pixels to localStorage
const container = document.getElementById('chat-container');
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
}
}
}}
onCollapse={() => {
showControls.set(false);
}}
collapsible={true}
class=" z-10 bg-white dark:bg-gray-850"
>
{#if $showControls}
<div class="flex max-h-full min-h-full">
<div
class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
? ' '
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
id="controls-container"
>
{#if $showCallOverlay}
<div class="w-full h-full flex justify-center">
<CallOverlay
bind:files
{submitPrompt}
{stopResponse}
{modelId}
{chatId}
{eventTarget}
on:close={() => {
showControls.set(false);
}}
/>
</div>
{:else if $showArtifacts}
<Artifacts {history} overlay={dragged} />
{:else if $showOverview}
<Overview
{history}
on:nodeclick={(e) => {
if (e.detail.node.data.message.favorite) {
history.messages[e.detail.node.data.message.id].favorite = true;
} else {
history.messages[e.detail.node.data.message.id].favorite = null;
}
showMessage(e.detail.node.data.message);
}}
on:close={() => {
showControls.set(false);
}}
/>
{:else}
<Controls
on:close={() => {
showControls.set(false);
}}
{models}
bind:chatFiles
bind:params
/>
{/if}
</div>
</div>
{/if}
</Pane>
{/if}
</SvelteFlowProvider>
@@ -0,0 +1,46 @@
<script>
import { embed, showControls, showEmbeds } from '$lib/stores';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let overlay = false;
</script>
{#if $embed}
<div class="h-full w-full">
<div
class="pointer-events-auto z-20 flex justify-between items-center py-3 px-2 font-primar text-gray-900 dark:text-white"
>
<div class="flex-1 flex items-center justify-between pl-2">
<a
class="flex items-center space-x-2 hover:underline"
href={$embed?.url}
target="_blank"
rel="noopener noreferrer"
>
{$embed?.title ?? 'Embedded Content'}
</a>
</div>
<button
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
on:click={() => {
showControls.set(false);
showEmbeds.set(false);
embed.set(null);
}}
>
<XMark className="size-3.5 text-gray-900 dark:text-white" />
</button>
</div>
<div class=" w-full h-full relative">
{#if overlay}
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
{/if}
<FullHeightIframe src={$embed?.url} iframeClassName="w-full h-full" />
</div>
</div>
{/if}
@@ -13,6 +13,7 @@
import LightBulb from '$lib/components/icons/LightBulb.svelte'; import LightBulb from '$lib/components/icons/LightBulb.svelte';
import Markdown from '../Messages/Markdown.svelte'; import Markdown from '../Messages/Markdown.svelte';
import Skeleton from '../Messages/Skeleton.svelte'; import Skeleton from '../Messages/Skeleton.svelte';
import { chatId, models, socket } from '$lib/stores';
export let id = ''; export let id = '';
export let messageId = ''; export let messageId = '';
@@ -118,6 +119,9 @@
let res; let res;
[res, controller] = await chatCompletion(localStorage.token, { [res, controller] = await chatCompletion(localStorage.token, {
model: model, model: model,
model_item: $models.find((m) => m.id === model),
session_id: $socket?.id,
chat_id: $chatId,
messages: [ messages: [
...messages, ...messages,
{ {
@@ -246,11 +250,11 @@
{#if responseContent === null} {#if responseContent === null}
{#if !floatingInput} {#if !floatingInput}
<div <div
class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl" class="flex flex-row shrink-0 p-0.5 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl shadow-xl border border-gray-100 dark:border-gray-800"
> >
{#each actions as action} {#each actions as action}
<button <button
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit" 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 () => { on:click={async () => {
selectedText = window.getSelection().toString(); selectedText = window.getSelection().toString();
selectedAction = action; selectedAction = action;
@@ -280,7 +284,7 @@
</div> </div>
{:else} {:else}
<div <div
class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border border-gray-100 dark:border-gray-850 w-72 rounded-full shadow-xl" class="py-1 flex dark:text-gray-100 bg-white dark:bg-gray-850 border border-gray-100 dark:border-gray-800 w-72 rounded-full shadow-xl"
> >
<input <input
type="text" type="text"
@@ -295,7 +299,7 @@
}} }}
/> />
<div class="ml-1 mr-2"> <div class="ml-1 mr-1">
<button <button
class="{floatingInputValue !== '' class="{floatingInputValue !== ''
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 ' ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
@@ -321,19 +325,22 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="bg-white dark:bg-gray-850 dark:text-gray-100 rounded-xl shadow-xl w-80 max-w-full"> <div
class="bg-white dark:bg-gray-850 dark:text-gray-100 rounded-3xl shadow-xl w-80 max-w-full border border-gray-100 dark:border-gray-800"
>
<div <div
class="bg-gray-50/50 dark:bg-gray-800 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full" class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-3xl px-3.5 pt-3 w-full"
> >
<div class="font-medium"> <div class="font-medium">
<Markdown id={`${id}-float-prompt`} {content} /> <Markdown id={`${id}-float-prompt`} {content} />
</div> </div>
</div> </div>
<div <div class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-4xl w-full">
class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full" <div
> class=" max-h-80 overflow-y-auto w-full markdown-prose-xs px-3.5 py-3"
<div class=" max-h-80 overflow-y-auto w-full markdown-prose-xs" id="response-container"> id="response-container"
>
{#if !responseContent || responseContent?.trim() === ''} {#if !responseContent || responseContent?.trim() === ''}
<Skeleton size="sm" /> <Skeleton size="sm" />
{:else} {:else}
+58 -20
View File
@@ -78,6 +78,8 @@
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions'; import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte'; import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -112,6 +114,10 @@
let inputVariables = {}; let inputVariables = {};
let inputVariableValues = {}; let inputVariableValues = {};
let showValvesModal = false;
let selectedValvesType = 'tool'; // 'tool' or 'function'
let selectedValvesItemId = null;
$: onChange({ $: onChange({
prompt, prompt,
files: files files: files
@@ -879,8 +885,6 @@
}) })
} }
]; ];
console.log(suggestions);
loaded = true; loaded = true;
window.setTimeout(() => { window.setTimeout(() => {
@@ -932,6 +936,16 @@
onSave={inputVariablesModalCallback} onSave={inputVariablesModalCallback}
/> />
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
{#if loaded} {#if loaded}
<div class="w-full font-primary"> <div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center"> <div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
@@ -1185,12 +1199,12 @@
floatingMenuPlacement={'top-start'} floatingMenuPlacement={'top-start'}
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false} insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile || !$mobile &&
!( !(
'ontouchstart' in window || 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 navigator.msMaxTouchPoints > 0
))} )}
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey} largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
autocomplete={$config?.features?.enable_autocomplete_generation && autocomplete={$config?.features?.enable_autocomplete_generation &&
@@ -1449,6 +1463,12 @@
bind:webSearchEnabled bind:webSearchEnabled
bind:imageGenerationEnabled bind:imageGenerationEnabled
bind:codeInterpreterEnabled bind:codeInterpreterEnabled
onShowValves={(e) => {
const { type, id } = e;
selectedValvesType = type;
selectedValvesItemId = id;
showValvesModal = true;
}}
onClose={async () => { onClose={async () => {
await tick(); await tick();
@@ -1465,6 +1485,24 @@
</IntegrationsMenu> </IntegrationsMenu>
{/if} {/if}
{#if selectedModelIds.length === 1 && $models.find((m) => m.id === selectedModelIds[0])?.has_user_valves}
<div class="ml-1 flex gap-1.5">
<Tooltip content={$i18n.t('Valves')} placement="top">
<button
id="model-valves-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
on:click={() => {
selectedValvesType = 'function';
selectedValvesItemId = selectedModelIds[0]?.split('.')[0];
showValvesModal = true;
}}
>
<Knobs className="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
</div>
{/if}
<div class="ml-1 flex gap-1.5"> <div class="ml-1 flex gap-1.5">
{#if (selectedToolIds ?? []).length > 0} {#if (selectedToolIds ?? []).length > 0}
<Tooltip <Tooltip
@@ -1500,11 +1538,11 @@
); );
}} }}
type="button" type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {selectedFilterIds.includes( class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
filterId filterId
) )
? 'text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20' ? 'text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} capitalize" : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} capitalize"
> >
{#if filter?.icon} {#if filter?.icon}
<div class="size-4 items-center flex justify-center"> <div class="size-4 items-center flex justify-center">
@@ -1533,10 +1571,10 @@
<button <button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)} on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
type="button" type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {webSearchEnabled || class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {webSearchEnabled ||
($settings?.webSearch ?? false) === 'always' ($settings?.webSearch ?? false) === 'always'
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20' ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-600/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}" : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
> >
<GlobeAlt className="size-4" strokeWidth="1.75" /> <GlobeAlt className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block"> <div class="hidden group-hover:block">
@@ -1552,9 +1590,9 @@
on:click|preventDefault={() => on:click|preventDefault={() =>
(imageGenerationEnabled = !imageGenerationEnabled)} (imageGenerationEnabled = !imageGenerationEnabled)}
type="button" type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {imageGenerationEnabled class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {imageGenerationEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20' ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '}" : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '}"
> >
<Photo className="size-4" strokeWidth="1.75" /> <Photo className="size-4" strokeWidth="1.75" />
<div class="hidden group-hover:block"> <div class="hidden group-hover:block">
@@ -1574,9 +1612,9 @@
on:click|preventDefault={() => on:click|preventDefault={() =>
(codeInterpreterEnabled = !codeInterpreterEnabled)} (codeInterpreterEnabled = !codeInterpreterEnabled)}
type="button" type="button"
class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden hover:bg-gray-50 dark:hover:bg-gray-800 {codeInterpreterEnabled class=" group p-[7px] flex gap-1.5 items-center text-sm transition-colors duration-300 max-w-full overflow-hidden {codeInterpreterEnabled
? ' text-sky-500 dark:text-sky-300 bg-sky-50 dark:bg-sky-400/10 border border-sky-200/40 dark:border-sky-500/20' ? ' text-sky-500 dark:text-sky-300 bg-sky-50 hover:bg-sky-100 dark:bg-sky-400/10 dark:hover:bg-sky-700/10 border border-sky-200/40 dark:border-sky-500/20'
: 'bg-transparent text-gray-600 dark:text-gray-300 '} {($settings?.highContrastMode ?? : 'bg-transparent text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 '} {($settings?.highContrastMode ??
false) false)
? 'm-1' ? 'm-1'
: 'focus:outline-hidden rounded-full'}" : 'focus:outline-hidden rounded-full'}"
@@ -13,6 +13,8 @@
import Database from '$lib/components/icons/Database.svelte'; import Database from '$lib/components/icons/Database.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Youtube from '$lib/components/icons/Youtube.svelte'; import Youtube from '$lib/components/icons/Youtube.svelte';
import { folders } from '$lib/stores';
import Folder from '$lib/components/icons/Folder.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -144,14 +146,25 @@
] ]
: []; : [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map( let folder_items = $folders.map((folder) => ({
(item) => { ...folder,
return { type: 'folder',
...item, description: $i18n.t('Folder'),
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {}) title: folder.name
}; }));
}
); items = [
...folder_items,
...collections,
...collection_files,
...legacy_collections,
...legacy_documents
].map((item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
@@ -213,6 +226,8 @@
> >
{#if item?.type === 'collection'} {#if item?.type === 'collection'}
<Database className="size-4" /> <Database className="size-4" />
{:else if item?.type === 'folder'}
<Folder className="size-4" />
{:else} {:else}
<DocumentPage className="size-4" /> <DocumentPage className="size-4" />
{/if} {/if}
@@ -6,8 +6,10 @@
import { config, user, tools as _tools, mobile, settings, toolServers } from '$lib/stores'; import { config, user, tools as _tools, mobile, settings, toolServers } from '$lib/stores';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { getTools } from '$lib/apis/tools'; import { getTools } from '$lib/apis/tools';
import Knobs from '$lib/components/icons/Knobs.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
@@ -19,9 +21,6 @@
import Terminal from '$lib/components/icons/Terminal.svelte'; import Terminal from '$lib/components/icons/Terminal.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ValvesModal from '$lib/components/workspace/common/ValvesModal.svelte';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { partition } from 'd3-hierarchy';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -41,16 +40,12 @@
export let showCodeInterpreterButton = false; export let showCodeInterpreterButton = false;
export let codeInterpreterEnabled = false; export let codeInterpreterEnabled = false;
export let onShowValves: Function;
export let onClose: Function; export let onClose: Function;
let show = false; let show = false;
let tab = ''; let tab = '';
let showValvesModal = false;
let selectedValvesType = 'tool';
let selectedValvesItemId = null;
let tools = null; let tools = null;
$: if (show) { $: if (show) {
@@ -96,16 +91,6 @@
}; };
</script> </script>
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
<Dropdown <Dropdown
bind:show bind:show
on:change={(e) => { on:change={(e) => {
@@ -192,6 +177,27 @@
</div> </div>
</div> </div>
{#if filter?.has_user_valves}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
onShowValves({
type: 'function',
id: filter.id
});
}}
>
<Knobs />
</button>
</Tooltip>
</div>
{/if}
<div class=" shrink-0"> <div class=" shrink-0">
<Switch <Switch
state={selectedFilterIds.includes(filter.id)} state={selectedFilterIds.includes(filter.id)}
@@ -324,7 +330,7 @@
{#each Object.keys(tools) as toolId} {#each Object.keys(tools) as toolId}
<button <button
class="relative 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="relative 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"
on:click={(e) => { on:click={async (e) => {
if (!(tools[toolId]?.authenticated ?? true)) { if (!(tools[toolId]?.authenticated ?? true)) {
e.preventDefault(); e.preventDefault();
@@ -332,15 +338,24 @@
let serverId = parts?.at(-1) ?? toolId; let serverId = parts?.at(-1) ?? toolId;
const authUrl = getOAuthClientAuthorizationUrl(serverId, 'mcp'); const authUrl = getOAuthClientAuthorizationUrl(serverId, 'mcp');
window.open(authUrl, '_blank', 'noopener'); window.open(authUrl, '_self', 'noopener');
} else { } else {
tools[toolId].enabled = !tools[toolId].enabled; tools[toolId].enabled = !tools[toolId].enabled;
const state = tools[toolId].enabled;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
} }
}} }}
> >
{#if !(tools[toolId]?.authenticated ?? true)} {#if !(tools[toolId]?.authenticated ?? true)}
<!-- make it slighly darker and not clickable --> <!-- make it slighly darker and not clickable -->
<div class="absolute inset-0 opacity-50 rounded-xl cursor-not-allowed z-10" /> <div class="absolute inset-0 opacity-50 rounded-xl cursor-pointer z-10" />
{/if} {/if}
<div class="flex-1 truncate"> <div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center"> <div class="flex flex-1 gap-2 items-center">
@@ -364,48 +379,20 @@
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
selectedValvesType = 'tool'; onShowValves({
selectedValvesItemId = toolId; type: 'tool',
showValvesModal = true; id: toolId
});
}} }}
> >
<svg <Knobs />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
{/if} {/if}
<div class=" shrink-0"> <div class=" shrink-0">
<Switch <Switch state={tools[toolId].enabled} />
state={tools[toolId].enabled}
on:change={async (e) => {
const state = e.detail;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
}}
/>
</div> </div>
</button> </button>
{/each} {/each}
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import CitationModal from './Citations/CitationModal.svelte'; import CitationModal from './Citations/CitationModal.svelte';
import { embed, showControls, showEmbeds } from '$lib/stores';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let id = ''; export let id = '';
export let sources = []; export let sources = [];
export let readOnly = false;
let citations = []; let citations = [];
let showPercentage = false; let showPercentage = false;
@@ -21,8 +23,30 @@
export const showSourceModal = (sourceIdx) => { export const showSourceModal = (sourceIdx) => {
if (citations[sourceIdx]) { if (citations[sourceIdx]) {
console.log('Showing citation modal for:', citations[sourceIdx]); console.log('Showing citation modal for:', citations[sourceIdx]);
selectedCitation = citations[sourceIdx];
showCitationModal = true; if (citations[sourceIdx]?.source?.embed_url) {
const embedUrl = citations[sourceIdx].source.embed_url;
if (embedUrl) {
if (readOnly) {
// Open in new tab if readOnly
window.open(embedUrl, '_blank');
return;
} else {
showControls.set(true);
showEmbeds.set(true);
embed.set({
title: citations[sourceIdx]?.source?.name || 'Embedded Content',
url: embedUrl
});
}
} else {
selectedCitation = citations[sourceIdx];
showCitationModal = true;
}
} else {
selectedCitation = citations[sourceIdx];
showCitationModal = true;
}
} }
}; };
@@ -6,7 +6,7 @@
import PyodideWorker from '$lib/workers/pyodide.worker?worker'; import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import { executeCode } from '$lib/apis/utils'; import { executeCode } from '$lib/apis/utils';
import { copyToClipboard, renderMermaidDiagram } from '$lib/utils'; import { copyToClipboard, renderMermaidDiagram, renderVegaVisualization } from '$lib/utils';
import 'highlight.js/styles/github-dark.min.css'; import 'highlight.js/styles/github-dark.min.css';
@@ -55,6 +55,7 @@
let _token = null; let _token = null;
let mermaidHtml = null; let mermaidHtml = null;
let vegaHtml = null;
let highlightedCode = null; let highlightedCode = null;
let executing = false; let executing = false;
@@ -325,7 +326,26 @@
const render = async () => { const render = async () => {
onUpdate(token); onUpdate(token);
if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) { if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
mermaidHtml = await renderMermaidDiagram(code); try {
mermaidHtml = await renderMermaidDiagram(code);
} catch (error) {
console.error('Failed to render mermaid diagram:', error);
const errorMsg = error instanceof Error ? error.message : String(error);
toast.error($i18n.t('Failed to render diagram') + `: ${errorMsg}`);
mermaidHtml = null;
}
} else if (
(lang === 'vega' || lang === 'vega-lite') &&
(token?.raw ?? '').slice(-4).includes('```')
) {
try {
vegaHtml = await renderVegaVisualization(code);
} catch (error) {
console.error('Failed to render Vega visualization:', error);
const errorMsg = error instanceof Error ? error.message : String(error);
toast.error($i18n.t('Failed to render diagram') + `: ${errorMsg}`);
vegaHtml = null;
}
} }
}; };
@@ -397,6 +417,16 @@
{:else} {:else}
<pre class="mermaid">{code}</pre> <pre class="mermaid">{code}</pre>
{/if} {/if}
{:else if lang === 'vega' || lang === 'vega-lite'}
{#if vegaHtml}
<SvgPanZoom
className="rounded-3xl max-h-fit overflow-hidden"
svg={vegaHtml}
content={_token.text}
/>
{:else}
<pre class="vega">{code}</pre>
{/if}
{:else} {:else}
<div <div
class="absolute left-0 right-0 py-2.5 pr-3 text-text-300 pl-4.5 text-xs font-medium dark:text-white" class="absolute left-0 right-0 py-2.5 pr-3 text-text-300 pl-4.5 text-xs font-medium dark:text-white"
@@ -10,6 +10,7 @@
settings, settings,
showArtifacts, showArtifacts,
showControls, showControls,
showEmbeds,
showOverview showOverview
} from '$lib/stores'; } from '$lib/stores';
import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte'; import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
@@ -194,6 +195,7 @@
await showControls.set(true); await showControls.set(true);
await showArtifacts.set(true); await showArtifacts.set(true);
await showOverview.set(false); await showOverview.set(false);
await showEmbeds.set(false);
}} }}
/> />
</div> </div>
@@ -209,7 +211,7 @@
: (selectedModels ?? []).length > 0 : (selectedModels ?? []).length > 0
? selectedModels.at(0) ? selectedModels.at(0)
: model?.id} : model?.id}
messages={createMessagesList(history, id)} messages={createMessagesList(history, messageId)}
onAdd={({ modelId, parentId, messages }) => { onAdd={({ modelId, parentId, messages }) => {
console.log(modelId, parentId, messages); console.log(modelId, parentId, messages);
onAddMessages({ modelId, parentId, messages }); onAddMessages({ modelId, parentId, messages });
@@ -53,6 +53,7 @@
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte'; import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte';
import StatusHistory from './ResponseMessage/StatusHistory.svelte'; import StatusHistory from './ResponseMessage/StatusHistory.svelte';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
interface MessageType { interface MessageType {
id: string; id: string;
@@ -575,8 +576,6 @@
await tick(); await tick();
if (buttonsContainerElement) { if (buttonsContainerElement) {
console.log(buttonsContainerElement);
buttonsContainerElement.addEventListener('wheel', function (event) { buttonsContainerElement.addEventListener('wheel', function (event) {
if (buttonsContainerElement.scrollWidth <= buttonsContainerElement.clientWidth) { if (buttonsContainerElement.scrollWidth <= buttonsContainerElement.clientWidth) {
// If the container is not scrollable, horizontal scroll // If the container is not scrollable, horizontal scroll
@@ -676,6 +675,22 @@
</div> </div>
{/if} {/if}
{#if message?.embeds && message.embeds.length > 0}
<div class="my-1 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message.embeds as embed, idx}
<div class="my-2 w-full" id={`${message.id}-embeds-${idx}`}>
<FullHeightIframe
src={embed}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{/if}
{#if edit === true} {#if edit === true}
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2"> <div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
<textarea <textarea
@@ -794,6 +809,7 @@
bind:this={citationsElement} bind:this={citationsElement}
id={message?.id} id={message?.id}
sources={message?.sources ?? message?.citations} sources={message?.sources ?? message?.citations}
{readOnly}
/> />
{/if} {/if}
@@ -29,7 +29,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-850 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition" class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="start" align="start"
@@ -68,17 +68,20 @@
}} }}
> >
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<div class="pt-3 px-1"> {#if history.length > 1}
<span class="relative flex size-1.5 rounded-full justify-center items-center"> <div class="pt-3 px-1">
{#if status?.done === false} <span class="relative flex size-1.5 rounded-full justify-center items-center">
{#if status?.done === false}
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-500 dark:bg-gray-300 opacity-75"
></span>
{/if}
<span <span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-500 dark:bg-gray-300 opacity-75" class="relative inline-flex size-1.5 rounded-full bg-gray-500 dark:bg-gray-300"
></span> ></span>
{/if} </span>
<span class="relative inline-flex size-1.5 rounded-full bg-gray-500 dark:bg-gray-300" </div>
></span> {/if}
</span>
</div>
<StatusItem {status} /> <StatusItem {status} />
</div> </div>
</button> </button>
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more