Compare commits

...
374 Commits
Author SHA1 Message Date
Tim Jaeryang Baek 23b9354cf6 Merge pull request #12932 from open-webui/dev
0.6.6
2025-05-05 17:58:52 +04:00
Timothy Jaeryang Baek 4fa2e136bf refac: styling 2025-05-05 17:58:40 +04:00
Timothy Jaeryang Baek 512d475ecc refac: api usage 2025-05-05 17:43:51 +04:00
Timothy Jaeryang Baek 56cefde161 doc: changelog 2025-05-05 17:13:09 +04:00
Timothy Jaeryang Baek f78811b253 chore: bump 2025-05-05 16:49:21 +04:00
Timothy Jaeryang Baek e6ca7a414b refac 2025-05-05 16:41:51 +04:00
Tim Jaeryang Baek 367f6fb8f1 Merge pull request #13498 from omahs/main
fix: typos
2025-05-05 16:27:33 +04:00
omahs 37e0313c83 Merge branch 'dev' into main 2025-05-05 14:26:40 +02:00
Timothy Jaeryang Baek 267889809d chore: format 2025-05-05 16:19:05 +04:00
Timothy Jaeryang Baek 33c206c15c enh: include profile image during user import 2025-05-05 16:17:20 +04:00
omahs 863f227be9 fix: typos 2025-05-05 14:14:59 +02:00
Timothy Jaeryang Baek abdde10b74 refac 2025-05-05 16:12:41 +04:00
Timothy Jaeryang Baek 26e9cd0b4b enh: user role update confirm dialog 2025-05-05 16:10:36 +04:00
Timothy Jaeryang Baek 457a0f318a refac 2025-05-05 16:05:39 +04:00
Timothy Jaeryang Baek 3771d27a52 refac 2025-05-05 15:50:09 +04:00
Timothy Jaeryang Baek d16ff54895 refac 2025-05-05 15:46:55 +04:00
Timothy Jaeryang Baek de0f52b686 doc: readme 2025-05-05 15:42:14 +04:00
Timothy Jaeryang Baek eec7a2afa1 refac 2025-05-05 11:25:00 +04:00
Timothy Jaeryang Baek 9e1400f055 enh: generate title 2025-05-05 10:36:01 +04:00
Timothy Jaeryang Baek aa374823ce refac 2025-05-05 10:16:34 +04:00
Timothy Jaeryang Baek a20a920ca4 refac: styling 2025-05-05 09:41:25 +04:00
Timothy Jaeryang Baek 827fd6606c refac 2025-05-05 09:36:34 +04:00
Timothy Jaeryang Baek deb1f6b6d7 feat: ai enhanced notes 2025-05-05 09:22:54 +04:00
Tim Jaeryang Baek 0f1df43bb2 Merge pull request #13485 from TiancongLx/dev
i18n: update zh-TW
2025-05-05 08:25:24 +04:00
Tiancong Li 62a5b14642 i18n: update zh-TW 2025-05-05 10:15:20 +08:00
Timothy Jaeryang Baek 9faf2d9849 refac: styling 2025-05-05 01:51:51 +04:00
Timothy Jaeryang Baek f24d0085c2 refac 2025-05-05 01:45:55 +04:00
Timothy Jaeryang Baek 463ac99e39 enh: note versioning 2025-05-05 01:35:43 +04:00
Timothy Jaeryang Baek e8b23ef0c1 refac 2025-05-05 00:05:06 +04:00
Timothy Jaeryang Baek a6a7ce6b68 enh: import md notes 2025-05-04 23:04:15 +04:00
Timothy Jaeryang Baek 8514669053 refac 2025-05-04 18:59:36 +04:00
Timothy Jaeryang Baek 291834ecbe chore: format 2025-05-04 18:32:56 +04:00
Timothy Jaeryang Baek 360b5b303a enh: note editor 2025-05-04 18:32:21 +04:00
Timothy Jaeryang Baek d8e9b000e3 enh: upload audio 2025-05-04 18:26:33 +04:00
Timothy Jaeryang Baek 62366ad020 refac: audio recording 2025-05-04 18:17:35 +04:00
Timothy Jaeryang Baek 2946f5fcce refac: styling 2025-05-04 17:33:15 +04:00
Timothy Jaeryang Baek f2cf21d64c enh: file item display audio 2025-05-04 17:29:59 +04:00
Timothy Jaeryang Baek 6475ae3b11 refac 2025-05-04 17:23:42 +04:00
Timothy Jaeryang Baek 84a05bec7b enh: notes user group permission 2025-05-04 17:22:51 +04:00
Timothy Jaeryang Baek f716968009 enh: delete notes 2025-05-04 17:01:12 +04:00
Timothy Jaeryang Baek 18149483f2 refac: note pdf export 2025-05-04 16:59:41 +04:00
Timothy Jaeryang Baek 175bbe27e9 refac 2025-05-04 16:53:48 +04:00
Tim Jaeryang Baek d1eaa9d4ee Merge pull request #13478 from SadmL/dev
[i18n] Russian locale update
2025-05-04 02:24:38 -07:00
SadmL 9b133a2245 misspell fix 2025-05-04 12:05:21 +03:00
SadmL 0e51b75654 Russian locale update 2025-05-04 11:59:13 +03:00
Timothy Jaeryang Baek 1cf9843468 refac 2025-05-04 12:02:10 +04:00
Timothy Jaeryang Baek dd42a10aa1 refac 2025-05-04 12:01:20 +04:00
Tim Jaeryang Baek 60c0aa36a2 Merge pull request #13470 from TiancongLx/dev
i18n: update zh-TW
2025-05-04 00:41:02 -07:00
Tiancong Li a7cbfeec9f i18n: update zh-TW 2025-05-04 11:58:20 +08:00
Timothy Jaeryang Baek c5c98591f9 refac 2025-05-04 00:20:30 +04:00
Timothy Jaeryang Baek 59e52add63 chore: bump 2025-05-04 00:15:46 +04:00
Timothy Jaeryang Baek 9c4a931d22 feat: capture display media audio 2025-05-04 00:14:19 +04:00
Timothy Jaeryang Baek 4cfb99248d chore: format 2025-05-03 23:48:24 +04:00
Timothy Jaeryang Baek aefd5d9557 chore: format 2025-05-03 23:48:12 +04:00
Timothy Jaeryang Baek 0f14f34dbe refac 2025-05-03 23:47:41 +04:00
Timothy Jaeryang Baek d74809853d enh: note files 2025-05-03 23:29:19 +04:00
Timothy Jaeryang Baek 23da63b06a refac: styling 2025-05-03 23:08:28 +04:00
Timothy Jaeryang Baek 116b61c53b refac 2025-05-03 23:06:46 +04:00
Timothy Jaeryang Baek f43cb87556 refac 2025-05-03 23:05:25 +04:00
Tim Jaeryang Baek 1af45435eb Merge pull request #13459 from open-webui/notes
feat: notes
2025-05-03 11:54:04 -07:00
Timothy Jaeryang Baek 4acd278624 refac 2025-05-03 22:53:23 +04:00
Timothy Jaeryang Baek 1d82a1c8c4 feat: note editor 2025-05-03 21:18:19 +04:00
Timothy Jaeryang Baek 52d32e8bf2 refac: styling 2025-05-03 21:02:21 +04:00
Timothy Jaeryang Baek a74297ed47 refac: styling 2025-05-03 18:56:27 +04:00
Timothy Jaeryang Baek 7de6112c5b feat: note list ui 2025-05-03 18:52:13 +04:00
Tim Jaeryang Baek bfacab5a0c Merge pull request #13447 from athoik/dev
feat(ocr): add support for Docling OCR engine and language configuration
2025-05-03 07:18:05 -07:00
Tim Jaeryang Baek bbc1ae0942 Merge pull request #13441 from salbahra/bug/fix-prefix-for-embedding-model
fix: Incorrect model reference with Ollama proxy when prefixes are used
2025-05-03 07:17:40 -07:00
Timothy Jaeryang Baek 7fee84c06e feat: notes 2025-05-03 18:16:32 +04:00
Athanasios Oikonomou 437804a2f8 fix: update validation logic for Docling OCR engine and language requirements
Both Docling OCR Engine and Language(s) must be provided or both left empty.
2025-05-03 08:12:58 +03:00
Athanasios Oikonomou 4801430ad2 fix: correct condition for Docling OCR engine and language validation
Both must have value or both must be empty.
2025-05-03 08:02:00 +03:00
Athanasios Oikonomou 657162e96d feat(ocr): add support for Docling OCR engine and language configuration
This commit adds support for configuring the OCR engine and language(s) for Docling.
Configuration can be set via the environment variables `DOCLING_OCR_ENGINE` and `DOCLING_OCR_LANG`, or through the UI.

Fixes #13133
2025-05-03 00:32:06 +03:00
Samer Albahra cafba3eaff Fix model reference with Ollama proxy when prefixes are used 2025-05-02 11:30:59 -04:00
Tim Jaeryang Baek 7d184c3a14 Merge pull request #13085 from ayan4m1/fix/tika-image-ocr
fix: pass extractInlineImages header to Tika if PDF_EXTRACT_IMAGES is true
2025-05-02 03:47:51 -07:00
Timothy Jaeryang Baek 754f631a07 feat: OAUTH_BLOCKED_GROUPS support 2025-05-02 14:47:02 +04:00
Timothy Jaeryang Baek c1ddb00377 refac: styling 2025-05-02 14:19:53 +04:00
Timothy Jaeryang Baek 74f160b849 refac 2025-05-02 14:16:11 +04:00
Tim Jaeryang Baek 11d599c55d Merge pull request #13111 from tth37/perf_multi_thread_web_searching
perf: Concurrent processing for web search queries
2025-05-02 03:15:49 -07:00
Tim Jaeryang Baek 7b014e44ee Merge pull request #13376 from Thaniel94/add-whisper-language-constraint
feat: Added WHISPER_LANGUAGE env variable
2025-05-02 03:08:00 -07:00
Timothy Jaeryang Baek 4a0b2a3afc chore: bump 2025-05-02 14:06:57 +04:00
Tim Jaeryang Baek 2d5643fb17 Merge pull request #13405 from yak1ex/fix_multiple_actions_in_an_action_module_do_not_work_as_intended
fix: Multiple actions in an action module do not work as intended.
2025-05-02 03:06:05 -07:00
Tim Jaeryang Baek d522914774 Merge pull request #13413 from hurxxxx/fix/eslint-version-mismatch
fix: eslint version mismatch
2025-05-02 03:05:30 -07:00
Tim Jaeryang Baek 3013ce2d1a Merge pull request #13415 from TiancongLx/dev
i18n: update zh-TW
2025-05-02 03:04:39 -07:00
Timothy Jaeryang Baek 6d81eef425 refac: reasoning detection 2025-05-02 14:04:12 +04:00
Timothy Jaeryang Baek c258272709 refac 2025-05-02 13:37:41 +04:00
Tim Jaeryang Baek a0981e01a0 Merge pull request #13417 from flefevre/dev-i8n-french
i18n french
2025-05-02 02:28:11 -07:00
hurxxxx 10243b0fac chore: bump @typescript-eslint/eslint-plugin to 8.31.1 2025-05-02 09:44:51 +09:00
nathaniel cc14aacaaa Improvements to parsing of WHISPER_LANGUAGE environment variable (Setting as empty string now equivalent to unsetting/removing WHISPER_LANGUAGE). Resolved crash caused when unset 2025-05-01 22:03:49 +01:00
nathaniel ef7acfbf3d WHISPER_LANGUAGE no longer a "PersistentConfig" variable (Was not appropriate with how WHISPER_LANGUAGE is currently configured). 2025-05-01 21:33:57 +01:00
flefevre fb8a7e8549 Merge branch 'dev' into dev-i8n-french 2025-05-01 20:17:50 +02:00
Francois LE FEVRE 754a3c4cb9 last elements to translate into french 2025-05-01 20:07:56 +02:00
Tiancong Li 725761ad6b i18n: update zh-TW 2025-05-01 22:56:47 +08:00
Francois LE FEVRE 57cb030a24 seconde review on french translation 2025-05-01 15:59:52 +02:00
Francois LE FEVRE 12626107b6 first elements translated in french 2025-05-01 15:46:58 +02:00
hurxxxx 72f985aba7 fix: align eslint plugin and parser versions 2025-05-01 22:39:29 +09:00
Timothy Jaeryang Baek 5d5e351937 feat: notes scaffolding 2025-05-01 16:39:36 +04:00
Tim Jaeryang Baek 2062d3434e Merge pull request #13385 from open-webui/dependabot/npm_and_yarn/dev/typescript-eslint/parser-8.31.1
build(deps-dev): bump @typescript-eslint/parser from 6.21.0 to 8.31.1
2025-05-01 04:59:00 -07:00
Timothy Jaeryang Baek f26cafd984 chore: langchain bump 2025-05-01 15:58:45 +04:00
Tim Jaeryang Baek aca82d64a0 Merge pull request #13391 from open-webui/dependabot/pip/backend/dev/rapidocr-onnxruntime-1.4.4
build(deps): bump rapidocr-onnxruntime from 1.3.24 to 1.4.4 in /backend
2025-05-01 04:56:32 -07:00
Tim Jaeryang Baek 3715919a6b Merge pull request #13389 from open-webui/dependabot/npm_and_yarn/dev/codemirror-lang-hcl-0.1.0
build(deps): bump codemirror-lang-hcl from 0.0.0-beta.2 to 0.1.0
2025-05-01 04:55:52 -07:00
Tim Jaeryang Baek 0e3b9f9da1 Merge pull request #13388 from open-webui/dependabot/npm_and_yarn/dev/prosemirror-schema-list-1.5.1
build(deps): bump prosemirror-schema-list from 1.4.1 to 1.5.1
2025-05-01 04:55:45 -07:00
Tim Jaeryang Baek f10e7ac81b Merge pull request #13387 from open-webui/dependabot/npm_and_yarn/dev/tiptap/core-2.11.9
build(deps): bump @tiptap/core from 2.10.0 to 2.11.9
2025-05-01 04:55:36 -07:00
Tim Jaeryang Baek 61580e9490 Merge pull request #13404 from NoMoreFood/dev
fix: Use SHA256 For Query Result Computation
2025-05-01 04:55:16 -07:00
Tim Jaeryang Baek 6f2d61dc4f Merge pull request #13398 from hurxxxx/i18n-korean
i18n: Update Korean translation.json and parsed messages
2025-05-01 04:48:56 -07:00
Bryan Berns 32257089f9 Use SHA256 For Query Result Computation 2025-05-01 03:56:20 -04:00
Yak! b5cfa2b992 fix: Multiple actions in an action module do not work as intended. 2025-05-01 16:41:06 +09:00
dependabot[bot] c826717343 build(deps): bump @tiptap/core from 2.10.0 to 2.11.9
Bumps [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) from 2.10.0 to 2.11.9.
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/@tiptap/core@2.11.9/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/@tiptap/core@2.11.9/packages/core)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 05:44:35 +00:00
Tim Jaeryang Baek 35a64b55a1 Merge pull request #13386 from open-webui/dependabot/npm_and_yarn/dev/tiptap/extension-code-block-lowlight-2.11.9
build(deps): bump @tiptap/extension-code-block-lowlight from 2.10.0 to 2.11.9
2025-04-30 22:43:30 -07:00
Tim Jaeryang Baek 9695e08411 Merge pull request #13392 from open-webui/dependabot/pip/backend/dev/pytest-approx-eq-8.3.5
build(deps): update pytest requirement from ~=8.3.2 to ~=8.3.5 in /backend
2025-04-30 22:43:09 -07:00
Tim Jaeryang Baek c6d76acf76 Merge pull request #13393 from open-webui/dependabot/pip/backend/dev/elasticsearch-9.0.1
build(deps): bump elasticsearch from 8.17.1 to 9.0.1 in /backend
2025-04-30 22:42:55 -07:00
Tim Jaeryang Baek b6a76dc026 Merge pull request #13394 from open-webui/dependabot/pip/backend/dev/loguru-0.7.3
build(deps): bump loguru from 0.7.2 to 0.7.3 in /backend
2025-04-30 22:42:45 -07:00
Timothy Jaeryang Baek 079af5fffe fix: send webhook notification when user is not active 2025-05-01 09:41:07 +04:00
Tim Jaeryang Baek e57f2c928a Merge pull request #13379 from NoMoreFood/dev
feat: Azure STT Allow Base URL & Max Speaker Setting
2025-04-30 22:26:18 -07:00
hurxxxx 513d5c8bf5 Fix typos 2025-05-01 13:21:15 +09:00
hurxxxx d72e41c563 Consolidate duplicate messages into a single one 2025-05-01 13:16:47 +09:00
hurxxxx 1ca5d532b8 chore(i18n): update parsed i18n data and Korean translations 2025-05-01 13:00:27 +09:00
hurxxxx f46c7d53b7 update Korean translation 2025-05-01 12:55:07 +09:00
hurxxxx a7cbf62c68 update Korean translation 2025-05-01 12:43:19 +09:00
dependabot[bot] 4eaaeb1888 build(deps): bump loguru from 0.7.2 to 0.7.3 in /backend
Bumps [loguru](https://github.com/Delgan/loguru) from 0.7.2 to 0.7.3.
- [Release notes](https://github.com/Delgan/loguru/releases)
- [Changelog](https://github.com/Delgan/loguru/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/Delgan/loguru/compare/0.7.2...0.7.3)

---
updated-dependencies:
- dependency-name: loguru
  dependency-version: 0.7.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:54:59 +00:00
dependabot[bot] 753d530cd0 build(deps): bump elasticsearch from 8.17.1 to 9.0.1 in /backend
Bumps [elasticsearch](https://github.com/elastic/elasticsearch-py) from 8.17.1 to 9.0.1.
- [Release notes](https://github.com/elastic/elasticsearch-py/releases)
- [Commits](https://github.com/elastic/elasticsearch-py/compare/v8.17.1...v9.0.1)

---
updated-dependencies:
- dependency-name: elasticsearch
  dependency-version: 9.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:54:56 +00:00
dependabot[bot] 10f55d39d2 build(deps): update pytest requirement in /backend
Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.2...8.3.5)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 8.3.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:54:52 +00:00
dependabot[bot] 0b84863d76 build(deps): bump rapidocr-onnxruntime from 1.3.24 to 1.4.4 in /backend
Bumps [rapidocr-onnxruntime](https://github.com/RapidAI/RapidOCR) from 1.3.24 to 1.4.4.
- [Release notes](https://github.com/RapidAI/RapidOCR/releases)
- [Commits](https://github.com/RapidAI/RapidOCR/compare/v1.3.24...v1.4.4)

---
updated-dependencies:
- dependency-name: rapidocr-onnxruntime
  dependency-version: 1.4.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:54:49 +00:00
dependabot[bot] efc163fede build(deps): bump codemirror-lang-hcl from 0.0.0-beta.2 to 0.1.0
Bumps codemirror-lang-hcl from 0.0.0-beta.2 to 0.1.0.

---
updated-dependencies:
- dependency-name: codemirror-lang-hcl
  dependency-version: 0.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:53:31 +00:00
dependabot[bot] dc5107a473 build(deps): bump prosemirror-schema-list from 1.4.1 to 1.5.1
Bumps [prosemirror-schema-list](https://github.com/prosemirror/prosemirror-schema-list) from 1.4.1 to 1.5.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-schema-list/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-schema-list/compare/1.4.1...1.5.1)

---
updated-dependencies:
- dependency-name: prosemirror-schema-list
  dependency-version: 1.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:53:26 +00:00
dependabot[bot] c5fd40d951 build(deps): bump @tiptap/extension-code-block-lowlight
Bumps [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) from 2.10.0 to 2.11.9.
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/@tiptap/extension-code-block-lowlight@2.11.9/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/@tiptap/extension-code-block-lowlight@2.11.9/packages/extension-code-block-lowlight)

---
updated-dependencies:
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:53:05 +00:00
dependabot[bot] 317032aa5c build(deps-dev): bump @typescript-eslint/parser from 6.21.0 to 8.31.1
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.21.0 to 8.31.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.31.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.31.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-01 02:52:50 +00:00
Tim Jaeryang Baek f3b38e9777 Merge pull request #13372 from therealmichaelberna/main
**fix** convert_function_to_pydantic_model returning empty descriptions
2025-04-30 13:36:22 -07:00
Tim Jaeryang Baek 336d20fa33 Merge pull request #13364 from NovoNordisk-OpenSource/websocket-behind-auth
refactor: hiding websocket initalization behind authentication
2025-04-30 13:35:27 -07:00
Timothy Jaeryang Baek dadfa5f043 refac: HTML_FILE_ID handling 2025-05-01 00:32:29 +04:00
Timothy Jaeryang Baek e52e45d613 refac: collapsible 2025-04-30 22:26:07 +04:00
Michael Berna 4a6b964a51 Fixed convert_function_to_pydantic_model
convert_function_to_pydantic_model was returning empty function descriptions. I have renamed the variables more precisely which helps to avoid confusion and also resolved this issue which was caused by a variable naming conflict.
2025-04-30 14:22:38 -04:00
Timothy Jaeryang Baek 41904f21e4 refac 2025-04-30 19:41:16 +04:00
Timothy Jaeryang Baek ef2aeb7c0e fix: only allow admin uploaded html to be rendered as html 2025-04-30 19:34:53 +04:00
shcodenick 4c81b6ebc5 hiding websocket initalization behind authentication 2025-04-30 15:05:57 +02:00
Timothy Jaeryang Baek 783d409b1d refac 2025-04-30 16:58:38 +04:00
Timothy Jaeryang Baek c1fa989cb8 refac: styling 2025-04-30 16:54:51 +04:00
Bryan Berns 6c8a9d000e Azure STT Allow Base URL & Max Speaker Setting 2025-04-30 08:51:01 -04:00
Timothy Jaeryang Baek f9d238e850 refac: user list sub-standard code 2025-04-30 16:49:41 +04:00
Tim Jaeryang Baek db06a925fe Merge pull request #13267 from AnapatChaiwongse/dev
feat: implement pagination for /admin/users
2025-04-30 05:04:14 -07:00
Timothy Jaeryang Baek eb68b21e0f refac 2025-04-30 16:01:23 +04:00
Tim Jaeryang Baek e822984162 Merge pull request #13358 from feddersen-group/fix/load_ext_dependencies_on_startup
fix: install external requirements of tools and functions to prevent deactivation
2025-04-30 04:59:56 -07:00
Alexander Grimm e80ed32aeb fix: install external requirements of admin functions and tools on startups 2025-04-30 11:14:38 +00:00
Tim Jaeryang Baek c9d3bb35c3 Merge pull request #13342 from feddersen-group/feat/embedding_dim
feat: allow pgvector to use large embedding models
2025-04-30 02:41:26 -07:00
Alexander Grimm da9966aca1 ~ truncate vectors for pgvector if too big 2025-04-30 05:35:17 +00:00
Tim Jaeryang Baek 4ee5dd58b7 Merge pull request #13177 from tth37/fix_firecrawl_loader_default_mode
fix: FireCrawlLoader default mode to scrape
2025-04-29 08:39:06 -07:00
Tim Jaeryang Baek e87f2669fa Merge pull request #13191 from tth37/feat_firecrawl_search_engine
feat: Add Firecrawl search engine
2025-04-29 08:38:28 -07:00
Tim Jaeryang Baek 390263742c Merge pull request #13273 from spammenotinoz/dev
**FIX** Update Knowledge ReIndexing to continue upon missing\corrupt collections
2025-04-29 08:37:55 -07:00
Tim Jaeryang Baek 8f4be09dd2 Merge pull request #13315 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-04-29 08:35:28 -07:00
Tim Jaeryang Baek 7b863465a9 Merge pull request #13311 from stephen304/yacy-support
feat: Yacy search support
2025-04-29 08:35:10 -07:00
Timothy Jaeryang Baek 6ae2d2c4ea refac: gpt-image-1 support 2025-04-29 19:34:00 +04:00
Timothy Jaeryang Baek addf44b21f refac 2025-04-29 19:27:54 +04:00
Aleix Dorca bc88a9bc8b Update Catalan translation.json 2025-04-29 07:04:16 +02:00
Tim Jaeryang Baek f7f3ae7cc9 Merge pull request #13306 from tarmst/remove-blocking-websocket-awaits
Perf: Chat Websocket Sequential Awaits
2025-04-28 11:17:50 -07:00
Tim Jaeryang Baek 5a1c4dbe1b Merge pull request #13279 from panda44312/patch-14
i18n: zh-cn
2025-04-28 11:17:24 -07:00
Panda 63e4d1ea41 i18n: fix 2025-04-28 18:22:36 +02:00
tth37 cf2d7de873 refac: Implicit asyncio create_task 2025-04-29 00:20:55 +08:00
tarmst c9a9e05bb4 Fix websocket sequential awaits 2025-04-28 15:17:34 +00:00
Timothy Jaeryang Baek 3ac74a4fcb refac: styling 2025-04-28 18:21:48 +04:00
Timothy Jaeryang Baek 63fe426a52 chore: format 2025-04-28 17:46:05 +04:00
Timothy Jaeryang Baek ee8de1ee0b refac: disable jwt display for non-admin users 2025-04-28 17:45:17 +04:00
Timothy Jaeryang Baek 791c19acf7 enh: unicode char support for user import 2025-04-28 17:26:19 +04:00
Tim Jaeryang Baek a9999833d2 Merge pull request #13185 from Chayud-Ac/feature-thai-i18n
fix(i18n): improve Thai translations
2025-04-28 06:16:20 -07:00
Tim Jaeryang Baek c3da9f3026 Merge pull request #13246 from jmtatsch/fix-video-treated-as-text
Fix uploading video files
2025-04-28 06:15:40 -07:00
Tim Jaeryang Baek 2f5abf7374 Merge pull request #13187 from Mister-Hope/patch-1
feat: update default CUDA version to 12.8, close #13186
2025-04-28 06:15:01 -07:00
Tim Jaeryang Baek dedc45183f Merge pull request #13258 from choket/dev
Fix: Fixed a bug that caused user notification webhooks to never be sent
2025-04-28 06:14:24 -07:00
Tim Jaeryang Baek 6d59a5f809 Merge pull request #13253 from noexcs/dev
fix: The value of the 'Access-Control-Allow-Origin' header in the res…
2025-04-28 06:12:53 -07:00
Timothy Jaeryang Baek 10855cf5ed refac 2025-04-28 17:11:28 +04:00
Timothy Jaeryang Baek 9398c843d3 refac 2025-04-28 17:08:07 +04:00
Timothy Jaeryang Baek 45ad6776de fix: token cookie issue 2025-04-28 17:04:13 +04:00
Tim Jaeryang Baek fb1c101dc6 Merge pull request #13269 from jarrod-lowe/s3-tagging
feat: Add tags to S3 uploaded files
2025-04-28 05:57:08 -07:00
Tim Jaeryang Baek a0273aaea8 Merge pull request #13271 from schulti/patch-1
Update de-DE translation.json
2025-04-28 05:56:22 -07:00
Tim Jaeryang Baek 146a08c68f Merge branch 'dev' into patch-1 2025-04-28 05:49:50 -07:00
Timothy Jaeryang Baek 7d0a78a43a refac: aiohttp trust_env=True 2025-04-28 16:47:34 +04:00
Timothy Jaeryang Baek 9bfb4ae838 fix: ollama proxy "format" 2025-04-28 16:45:23 +04:00
Panda ec496de593 i18n: zh-cn 2025-04-28 11:05:06 +02:00
spammenotinoz 12fd7c8cb9 Update knowledge.py
Improve Error Handling ReIndexing Knowledge Database
2025-04-28 16:39:20 +10:00
Stefan Schultheis, OE1SCS d9186e138b Update de-DE translation.json
added a few translations and corrected some typos
2025-04-28 08:21:21 +02:00
Jarrod Lowe 141810039f Add tags to S3 uploaded files 2025-04-28 16:56:56 +12:00
KaeOhhhh 2112067054 Support pagination in /admin/users API
Update Users.svelte

Update index.ts

Update users.py

Update users.py

Update UserList.svelte
2025-04-28 11:29:42 +07:00
root 8a9de0a62f Fix bug that never triggers user notification webhooks 2025-04-27 21:31:34 +02:00
noexcs 4b9b24ae78 fix: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. 2025-04-27 21:29:13 +08:00
jmtatsch 056a042ef2 Update files.py
fix formatting
2025-04-27 11:18:27 +02:00
jmtatsch c51a91583e Dont attempt to infer charset for video files 2025-04-27 10:59:35 +02:00
nathaniel 1efa708f83 Added WHISPER_LANGUAGE env variable. If set to a country's ISO2, constrains Whisper's stt to that language. Detects language as normal if unset 2025-04-27 05:58:06 +01:00
Stephen Smith ea16426a8d Remove unused kwargs in yacy, update comments. 2025-04-27 00:41:46 -04:00
Stephen Smith f9b9217e98 Set Yacy search to text 2025-04-26 23:13:31 -04:00
Stephen Smith e6d43d70f3 Don't request nav and pass count to Yacy 2025-04-26 23:08:16 -04:00
Stephen Smith 240d91d38d Add yacy config for user/pass, automatically add yacy json api path 2025-04-26 22:28:30 -04:00
Stephen Smith 0f73b96616 first pass at yacy support copied from searxng 2025-04-26 14:07:13 -04:00
Timothy Jaeryang Baek 1349bc47b1 fix: chat message input ui styling 2025-04-25 14:21:58 +09:00
tth37 92dbeb1939 feat: Add Firecrawl search engine 2025-04-24 14:57:28 +08:00
Mister-Hope 5f69424215 Update Dockerfile 2025-04-24 13:59:57 +08:00
Chayud 86b9f252c6 fix(i18n): improve Thai translations 2025-04-24 10:35:02 +07:00
Timothy Jaeryang Baek 0b5514f376 refac 2025-04-24 03:16:46 +09:00
Timothy Jaeryang Baek 28ec3069de enh: exp token check 2025-04-24 03:12:22 +09:00
tth37 8f7195ceda fix: FireCrawlLoader default mode to scrape 2025-04-24 01:17:35 +08:00
Tim Jaeryang Baek 91e758f3ec Merge pull request #13165 from feddersen-group/perf/parallel_knowledge_search
perf: all knowledge searches in parallel in non-hybrid mode
2025-04-23 10:01:06 -07:00
Tim Jaeryang Baek d0a1a0f16b Merge pull request #13119 from jarrod-lowe/suggestions
feat: Set default prompt suggestions from environment variable DEFAULT_PROMPT_SUGGESTIONS
2025-04-23 09:55:57 -07:00
Timothy Jaeryang BaekandDrZoidberg09 732d7aee70 enh: sentence transformers env vars
Co-Authored-By: DrZoidberg09 <96449693+drzoidberg09@users.noreply.github.com>
2025-04-24 01:55:18 +09:00
Timothy Jaeryang Baek 3ec6652f99 chore: dep bump 2025-04-24 01:48:07 +09:00
Tim Jaeryang Baek cad9b292bf Merge pull request #13175 from PeterDaveHello/Improve-zh-TW-locale
i18n: improve zh-TW Traditional Chinese locale
2025-04-23 09:41:23 -07:00
Timothy Jaeryang Baek 09874ab83d fix: FireCrawlLoader 2025-04-24 01:40:34 +09:00
Peter Dave Hello c798adb04c i18n: improve zh-TW Traditional Chinese locale 2025-04-24 00:37:15 +08:00
Timothy Jaeryang Baek 7baca2b483 fix: ollama url model download issue 2025-04-24 01:26:19 +09:00
Tim Jaeryang Baek 2b10b378b6 Merge pull request #13170 from TiancongLx/dev
i18n: update zh-TW
2025-04-23 08:22:27 -07:00
Tim Jaeryang Baek 2652b91f5a Merge pull request #13166 from oleguldberg/update-danish-translation
i18n - Add to Danish Translation
2025-04-23 08:22:17 -07:00
Tiancong Li afdba443f4 i18n: update zh-TW 2025-04-23 21:28:46 +08:00
Ole Guldberg ba4f399ace Merge branch 'update-danish-translation' of https://github.com/oleguldberg/open-webui into update-danish-translation 2025-04-23 14:51:01 +02:00
Ole Guldberg 176ff56e85 Merge branch 'update-danish-translation' of https://github.com/oleguldberg/open-webui into update-danish-translation 2025-04-23 12:30:19 +02:00
Ole Guldberg f2765bee72 More Danish Translation
i18n - add to Danish translation. Made agains -dev.
2025-04-23 12:25:32 +02:00
Ole Guldberg 497a7b4ded More Danish Translation
i18n - add to Danish translation. Made agains -dev.
2025-04-23 12:06:17 +02:00
Alexander Grimm d182155fac ~ call knowledge searches in parallel in non-hybrid mode 2025-04-23 09:20:51 +00:00
Timothy Jaeryang Baek 5030041683 chore: format 2025-04-23 16:05:15 +09:00
Tim Jaeryang Baek faa3cac0e4 Merge pull request #13107 from tth37/fix_tavily_max_results
fix: `max_results` in Tavily search handler
2025-04-22 23:47:36 -07:00
Tim Jaeryang Baek d3e516934c Merge pull request #13098 from athoik/dev
feat: Add abstract base class for vector database integration
2025-04-22 23:24:08 -07:00
Timothy Jaeryang Baek 2f7b5acdf8 enh: chat share & export permissions 2025-04-23 14:43:33 +09:00
Tim Jaeryang Baek 7937ed3ee1 Merge pull request #13144 from PeterDaveHello/FixI18nInMultiResponseMessages
i18n: Add missing i18n locale for "Merged Response" string
2025-04-22 20:38:37 -07:00
Tim Jaeryang Baek f0a129482e Merge pull request #13145 from PeterDaveHello/Update-zh-TW-locale
i18n: improve zh-TW Traditional Chinese locale
2025-04-22 20:37:47 -07:00
Ole Guldberg 0e0ee3ca9c More Danish Translation 2025-04-22 21:32:21 +02:00
Ole Guldberg 26a2187e6e More Danish translation 2025-04-22 21:30:05 +02:00
Ole Guldberg 2b4e946f70 More danish 2025-04-22 21:10:51 +02:00
Ole Guldberg 17a9d11435 More dansh 2025-04-22 21:00:36 +02:00
Ole Guldberg a21ac3cc25 More danish 2025-04-22 20:54:33 +02:00
Peter Dave Hello 4eb6985c1b i18n: improve zh-TW Traditional Chinese locale 2025-04-23 02:03:07 +08:00
Peter Dave Hello 5f81755b86 i18n: Add missing i18n locale for "Merged Response" string 2025-04-23 02:01:18 +08:00
Ole Guldberg 4340431753 More danish translation 2025-04-22 18:10:43 +02:00
Timothy Jaeryang Baek f2314596ba enh: failed login attempts audit log 2025-04-23 00:06:44 +09:00
Timothy Jaeryang Baek 43efff0fe6 refac 2025-04-22 23:22:50 +09:00
Ole Guldberg 4e8cd688bf beginning of update 2025-04-22 16:05:13 +02:00
Jarrod Lowe af5efe1580 Set default prompt suggestions from environment variable 2025-04-22 11:39:01 +12:00
tth37 5b9c1de3d1 fix: Translatable 'Searching the web' 2025-04-22 01:24:30 +08:00
tth37 4b451b984b perf: Multi-thread web searching 2025-04-21 22:18:05 +08:00
tth37 bc315bd530 fix: max_results in Tavily search api 2025-04-21 20:59:47 +08:00
Athanasios Oikonomou 1e291aff25 feat: Add abstract base class for vector database integration
- Created `VectorDBBase` as an abstract base class to standardize vector database operations.
- Added required methods for common vector database operations: `has_collection`, `delete_collection`, `insert`, `upsert`, `search`, `query`, `get`, `delete`, `reset`.
- The base class can now be extended by any vector database implementation (e.g., Qdrant, Pinecone) to ensure a consistent API across different database systems.
2025-04-21 08:27:27 +03:00
Tim Jaeryang Baek 913f8a15f9 Merge pull request #13073 from athoik/dev
feat(vector-db): add support for Pinecone client
2025-04-20 14:42:36 -07:00
Tim Jaeryang Baek 7f85436a94 Merge pull request #13089 from izumi0004/fix/tool_calls
fix: handling tool calls in streamed responses
2025-04-20 14:08:09 -07:00
izumi0004 99ce85f20f fix: handling tool calls in streamed responses 2025-04-21 00:29:52 +08:00
ayan4m1 039dec6820 fix: pass header to Tika if PDF_EXTRACT_IMAGES is true 2025-04-20 17:36:40 +02:00
Athanasios Oikonomou e000c56ef7 feat(vector-db): add support for Pinecone client
Adds Pinecone as a supported vector database option.

- Implements `PineconeClient` with support for common operations: `add`, `query`, `delete`, `reset`.
- Emulates namespace support using metadata filtering (`collection_name` prefix).
- Dynamically configures Pinecone via the following env vars:
  - `PINECONE_API_KEY`
  - `PINECONE_ENVIRONMENT`
  - `PINECONE_INDEX_NAME`
  - `PINECONE_DIMENSION`
  - `PINECONE_METRIC`
  - `PINECONE_CLOUD`
- Integrates cleanly with the vector DB abstraction layer.
- Includes markdown documentation under `docs/getting-started/env-configuration.md`.

BREAKING CHANGE: None
2025-04-20 11:08:51 +03:00
Tim Jaeryang Baek af02708b4c Merge pull request #13072 from grandtele/fix/openai_o_series_handler
* fix: Refactor openai handler to support all o-series models universally
2025-04-20 00:42:39 -07:00
grand 5a8e417590 * refactor openai handler to support all o-series models universally 2025-04-20 09:14:27 +02:00
Timothy Jaeryang Baek 1a6e4c2948 refac 2025-04-19 23:40:31 -07:00
Timothy Jaeryang Baek 6fd082d55f enh: html token (allow yt embed) 2025-04-19 23:38:08 -07:00
Timothy Jaeryang Baek f4de3b4c45 enh: notificationSoundAlways option 2025-04-19 23:13:19 -07:00
Tim Jaeryang Baek 8fcad1df4e Merge pull request #13062 from open-webui/cla
chore: cla
2025-04-19 11:33:02 -07:00
Tim Jaeryang Baek 08a35417f2 Merge branch 'dev' into cla 2025-04-19 11:32:49 -07:00
Timothy Jaeryang Baek f7906790e1 refac 2025-04-19 11:32:16 -07:00
Timothy Jaeryang Baek 852d9dcbe9 chore: cla 2025-04-19 11:30:59 -07:00
Timothy Jaeryang Baek 48637893b1 fix: scroll on branch change w/ multi response chat 2025-04-19 04:09:46 -07:00
Timothy Jaeryang Baek 463d7fb628 refac: openapi to tool spec 2025-04-19 03:46:06 -07:00
Timothy Jaeryang Baek 332f8579d7 doc: readme 2025-04-19 02:55:02 -07:00
Timothy Jaeryang Baek ad8c8da438 refac 2025-04-19 02:47:48 -07:00
Timothy Jaeryang Baek f0447b24ab refac: wording 2025-04-19 01:51:40 -07:00
Timothy Jaeryang Baek 63a887151f chore: pr template 2025-04-19 00:47:52 -07:00
Timothy Jaeryang Baek 0aea894e2d Create CONTRIBUTOR_LICENSE_AGREEMENT.md 2025-04-19 00:41:08 -07:00
Timothy Jaeryang Baek 8507c47459 refac: WEBUI_AUTH_SIGNOUT_REDIRECT_URL 2025-04-18 23:58:37 -07:00
Tim Jaeryang Baek 221303eec5 Merge pull request #12592 from jarrod-lowe/main
feat: Redirect on sign out
2025-04-18 23:56:38 -07:00
Tim Jaeryang Baek 87844a8042 Merge pull request #12822 from tth37/feat_external_search_loader
feat: Support for Self-Hosted/External Web Search/Loader Engines
2025-04-18 23:51:27 -07:00
Timothy Jaeryang Baek 1c18282d19 fix 2025-04-18 18:21:49 -07:00
Tim Jaeryang Baek 6101067ea4 Merge pull request #12974 from holmlund/fix/prompts-international
fix: improve international character handling in prompt commands
2025-04-18 18:19:12 -07:00
Tim Jaeryang Baek d2f3c82fd6 Merge pull request #13031 from gaby/anyio-workers
feat: Add support for configuring FastAPI/AnyIO Thread Pool
2025-04-18 17:53:35 -07:00
Tim Jaeryang Baek 27936014b0 Merge pull request #13039 from taylorwilsdon/jit_oauth_groups
feat: Just-in-Time Group Creation for OAuth Groups
2025-04-18 17:53:04 -07:00
Timothy Jaeryang Baek 8b9527d887 refac 2025-04-18 17:14:09 -07:00
Timothy Jaeryang Baek a5476ef7d8 refac 2025-04-18 17:10:44 -07:00
Juan Calderon-Perez 62f87c2e38 Use default AnyIO pool size 2025-04-18 19:21:54 -04:00
Timothy Jaeryang Baek 60d84a3aae chore: license "branding" clause 2025-04-18 14:49:20 -07:00
Tim Jaeryang Baek 3e7bce0921 Merge pull request #13036 from erandow/translate-persian-texts
feat: translate all texts to persian
2025-04-18 12:32:17 -07:00
Taylor Wilsdon 1dbf4d0461 Add ENABLE_OAUTH_GROUP_CREATION flag for JIT group creation with OAuth group updates enabled 2025-04-18 10:17:08 -07:00
Taylor Wilsdon a67e5f725f Merge branch 'dev' of github.com:taylorwilsdon/open-webui into dev 2025-04-18 10:04:42 -07:00
Erfan Asadi 527c5e73dc fix: revert اولاما to ollama after feedback 2025-04-18 18:56:01 +03:30
Erfan Asadi 2ecfd3dbed Merge branch 'dev' of https://github.com/erandow/open-webui into translate-persian-texts 2025-04-18 18:55:14 +03:30
Erfan Asadi 5989b6ca12 Merge branch 'main' of https://github.com/erandow/open-webui into translate-persian-texts 2025-04-18 18:54:35 +03:30
Erfan Asadi e3af831db1 feat: translate the remaining texts in persian 2025-04-18 18:38:06 +03:30
Juan Calderon-Perez f77f6cd1e2 Update imports to match anyio docs 2025-04-18 09:30:34 -04:00
Juan Calderon-Perez e19554e9a8 Simplify import 2025-04-18 09:26:57 -04:00
Juan Calderon-Perez 19b3d88737 Add support for configuring FastAPI/AnyIO Thread Pool Size 2025-04-18 09:22:23 -04:00
Timothy Jaeryang Baek 0d09e46281 refac 2025-04-18 05:32:11 -07:00
Timothy Jaeryang Baek 961869a60c refac 2025-04-18 05:30:50 -07:00
Timothy Jaeryang Baek b8fb4e528d enh: toast close button 2025-04-18 04:33:18 -07:00
Tim Jaeryang Baek 794360addc Merge pull request #12997 from Elmolesto/feat/knowledge-lazy-load-content
feat: Lazy load file content on click
2025-04-18 04:04:29 -07:00
Tim Jaeryang Baek fa8132903e Merge pull request #12904 from hurxxxx/feat/onedrive-orgs
feat: support Organization and School accounts in OneDrive
2025-04-18 04:03:54 -07:00
Timothy Jaeryang Baek a86a932b46 refac: deprecate event dispatcher 2025-04-18 02:38:53 -07:00
Felipe Hernandez 14fa7d16f9 feat: Cache file content 2025-04-18 11:38:15 +02:00
Timothy Jaeryang Baek 64a98b2e0e refac: manifest orientation "any" 2025-04-18 02:24:44 -07:00
Timothy Jaeryang Baek 8f67ffabbd fix: model editor system not being preserved 2025-04-18 02:23:25 -07:00
Timothy Jaeryang Baek a22be555cb refac 2025-04-18 01:26:42 -07:00
Timothy Jaeryang Baek eb80719cf0 feat: AIOHTTP_CLIENT_SESSION_SSL 2025-04-17 22:11:42 -07:00
Tim Jaeryang Baek 840dd9c89a Merge pull request #12971 from tth37/fix_source_indexing
fix: Align backend <source> tag indexing with frontend citation grouping
2025-04-17 22:08:07 -07:00
Tim Jaeryang Baek 30e84adb51 Merge pull request #12883 from Classic298/patch-1
fix: UVICORN_WORKERS __init__.py pass application as string
2025-04-17 22:07:21 -07:00
Tim Jaeryang Baek 414ebc87ee Merge pull request #12894 from Classic298/patch-2
security/fix: prevent email and password changes to the primary admin account
2025-04-17 22:06:54 -07:00
Tim Jaeryang Baek 165eb50c22 Merge pull request #12958 from Youggls/fix_search_web
fix: use run_in_threadpool for search_web to prevent blocking
2025-04-17 22:03:31 -07:00
Tim Jaeryang Baek 477ac7de86 Merge pull request #12968 from alpha-pet/feat-openapi-parameter-description
feat: support OpenAPI parameter description and enum values in tool spec
2025-04-17 22:02:50 -07:00
Timothy Jaeryang Baek 824238a620 enh: AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL 2025-04-17 22:02:16 -07:00
Felipe Hernandez 88f97372d9 fix: Show content on upload file 2025-04-18 02:01:47 +02:00
Felipe Hernandez 0577dc2990 feat: Lazy load file content on click 2025-04-18 01:40:30 +02:00
Tim Jaeryang Baek 3b20c57ea8 Merge pull request #12956 from lukasz-pekala/feat/improve-polish-translation
fix: update Polish translations for clarity and consistency
2025-04-17 12:38:14 -07:00
Tim Jaeryang Baek 43b9bf17dc Merge pull request #12966 from damianmr/dev
i18n: Update es-ES (Spanish) translation.json
2025-04-17 12:38:03 -07:00
Tim Jaeryang Baek 246ff83864 Merge pull request #12981 from knight-upstage/dev
i18n: Update ko-KR (Korean) translation.json
2025-04-17 12:37:41 -07:00
knight-upstage cdf26d8189 i18n: Update ko-KR (Korean) translation.json 2025-04-17 09:57:22 -07:00
tth37 79bde6fa47 fix: Align backend <source> tag indexing with frontend citation grouping 2025-04-17 22:27:22 +08:00
Thomas Rehn 3dcbf1acf5 feat: support OpenAPI parameter description and enum values in tool spec 2025-04-17 15:48:55 +02:00
Damian Mendez Romera b1629d8660 i18n: Update es-ES (Spanish) translation.json 2025-04-17 13:58:00 +01:00
David Holmlund 9f8b94e45f fix: improve international character handling in prompt commands 2025-04-17 14:56:57 +02:00
Youggls 9669cd3454 fix: use run_in_threadpool for search_web to prevent blocking
Used fastapi's run_in_threadpool function to execute the search_web function,
preventing the synchronous function from blocking the entire web search process.
2025-04-17 17:23:20 +08:00
Łukasz Pękala c4edcdb12a fix: update Polish translations for clarity and consistency 2025-04-17 10:56:23 +02:00
Timothy Jaeryang Baek a6ccc48d91 refac: textarea component 2025-04-17 00:35:39 -07:00
Tim Jaeryang Baek b6caa95fac Merge pull request #12947 from gaby/qdrant-grpc
feat: Add gRPC support to Qdrant
2025-04-16 23:08:50 -07:00
Juan Calderon-Perez 6188c0c5b7 Add suport for Qdrant GRPC 2025-04-17 01:13:49 -04:00
Jarrod Lowe dfe50b4e56 Merge branch 'open-webui:main' into main 2025-04-17 15:46:24 +12:00
Taylor Wilsdon 54c6e64eec Merge branch 'dev' of github.com:taylorwilsdon/open-webui into dev 2025-04-16 13:01:04 -07:00
Tim Jaeryang Baek 465e879a78 Merge pull request #12917 from rimvel/patch-1
Update translation.json
2025-04-16 08:05:44 -07:00
Tim Jaeryang Baek 93fd5185f9 Merge pull request #12921 from aleixdorca/dev
i18n: Update catalan translation.json
2025-04-16 08:05:14 -07:00
Tim Jaeryang Baek 4ebee592f2 Merge pull request #12925 from JaworIwanowRoosi/main
fix: typos in German translation
2025-04-16 08:05:05 -07:00
JaworIwanowRoosi 6076476a74 fixed: typos in German translation 2025-04-16 14:47:15 +02:00
Aleix Dorca 59918b0f54 Update catalan translation.json 2025-04-16 11:20:03 +02:00
Rimvydas bb1dea72d4 Update translation.json
Fixed typo from "Tęstti" to "Tęsti" (continue).
2025-04-16 09:59:20 +03:00
Tim Jaeryang Baek 264a87f467 Merge pull request #12901 from Kylapaallikko/dev
i18n: Update Finnish Translation
2025-04-15 19:03:52 -07:00
Tim Jaeryang Baek 07347f518a Merge pull request #12896 from gaby/fix-12893
fix: Optional content from files routes
2025-04-15 15:54:29 -07:00
hurxxxx 84f5a52923 chore: clean up unnecessary code 2025-04-16 02:38:20 +09:00
Kylapaallikko 8a50d50038 Update translation.json 2025-04-15 19:44:20 +03:00
hurxxxx 41ef596472 Merge remote-tracking branch 'upstream/main' into feat/onedrive-orgs 2025-04-16 01:41:24 +09:00
Juan Calderon-Perez b4d0d840d1 Fix formatting of qdrant.py 2025-04-15 08:56:51 -04:00
Juan Calderon-Perez b1ef53873f Check if content is present before removing 2025-04-15 08:53:05 -04:00
Classic298 4f14b17c34 Update users.py 2025-04-15 13:50:52 +02:00
Classic298 36b2052fb0 Update __init__.py 2025-04-15 09:55:35 +02:00
Timothy Jaeryang Baek 12c7ecf0f4 refac: styling 2025-04-14 22:34:38 -07:00
Tim Jaeryang Baek e5cbef0baa Merge pull request #12868 from athoik/dev
feat: add QDRANT_ON_DISK configuration option for Qdrant integration
2025-04-14 21:31:46 -07:00
Tim Jaeryang Baek 44e2785495 Merge pull request #12859 from aindriu80/main
i18n: Updated Irish translation
2025-04-14 21:30:21 -07:00
Tim Jaeryang Baek f96c610e21 Merge pull request #12847 from panda44312/patch-13
i18n: zh-cn fix
2025-04-14 21:30:17 -07:00
Taylor Wilsdon 0fc5699533 Merge branch 'dev' of github.com:taylorwilsdon/open-webui into dev 2025-04-14 19:34:12 -04:00
Athanasios Oikonomou 575c12f80c feat: add QDRANT_ON_DISK configuration option for Qdrant integration
This commit will allow configuring the on_disk client parameter, to reduce the memory usage.
https://qdrant.tech/documentation/concepts/storage/?q=mmap#configuring-memmap-storage
Default is false, keeping vectors in memory.
2025-04-15 01:40:57 +03:00
Tim Jaeryang Baek 7e0e6177e9 Merge pull request #12865 from TiancongLx/dev
i18n: update zh-TW
2025-04-14 13:53:24 -07:00
Tiancong Li 0d388b4e54 i18n: update zh-TW 2025-04-15 03:33:30 +08:00
Aindriú Mac Giolla Eoin a3d950872c Updated Irish translations - April 2025-04-14 17:14:02 +01:00
hurxxxx 55d077b52a refactor 2025-04-15 00:35:18 +09:00
hurxxxx 2d7062fc99 fix: onedrive orgs selection 2025-04-15 00:27:59 +09:00
hurxxxx 5fd794612e add onedrive sub menu 2025-04-14 22:57:32 +09:00
Panda 21206bf4b9 i18n: zh-cn fix 2025-04-14 14:26:10 +02:00
tth37 dcb3f18e1e refac: styling 2025-04-14 18:56:47 +08:00
tth37 85f8e91288 feat: Allow admin editing external search/loader settings 2025-04-14 18:19:26 +08:00
tth37 008fec80c1 fix: Update external search/loader method to POST 2025-04-14 18:17:27 +08:00
Tim Jaeryang Baek 07d8460126 Merge pull request #12809 from open-webui/dev
0.6.5
2025-04-14 02:13:21 -07:00
Timothy Jaeryang Baek e4c7417522 refac 2025-04-14 02:12:58 -07:00
Timothy Jaeryang Baek 91523bbb07 chore: bump 2025-04-14 02:08:11 -07:00
Timothy Jaeryang Baek 49bf3207e8 chore: format 2025-04-14 02:00:52 -07:00
Timothy Jaeryang Baek ec730b08c5 chore: format 2025-04-14 02:00:46 -07:00
Timothy Jaeryang Baek 815d18b57e revert 2025-04-14 01:57:23 -07:00
Timothy Jaeryang Baek 8d33b5e063 refac 2025-04-14 01:56:52 -07:00
Timothy Jaeryang BaekandSebastian Whincop aa8db40376 enh: copy formatted option
Co-Authored-By: Sebastian Whincop <123417897+macjedi42@users.noreply.github.com>
2025-04-14 01:56:15 -07:00
Timothy Jaeryang Baek 95fca7b700 refac 2025-04-14 01:41:58 -07:00
Timothy Jaeryang Baek 87aa01a5c2 refac: styling 2025-04-14 01:40:29 -07:00
Timothy Jaeryang Baek d932fc555d enh: tts/stt user permissions 2025-04-14 01:40:22 -07:00
Timothy Jaeryang Baek ec3b8fab5b refac 2025-04-14 01:08:16 -07:00
Timothy Jaeryang Baek 403295600f refac: audio tts voice list 2025-04-14 00:20:51 -07:00
Tim Jaeryang Baek ceffac87b6 Merge pull request #12835 from weisser-dev/dev
Fix: Robust Handling of LDAP Email Attribute Types
2025-04-14 00:15:51 -07:00
Timothy Jaeryang Baek e7332fd6fe refac 2025-04-13 23:39:38 -07:00
Tim Jaeryang Baek 60596c362a Merge pull request #12815 from TomBayne/enh/vad_filter
feat/Enable vad_filter to improve quality of transcription in faster-whisper
2025-04-13 23:27:03 -07:00
Erik c749083935 Update auths.py 2025-04-14 08:12:37 +02:00
Timothy Jaeryang Baek 70718dda90 refac 2025-04-13 22:31:43 -07:00
Timothy Jaeryang Baek 2fcf5d8578 refac 2025-04-13 17:28:09 -07:00
Tim Jaeryang Baek bc2b5d3cc8 Merge pull request #12816 from SadmL/dev
[i18n] Russian locale update
2025-04-13 14:11:16 -07:00
Tim Jaeryang Baek 370f934158 Merge pull request #12819 from athoik/dev
fix: Properly handle ldap3.abstract.attribute.Attribute value
2025-04-13 14:09:56 -07:00
tth37 22f0365cef format 2025-04-14 02:05:58 +08:00
tth37 839ba22c90 feat: Backend for Self-Hosted/External Web Search/Loader Engines 2025-04-14 01:49:05 +08:00
Athanasios Oikonomou c70fedb626 Properly handle ldap3.abstract.attribute.Attribute value
This commit fixes an issue created on commit 2e7a01f30a.

Ldap object is type <class 'ldap3.abstract.attribute.Attribute'>.
So we need to return the value and verify if it's null, string or list.

Fixes #12813
2025-04-13 18:00:42 +03:00
SadmL 08bcfdb8cc [i18n] Russian locale update 2025-04-13 15:37:18 +03:00
Tom 24367d459b Enable vad_filter to improve quality of transcription in faster-whisper model. 2025-04-13 13:03:57 +01:00
Tim Jaeryang Baek 8158609c93 Merge pull request #12810 from tth37/i18n_small_fix
i18n: update zh-CN
2025-04-12 23:15:54 -07:00
tth37 70d9405e64 i18n: update zh-CN 2025-04-13 14:14:34 +08:00
Timothy Jaeryang Baek aaefba2777 chore: bump 2025-04-12 23:06:40 -07:00
Jarrod Lowe a8bbaa61bd Merge branch 'dev' into main 2025-04-11 22:14:41 +12:00
Taylor Wilsdon 708fcdc6b7 Merge branch 'dev' of github.com:taylorwilsdon/open-webui into dev 2025-04-10 06:32:17 -04:00
Jarrod Lowe b4465987f9 Environment variable to redirect on logout 2025-04-08 13:39:00 +12:00
Taylor Wilsdon 66bf6d88bc Merge branch 'dev' of github.com:taylorwilsdon/open-webui into dev 2025-04-07 10:17:57 -04:00
Taylor Wilsdon 0a2747bc6a Merge branch 'dev' of github.com:taylorwilsdon/open-webui into dev 2025-04-06 13:01:39 -04:00
208 changed files with 12875 additions and 4705 deletions
+4
View File
@@ -70,3 +70,7 @@
### Screenshots or Videos
- [Attach any relevant screenshots or videos demonstrating the changes]
### Contributor License Agreement
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
+62
View File
@@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.6] - 2025-05-05
### Added
- 📝 **AI-Enhanced Notes (With Audio Transcription)**: Effortlessly create notes, attach meeting or voice audio, and let the AI instantly enhance, summarize, or refine your notes using audio transcriptions—making your documentation smarter, cleaner, and more insightful with minimal effort.
- 🔊 **Meeting Audio Recording & Import**: Seamlessly record audio from your meetings or capture screen audio and attach it to your notes—making it easier to revisit, annotate, and extract insights from important discussions.
- 📁 **Import Markdown Notes Effortlessly**: Bring your existing knowledge library into Open WebUI by importing your Markdown notes, so you can leverage all advanced note management and AI features right away.
- 👥 **Notes Permissions by User Group**: Fine-tune access and editing rights for notes based on user roles or groups, so you can delegate writing or restrict sensitive information as needed.
- ☁️ **OneDrive & SharePoint Integration**: Keep your content in sync by connecting notes and files directly with OneDrive or SharePoint—unlocking fast enterprise import/export and seamless collaboration with your existing workflows.
- 🗂️ **Paginated User List in Admin Panel**: Effortlessly manage and search through large teams via the new paginated user list—saving time and streamlining user administration in big organizations.
- 🕹️ **Granular Chat Share & Export Permissions**: Enjoy enhanced control over who can share or export chats, enabling tighter governance and privacy in team and enterprise settings.
- 🛑 **User Role Change Confirmation Dialog**: Reduce accidental privilege changes with a required confirmation step before updating user roles—improving security and preventing costly mistakes in team management.
- 🚨 **Audit Log for Failed Login Attempts**: Quickly detect unauthorized access attempts or troubleshoot user login problems with detailed logs of failed authentication right in the audit trail.
- 💡 **Dedicated 'Generate Title' Button for Chats**: Swiftly organize every conversation—tap the new button to let AI create relevant, clear titles for all your chats, saving time and reducing clutter.
- 💬 **Notification Sound Always-On Option**: Take control of your notifications by setting sound alerts to always play—helping you stay on top of important updates in busy environments.
- 🆔 **S3 File Tagging Support**: Uploaded files to S3 now include tags for better organization, searching, and integration with your file management policies.
- 🛡️ **OAuth Blocked Groups Support**: Gain more control over group-based access by explicitly blocking specified OAuth groups—ideal for complex identity or security requirements.
- 🚀 **Optimized Faster Web Search & Multi-Threaded Queries**: Enjoy dramatically faster web search and RAG (retrieval augmented generation) with revamped multi-threaded search—get richer, more accurate results in less time.
- 🔍 **All-Knowledge Parallel Search**: Searches across your entire knowledge base now happen in parallel even in non-hybrid mode, speeding up responses and improving knowledge accuracy for every question.
- 🌐 **New Firecrawl & Yacy Web Search Integrations**: Expand your world of information with two new advanced search engines—Firecrawl for deeper web insight and Yacy for decentralized, privacy-friendly search capabilities.
- 🧠 **Configurable Docling OCR Engine & Language**: Use environment variables to fine-tune Docling OCR engine and supported languages for smarter, more tailored document extraction and RAG workflows.
- 🗝️ **Enhanced Sentence Transformers Configuration**: Added new environment variables for easier set up and advanced customization of Sentence Transformers—ensuring best fit for your embedding needs.
- 🌲 **Pinecone Vector Database Integration**: Index, search, and manage knowledge at enterprise scale with full native support for Pinecone as your vector database—effortlessly handle even the biggest document sets.
- 🔄 **Automatic Requirements Installation for Tools & Functions**: Never worry about lost dependencies on restart—external function and tool requirements are now auto-installed at boot, ensuring tools always “just work.”
- 🔒 **Automatic Sign-Out on Token Expiry**: Security is smarter—users are now automatically logged out if their authentication token expires, protecting sensitive content and ensuring compliance without disruption.
- 🎬 **Automatic YouTube Embed Detection**: Paste YouTube links and see instant in-chat video embeds—no more manual embedding, making knowledge sharing and media consumption even easier for every team.
- 🔄 **Expanded Language & Locale Support**: Translations for Danish, French, Russian, Traditional Chinese, Simplified Chinese, Thai, Catalan, German, and Korean have been upgraded, offering smoother, more natural user experiences across the platform.
### Fixed
- 🔒 **Tighter HTML Token Security**: HTML rendering is now restricted to admin-uploaded tokens only, reducing any risk of XSS and keeping your data safe.
- 🔐 **Refined HTML Security and Token Handling**: Further hardened how HTML tokens and content are handled, guaranteeing even stronger resistance to security vulnerabilities and attacks.
- 🔏 **Correct Model Usage with Ollama Proxy Prefixes**: Enhanced model reference handling so proxied models in Ollama always download and run correctly—even when using custom prefixes.
- 📥 **Video File Upload Handling**: Prevented video files from being misclassified as text, fixing bugs with uploads and ensuring media files work as expected.
- 🔄 **No More Dependent WebSocket Sequential Delays**: Streamlined WebSocket operation to prevent delays and maintain snappy real-time collaboration, especially in multi-user environments.
- 🛠️ **More Robust Action Module Execution**: Multiple actions in a module now trigger as designed, increasing automation and scripting flexibility.
- 📧 **Notification Webhooks**: Ensured that notification webhooks are always sent for user events, even when the user isn’t currently active.
- 🗂️ **Smarter Knowledge Base Reindexing**: Knowledge reindexing continues even when corrupt or missing collections are encountered, keeping your search features running reliably.
- 🏷️ **User Import with Profile Images**: When importing users, their profile images now come along—making onboarding and collaboration visually clearer from day one.
- 💬 **OpenAI o-Series Universal Support**: All OpenAI o-series models are now seamlessly recognized and supported, unlocking more advanced capabilities and model choices for every workflow.
### Changed
- 📜 **Custom License Update & Contributor Agreement**: Open WebUI now operates under a custom license with Contributor License Agreement required by default—see https://docs.openwebui.com/license/ for details, ensuring sustainable open innovation for the community.
- 🔨 **CUDA Docker Images Updated to 12.8**: Upgraded CUDA image support for faster, more compatible model inference and futureproof GPU performance in your AI infrastructure.
- 🧱 **General Backend Refactoring for Reliability**: Continuous stability improvements streamline backend logic, reduce errors, and lay a stronger foundation for the next wave of feature releases—all under the hood for a more dependable WebUI.
## [0.6.5] - 2025-04-14
### Added
- 🛂 **Granular Voice Feature Permissions Per User Group**: Admins can now separately manage access to Speech-to-Text (record voice), Text-to-Speech (read aloud), and Tool Calls for each user group—giving teams tighter control over voice features and enhanced governance across roles.
- 🗣️ **Toggle Voice Activity Detection (VAD) for Whisper STT**: New environment variable lets you enable/disable VAD filtering with built-in Whisper speech-to-text, giving you flexibility to optimize for different audio quality and response accuracy levels.
- 📋 **Copy Formatted Response Mode**: You can now enable “Copy Formatted” in Settings > Interface to copy AI responses exactly as styled (with rich formatting, links, and structure preserved), making it faster and cleaner to paste into documents, emails, or reports.
- ⚙️ **Backend Stability and Performance Enhancements**: General backend refactoring improves system resilience, consistency, and overall reliability—offering smoother performance across workflows whether chatting, generating media, or using external tools.
- 🌎 **Translation Refinements Across Multiple Languages**: Updated translations deliver smoother language localization, clearer labels, and improved international usability throughout the UI—ensuring a better experience for non-English speakers.
### Fixed
- 🛠️ **LDAP Login Reliability Restored**: Resolved a critical issue where some LDAP setups failed due to attribute parsing—ensuring consistent, secure, and seamless user authentication across enterprise deployments.
- 🖼️ **Image Generation in Temporary Chats Now Works Properly**: Fixed a bug where image outputs weren’t generated during temporary chats—visual content can now be used reliably in all chat modes without interruptions.
## [0.6.4] - 2025-04-12
### Fixed
+7
View File
@@ -0,0 +1,7 @@
# Open WebUI Contributor License Agreement
By submitting my contributions to Open WebUI, I grant Open WebUI full freedom to use my work in any way they choose, under any terms they like, both now and in the future. This approach helps ensure the project remains unified, flexible, and easy to maintain, while empowering Open WebUI to respond quickly to the needs of its users and the wider community.
Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the Open WebUI project. This collaborative approach strengthens the project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._**
+1 -1
View File
@@ -4,7 +4,7 @@
ARG USE_CUDA=false
ARG USE_OLLAMA=false
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
ARG USE_CUDA_VER=cu121
ARG USE_CUDA_VER=cu128
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
# Leaderboard: https://huggingface.co/spaces/mteb/leaderboard
# for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB)
+7 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2023-2025 Timothy Jaeryang Baek
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
All rights reserved.
Redistribution and use in source and binary forms, with or without
@@ -15,6 +15,12 @@ modification, are permitted provided that the following conditions are met:
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+20 -4
View File
@@ -7,7 +7,6 @@
![GitHub language count](https://img.shields.io/github/languages/count/open-webui/open-webui)
![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
@@ -62,9 +61,26 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
## 🔗 Also Check Out Open WebUI Community!
## Sponsors 🙌
Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀
#### Emerald
<table>
<tr>
<td>
<a href="https://n8n.io/" target="_blank">
<img src="https://docs.openwebui.com/sponsors/logos/n8n.png" alt="n8n" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
</a>
</td>
<td>
Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
</td>
</tr>
</table>
---
We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
## How to Install 🚀
@@ -206,7 +222,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
## License 📜
This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
## Support 💬
+1 -1
View File
@@ -76,7 +76,7 @@ def serve(
from open_webui.env import UVICORN_WORKERS # Import the workers setting
uvicorn.run(
open_webui.main.app,
"open_webui.main:app",
host=host,
port=port,
forwarded_allow_ips="*",
+160 -6
View File
@@ -509,6 +509,19 @@ ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig(
os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true",
)
ENABLE_OAUTH_GROUP_CREATION = PersistentConfig(
"ENABLE_OAUTH_GROUP_CREATION",
"oauth.enable_group_creation",
os.environ.get("ENABLE_OAUTH_GROUP_CREATION", "False").lower() == "true",
)
OAUTH_BLOCKED_GROUPS = PersistentConfig(
"OAUTH_BLOCKED_GROUPS",
"oauth.blocked_groups",
os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"),
)
OAUTH_ROLES_CLAIM = PersistentConfig(
"OAUTH_ROLES_CLAIM",
"oauth.roles_claim",
@@ -952,10 +965,15 @@ DEFAULT_MODELS = PersistentConfig(
"DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None)
)
DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
"DEFAULT_PROMPT_SUGGESTIONS",
"ui.prompt_suggestions",
[
try:
default_prompt_suggestions = json.loads(
os.environ.get("DEFAULT_PROMPT_SUGGESTIONS", "[]")
)
except Exception as e:
log.exception(f"Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}")
default_prompt_suggestions = []
if default_prompt_suggestions == []:
default_prompt_suggestions = [
{
"title": ["Help me study", "vocabulary for a college entrance exam"],
"content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.",
@@ -983,7 +1001,11 @@ DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
"title": ["Overcome procrastination", "give me tips"],
"content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?",
},
],
]
DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
"DEFAULT_PROMPT_SUGGESTIONS",
"ui.prompt_suggestions",
default_prompt_suggestions,
)
MODEL_ORDER_LIST = PersistentConfig(
@@ -1062,6 +1084,26 @@ USER_PERMISSIONS_CHAT_EDIT = (
os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_SHARE = (
os.environ.get("USER_PERMISSIONS_CHAT_SHARE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_EXPORT = (
os.environ.get("USER_PERMISSIONS_CHAT_EXPORT", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_STT = (
os.environ.get("USER_PERMISSIONS_CHAT_STT", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_TTS = (
os.environ.get("USER_PERMISSIONS_CHAT_TTS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_CALL = (
os.environ.get("USER_PERMISSIONS_CHAT_CALL", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_MULTIPLE_MODELS = (
os.environ.get("USER_PERMISSIONS_CHAT_MULTIPLE_MODELS", "True").lower() == "true"
)
@@ -1095,6 +1137,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = (
== "true"
)
USER_PERMISSIONS_FEATURES_NOTES = (
os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
)
DEFAULT_USER_PERMISSIONS = {
"workspace": {
@@ -1114,6 +1160,11 @@ DEFAULT_USER_PERMISSIONS = {
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"share": USER_PERMISSIONS_CHAT_SHARE,
"export": USER_PERMISSIONS_CHAT_EXPORT,
"stt": USER_PERMISSIONS_CHAT_STT,
"tts": USER_PERMISSIONS_CHAT_TTS,
"call": USER_PERMISSIONS_CHAT_CALL,
"multiple_models": USER_PERMISSIONS_CHAT_MULTIPLE_MODELS,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
"temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED,
@@ -1123,6 +1174,7 @@ DEFAULT_USER_PERMISSIONS = {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
"notes": USER_PERMISSIONS_FEATURES_NOTES,
},
}
@@ -1138,6 +1190,11 @@ ENABLE_CHANNELS = PersistentConfig(
os.environ.get("ENABLE_CHANNELS", "False").lower() == "true",
)
ENABLE_NOTES = PersistentConfig(
"ENABLE_NOTES",
"notes.enable",
os.environ.get("ENABLE_NOTES", "True").lower() == "true",
)
ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
"ENABLE_EVALUATION_ARENA_MODELS",
@@ -1188,6 +1245,9 @@ ENABLE_USER_WEBHOOKS = PersistentConfig(
os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true",
)
# FastAPI / AnyIO settings
THREAD_POOL_SIZE = int(os.getenv("THREAD_POOL_SIZE", "0"))
def validate_cors_origins(origins):
for origin in origins:
@@ -1214,7 +1274,9 @@ def validate_cors_origin(origin):
# To test CORS_ALLOW_ORIGIN locally, you can set something like
# CORS_ALLOW_ORIGIN=http://localhost:5173;http://localhost:8080
# in your .env file depending on your frontend port, 5173 in this case.
CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";")
CORS_ALLOW_ORIGIN = os.environ.get(
"CORS_ALLOW_ORIGIN", "*;http://localhost:5173;http://localhost:8080"
).split(";")
if "*" in CORS_ALLOW_ORIGIN:
log.warning(
@@ -1678,6 +1740,9 @@ MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
# Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None)
QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true"
QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
# OpenSearch
OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@@ -1709,6 +1774,14 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
# Pinecone
PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "open-webui-index")
PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024, 768
PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine")
PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure"
####################################
# Information Retrieval (RAG)
####################################
@@ -1745,6 +1818,13 @@ ONEDRIVE_CLIENT_ID = PersistentConfig(
os.environ.get("ONEDRIVE_CLIENT_ID", ""),
)
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
"ONEDRIVE_SHAREPOINT_URL",
"onedrive.sharepoint_url",
os.environ.get("ONEDRIVE_SHAREPOINT_URL", ""),
)
# RAG Content Extraction
CONTENT_EXTRACTION_ENGINE = PersistentConfig(
"CONTENT_EXTRACTION_ENGINE",
@@ -1764,6 +1844,18 @@ DOCLING_SERVER_URL = PersistentConfig(
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
DOCLING_OCR_ENGINE = PersistentConfig(
"DOCLING_OCR_ENGINE",
"rag.docling_ocr_engine",
os.getenv("DOCLING_OCR_ENGINE", "tesseract"),
)
DOCLING_OCR_LANG = PersistentConfig(
"DOCLING_OCR_LANG",
"rag.docling_ocr_lang",
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
)
DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
"DOCUMENT_INTELLIGENCE_ENDPOINT",
"rag.document_intelligence_endpoint",
@@ -2072,6 +2164,24 @@ SEARXNG_QUERY_URL = PersistentConfig(
os.getenv("SEARXNG_QUERY_URL", ""),
)
YACY_QUERY_URL = PersistentConfig(
"YACY_QUERY_URL",
"rag.web.search.yacy_query_url",
os.getenv("YACY_QUERY_URL", ""),
)
YACY_USERNAME = PersistentConfig(
"YACY_USERNAME",
"rag.web.search.yacy_username",
os.getenv("YACY_USERNAME", ""),
)
YACY_PASSWORD = PersistentConfig(
"YACY_PASSWORD",
"rag.web.search.yacy_password",
os.getenv("YACY_PASSWORD", ""),
)
GOOGLE_PSE_API_KEY = PersistentConfig(
"GOOGLE_PSE_API_KEY",
"rag.web.search.google_pse_api_key",
@@ -2236,6 +2346,29 @@ FIRECRAWL_API_BASE_URL = PersistentConfig(
os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"),
)
EXTERNAL_WEB_SEARCH_URL = PersistentConfig(
"EXTERNAL_WEB_SEARCH_URL",
"rag.web.search.external_web_search_url",
os.environ.get("EXTERNAL_WEB_SEARCH_URL", ""),
)
EXTERNAL_WEB_SEARCH_API_KEY = PersistentConfig(
"EXTERNAL_WEB_SEARCH_API_KEY",
"rag.web.search.external_web_search_api_key",
os.environ.get("EXTERNAL_WEB_SEARCH_API_KEY", ""),
)
EXTERNAL_WEB_LOADER_URL = PersistentConfig(
"EXTERNAL_WEB_LOADER_URL",
"rag.web.loader.external_web_loader_url",
os.environ.get("EXTERNAL_WEB_LOADER_URL", ""),
)
EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
"EXTERNAL_WEB_LOADER_API_KEY",
"rag.web.loader.external_web_loader_api_key",
os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
)
####################################
# Images
@@ -2489,6 +2622,14 @@ WHISPER_MODEL_AUTO_UPDATE = (
and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
)
WHISPER_VAD_FILTER = PersistentConfig(
"WHISPER_VAD_FILTER",
"audio.stt.whisper_vad_filter",
os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true",
)
WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None
# Add Deepgram configuration
DEEPGRAM_API_KEY = PersistentConfig(
"DEEPGRAM_API_KEY",
@@ -2496,6 +2637,7 @@ DEEPGRAM_API_KEY = PersistentConfig(
os.getenv("DEEPGRAM_API_KEY", ""),
)
AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig(
"AUDIO_STT_OPENAI_API_BASE_URL",
"audio.stt.openai.api_base_url",
@@ -2538,6 +2680,18 @@ AUDIO_STT_AZURE_LOCALES = PersistentConfig(
os.getenv("AUDIO_STT_AZURE_LOCALES", ""),
)
AUDIO_STT_AZURE_BASE_URL = PersistentConfig(
"AUDIO_STT_AZURE_BASE_URL",
"audio.stt.azure.base_url",
os.getenv("AUDIO_STT_AZURE_BASE_URL", ""),
)
AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig(
"AUDIO_STT_AZURE_MAX_SPEAKERS",
"audio.stt.azure.max_speakers",
os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", "3"),
)
AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig(
"AUDIO_TTS_OPENAI_API_BASE_URL",
"audio.tts.openai.api_base_url",
+61
View File
@@ -354,6 +354,10 @@ BYPASS_MODEL_ACCESS_CONTROL = (
os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
)
WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get(
"WEBUI_AUTH_SIGNOUT_REDIRECT_URL", None
)
####################################
# WEBUI_SECRET_KEY
####################################
@@ -409,6 +413,11 @@ else:
except Exception:
AIOHTTP_CLIENT_TIMEOUT = 300
AIOHTTP_CLIENT_SESSION_SSL = (
os.environ.get("AIOHTTP_CLIENT_SESSION_SSL", "True").lower() == "true"
)
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
"AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
@@ -437,6 +446,56 @@ else:
except Exception:
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
os.environ.get("AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL", "True").lower() == "true"
)
####################################
# SENTENCE TRANSFORMERS
####################################
SENTENCE_TRANSFORMERS_BACKEND = os.environ.get("SENTENCE_TRANSFORMERS_BACKEND", "")
if SENTENCE_TRANSFORMERS_BACKEND == "":
SENTENCE_TRANSFORMERS_BACKEND = "torch"
SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get(
"SENTENCE_TRANSFORMERS_MODEL_KWARGS", ""
)
if SENTENCE_TRANSFORMERS_MODEL_KWARGS == "":
SENTENCE_TRANSFORMERS_MODEL_KWARGS = None
else:
try:
SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads(
SENTENCE_TRANSFORMERS_MODEL_KWARGS
)
except Exception:
SENTENCE_TRANSFORMERS_MODEL_KWARGS = None
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get(
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND", ""
)
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == "":
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = "torch"
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = os.environ.get(
"SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS", ""
)
if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == "":
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
else:
try:
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads(
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS
)
except Exception:
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
####################################
# OFFLINE_MODE
####################################
@@ -446,6 +505,7 @@ OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1"
####################################
# AUDIT LOGGING
####################################
@@ -467,6 +527,7 @@ AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders"
AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
####################################
# OPENTELEMETRY
####################################
+69 -10
View File
@@ -17,6 +17,7 @@ from sqlalchemy import text
from typing import Optional
from aiocache import cached
import aiohttp
import anyio.to_thread
import requests
@@ -63,6 +64,7 @@ from open_webui.routers import (
auths,
channels,
chats,
notes,
folders,
configs,
groups,
@@ -100,11 +102,14 @@ from open_webui.config import (
# OpenAI
ENABLE_OPENAI_API,
ONEDRIVE_CLIENT_ID,
ONEDRIVE_SHAREPOINT_URL,
OPENAI_API_BASE_URLS,
OPENAI_API_KEYS,
OPENAI_API_CONFIGS,
# Direct Connections
ENABLE_DIRECT_CONNECTIONS,
# Thread pool size for FastAPI/AnyIO
THREAD_POOL_SIZE,
# Tool Server Configs
TOOL_SERVER_CONNECTIONS,
# Code Execution
@@ -151,6 +156,8 @@ from open_webui.config import (
AUDIO_STT_AZURE_API_KEY,
AUDIO_STT_AZURE_REGION,
AUDIO_STT_AZURE_LOCALES,
AUDIO_STT_AZURE_BASE_URL,
AUDIO_STT_AZURE_MAX_SPEAKERS,
AUDIO_TTS_API_KEY,
AUDIO_TTS_ENGINE,
AUDIO_TTS_MODEL,
@@ -166,6 +173,8 @@ from open_webui.config import (
FIRECRAWL_API_KEY,
WEB_LOADER_ENGINE,
WHISPER_MODEL,
WHISPER_VAD_FILTER,
WHISPER_LANGUAGE,
DEEPGRAM_API_KEY,
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_MODEL_DIR,
@@ -194,6 +203,8 @@ from open_webui.config import (
CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG,
DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY,
@@ -218,6 +229,9 @@ from open_webui.config import (
SERPAPI_API_KEY,
SERPAPI_ENGINE,
SEARXNG_QUERY_URL,
YACY_QUERY_URL,
YACY_USERNAME,
YACY_PASSWORD,
SERPER_API_KEY,
SERPLY_API_KEY,
SERPSTACK_API_KEY,
@@ -239,12 +253,17 @@ from open_webui.config import (
GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY,
ONEDRIVE_CLIENT_ID,
ONEDRIVE_SHAREPOINT_URL,
ENABLE_RAG_HYBRID_SEARCH,
ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_WEB_LOADER_SSL_VERIFICATION,
ENABLE_GOOGLE_DRIVE_INTEGRATION,
ENABLE_ONEDRIVE_INTEGRATION,
UPLOAD_DIR,
EXTERNAL_WEB_SEARCH_URL,
EXTERNAL_WEB_SEARCH_API_KEY,
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
# WebUI
WEBUI_AUTH,
WEBUI_NAME,
@@ -259,6 +278,7 @@ from open_webui.config import (
ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
API_KEY_ALLOWED_ENDPOINTS,
ENABLE_CHANNELS,
ENABLE_NOTES,
ENABLE_COMMUNITY_SHARING,
ENABLE_MESSAGE_RATING,
ENABLE_USER_WEBHOOKS,
@@ -340,6 +360,7 @@ from open_webui.env import (
WEBUI_SESSION_COOKIE_SECURE,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_WEBSOCKET_SUPPORT,
BYPASS_MODEL_ACCESS_CONTROL,
RESET_CONFIG_ON_START,
@@ -369,6 +390,7 @@ from open_webui.utils.auth import (
get_admin_user,
get_verified_user,
)
from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import OAuthManager
from open_webui.utils.security_headers import SecurityHeadersMiddleware
@@ -431,7 +453,18 @@ async def lifespan(app: FastAPI):
if LICENSE_KEY:
get_license_data(app, LICENSE_KEY)
# This should be blocking (sync) so functions are not deactivated on first /get_models calls
# when the first user lands on the / route.
log.info("Installing external dependencies of functions and tools...")
install_tool_and_function_dependencies()
pool_size = THREAD_POOL_SIZE
if pool_size and pool_size > 0:
limiter = anyio.to_thread.current_default_thread_limiter()
limiter.total_tokens = pool_size
asyncio.create_task(periodic_usage_pool_cleanup())
yield
@@ -542,6 +575,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_NOTES = ENABLE_NOTES
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
@@ -575,6 +609,7 @@ app.state.config.LDAP_CIPHERS = LDAP_CIPHERS
app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER
app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER
app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL
app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL
app.state.USER_COUNT = None
@@ -603,6 +638,8 @@ app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERI
app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
@@ -645,6 +682,9 @@ app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
app.state.config.YACY_USERNAME = YACY_USERNAME
app.state.config.YACY_PASSWORD = YACY_PASSWORD
app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY
@@ -667,6 +707,10 @@ app.state.config.EXA_API_KEY = EXA_API_KEY
app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY
app.state.config.SOUGOU_API_SID = SOUGOU_API_SID
app.state.config.SOUGOU_API_SK = SOUGOU_API_SK
app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
@@ -789,11 +833,14 @@ app.state.config.STT_ENGINE = AUDIO_STT_ENGINE
app.state.config.STT_MODEL = AUDIO_STT_MODEL
app.state.config.WHISPER_MODEL = WHISPER_MODEL
app.state.config.WHISPER_VAD_FILTER = WHISPER_VAD_FILTER
app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY
app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY
app.state.config.AUDIO_STT_AZURE_REGION = AUDIO_STT_AZURE_REGION
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_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
@@ -867,7 +914,8 @@ class RedirectMiddleware(BaseHTTPMiddleware):
# Check for the specific watch path and the presence of 'v' parameter
if path.endswith("/watch") and "v" in query_params:
video_id = query_params["v"][0] # Extract the first 'v' parameter
# Extract the first 'v' parameter
video_id = query_params["v"][0]
encoded_video_id = urlencode({"youtube": video_id})
redirect_url = f"/?{encoded_video_id}"
return RedirectResponse(url=redirect_url)
@@ -953,6 +1001,8 @@ app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"])
app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
@@ -1023,14 +1073,19 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
if "pipeline" in model and model["pipeline"].get("type", None) == "filter":
continue
model_tags = [
tag.get("name")
for tag in model.get("info", {}).get("meta", {}).get("tags", [])
]
tags = [tag.get("name") for tag in model.get("tags", [])]
try:
model_tags = [
tag.get("name")
for tag in model.get("info", {}).get("meta", {}).get("tags", [])
]
tags = [tag.get("name") for tag in model.get("tags", [])]
tags = list(set(model_tags + tags))
model["tags"] = [{"name": tag} for tag in tags]
tags = list(set(model_tags + tags))
model["tags"] = [{"name": tag} for tag in tags]
except Exception as e:
log.debug(f"Error processing model tags: {e}")
model["tags"] = []
pass
models.append(model)
@@ -1276,6 +1331,7 @@ async def get_app_config(request: Request):
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_notes": app.state.config.ENABLE_NOTES,
"enable_web_search": app.state.config.ENABLE_WEB_SEARCH,
"enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION,
"enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER,
@@ -1320,7 +1376,10 @@ async def get_app_config(request: Request):
"client_id": GOOGLE_DRIVE_CLIENT_ID.value,
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
"onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value},
"onedrive": {
"client_id": ONEDRIVE_CLIENT_ID.value,
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
},
"license_metadata": app.state.LICENSE_METADATA,
**(
{
@@ -1432,7 +1491,7 @@ async def get_manifest_json():
"start_url": "/",
"display": "standalone",
"background_color": "#343541",
"orientation": "natural",
"orientation": "any",
"icons": [
{
"src": "/static/logo.png",
@@ -0,0 +1,33 @@
"""Add note table
Revision ID: 9f0c9cd09105
Revises: 3781e22d8b01
Create Date: 2025-05-03 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "9f0c9cd09105"
down_revision = "3781e22d8b01"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"note",
sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
sa.Column("user_id", sa.Text(), nullable=True),
sa.Column("title", sa.Text(), nullable=True),
sa.Column("data", sa.JSON(), nullable=True),
sa.Column("meta", sa.JSON(), nullable=True),
sa.Column("access_control", sa.JSON(), nullable=True),
sa.Column("created_at", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.BigInteger(), nullable=True),
)
def downgrade():
op.drop_table("note")
+1 -1
View File
@@ -436,7 +436,7 @@ class ChatTable:
all_chats = query.all()
# result has to be destrctured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass.
# result has to be destructured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass.
return [
ChatTitleIdResponse.model_validate(
{
+135
View File
@@ -0,0 +1,135 @@
import json
import time
import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
####################
# Note DB Schema
####################
class Note(Base):
__tablename__ = "note"
id = Column(Text, primary_key=True)
user_id = Column(Text)
title = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
access_control = Column(JSON, nullable=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class NoteModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
####################
# Forms
####################
class NoteForm(BaseModel):
title: str
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None
class NoteTable:
def insert_new_note(
self,
form_data: NoteForm,
user_id: str,
) -> Optional[NoteModel]:
with get_db() as db:
note = NoteModel(
**{
"id": str(uuid.uuid4()),
"user_id": user_id,
**form_data.model_dump(),
"created_at": int(time.time_ns()),
"updated_at": int(time.time_ns()),
}
)
new_note = Note(**note.model_dump())
db.add(new_note)
db.commit()
return note
def get_notes(self) -> list[NoteModel]:
with get_db() as db:
notes = db.query(Note).order_by(Note.updated_at.desc()).all()
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[NoteModel]:
notes = self.get_notes()
return [
note
for note in notes
if note.user_id == user_id
or has_access(user_id, permission, note.access_control)
]
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db:
note = db.query(Note).filter(Note.id == id).first()
return NoteModel.model_validate(note) if note else None
def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
with get_db() as db:
note = db.query(Note).filter(Note.id == id).first()
if not note:
return None
note.title = form_data.title
note.data = form_data.data
note.meta = form_data.meta
note.access_control = form_data.access_control
note.updated_at = int(time.time_ns())
db.commit()
return NoteModel.model_validate(note) if note else None
def delete_note_by_id(self, id: str):
with get_db() as db:
db.query(Note).filter(Note.id == id).delete()
db.commit()
return True
Notes = NoteTable()
+66 -5
View File
@@ -10,6 +10,8 @@ from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
from sqlalchemy import or_
####################
# User DB Schema
@@ -67,6 +69,11 @@ class UserModel(BaseModel):
####################
class UserListResponse(BaseModel):
users: list[UserModel]
total: int
class UserResponse(BaseModel):
id: str
name: str
@@ -160,11 +167,63 @@ class UsersTable:
return None
def get_users(
self, skip: Optional[int] = None, limit: Optional[int] = None
) -> list[UserModel]:
self,
filter: Optional[dict] = None,
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> UserListResponse:
with get_db() as db:
query = db.query(User)
query = db.query(User).order_by(User.created_at.desc())
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
User.name.ilike(f"%{query_key}%"),
User.email.ilike(f"%{query_key}%"),
)
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(User.name.asc())
else:
query = query.order_by(User.name.desc())
elif order_by == "email":
if direction == "asc":
query = query.order_by(User.email.asc())
else:
query = query.order_by(User.email.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(User.created_at.asc())
else:
query = query.order_by(User.created_at.desc())
elif order_by == "last_active_at":
if direction == "asc":
query = query.order_by(User.last_active_at.asc())
else:
query = query.order_by(User.last_active_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(User.updated_at.asc())
else:
query = query.order_by(User.updated_at.desc())
elif order_by == "role":
if direction == "asc":
query = query.order_by(User.role.asc())
else:
query = query.order_by(User.role.desc())
else:
query = query.order_by(User.created_at.desc())
if skip:
query = query.offset(skip)
@@ -172,8 +231,10 @@ class UsersTable:
query = query.limit(limit)
users = query.all()
return [UserModel.model_validate(user) for user in users]
return {
"users": [UserModel.model_validate(user) for user in users],
"total": db.query(User).count(),
}
def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
with get_db() as db:
@@ -0,0 +1,53 @@
import requests
import logging
from typing import Iterator, List, Union
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ExternalLoader(BaseLoader):
def __init__(
self,
web_paths: Union[str, List[str]],
external_url: str,
external_api_key: str,
continue_on_failure: bool = True,
**kwargs,
) -> None:
self.external_url = external_url
self.external_api_key = external_api_key
self.urls = web_paths if isinstance(web_paths, list) else [web_paths]
self.continue_on_failure = continue_on_failure
def lazy_load(self) -> Iterator[Document]:
batch_size = 20
for i in range(0, len(self.urls), batch_size):
urls = self.urls[i : i + batch_size]
try:
response = requests.post(
self.external_url,
headers={
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Authorization": f"Bearer {self.external_api_key}",
},
json={
"urls": urls,
},
)
response.raise_for_status()
results = response.json()
for result in results:
yield Document(
page_content=result.get("page_content", ""),
metadata=result.get("metadata", {}),
)
except Exception as e:
if self.continue_on_failure:
log.error(f"Error extracting content from batch {urls}: {e}")
else:
raise e
+16 -1
View File
@@ -99,6 +99,9 @@ class TikaLoader:
else:
headers = {}
if self.kwargs.get("PDF_EXTRACT_IMAGES") == True:
headers["X-Tika-PDFextractInlineImages"] = "true"
endpoint = self.url
if not endpoint.endswith("/"):
endpoint += "/"
@@ -121,10 +124,14 @@ class TikaLoader:
class DoclingLoader:
def __init__(self, url, file_path=None, mime_type=None):
def __init__(
self, url, file_path=None, mime_type=None, ocr_engine=None, ocr_lang=None
):
self.url = url.rstrip("/")
self.file_path = file_path
self.mime_type = mime_type
self.ocr_engine = ocr_engine
self.ocr_lang = ocr_lang
def load(self) -> list[Document]:
with open(self.file_path, "rb") as f:
@@ -141,6 +148,12 @@ class DoclingLoader:
"table_mode": "accurate",
}
if self.ocr_engine and self.ocr_lang:
params["ocr_engine"] = self.ocr_engine
params["ocr_lang"] = [
lang.strip() for lang in self.ocr_lang.split(",") if lang.strip()
]
endpoint = f"{self.url}/v1alpha/convert/file"
r = requests.post(endpoint, files=files, data=params)
@@ -209,6 +222,8 @@ class Loader:
url=self.kwargs.get("DOCLING_SERVER_URL"),
file_path=file_path,
mime_type=file_content_type,
ocr_engine=self.kwargs.get("DOCLING_OCR_ENGINE"),
ocr_lang=self.kwargs.get("DOCLING_OCR_LANG"),
)
elif (
self.engine == "document_intelligence"
+41 -17
View File
@@ -207,7 +207,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
for distance, document, metadata in zip(distances, documents, metadatas):
if isinstance(document, str):
doc_hash = hashlib.md5(
doc_hash = hashlib.sha256(
document.encode()
).hexdigest() # Compute a hash for uniqueness
@@ -260,23 +260,47 @@ def query_collection(
k: int,
) -> dict:
results = []
for query in queries:
log.debug(f"query_collection:query {query}")
query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
for collection_name in collection_names:
error = False
def process_query_collection(collection_name, query_embedding):
try:
if collection_name:
try:
result = query_doc(
collection_name=collection_name,
k=k,
query_embedding=query_embedding,
)
if result is not None:
results.append(result.model_dump())
except Exception as e:
log.exception(f"Error when querying the collection: {e}")
else:
pass
result = query_doc(
collection_name=collection_name,
k=k,
query_embedding=query_embedding,
)
if result is not None:
return result.model_dump(), None
return None, None
except Exception as e:
log.exception(f"Error when querying the collection: {e}")
return None, e
# Generate all query embeddings (in one call)
query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
log.debug(
f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections"
)
with ThreadPoolExecutor() as executor:
future_results = []
for query_embedding in query_embeddings:
for collection_name in collection_names:
result = executor.submit(
process_query_collection, collection_name, query_embedding
)
future_results.append(result)
task_results = [future.result() for future in future_results]
for result, err in task_results:
if err is not None:
error = True
elif result is not None:
results.append(result)
if error and not results:
log.warning("All collection queries failed. No results returned.")
return merge_and_sort_query_results(results, k=k)
@@ -20,6 +20,10 @@ elif VECTOR_DB == "elasticsearch":
from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
VECTOR_DB_CLIENT = ElasticsearchClient()
elif VECTOR_DB == "pinecone":
from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
VECTOR_DB_CLIENT = PineconeClient()
else:
from open_webui.retrieval.vector.dbs.chroma import ChromaClient
@@ -5,7 +5,12 @@ from chromadb.utils.batch_utils import create_batches
from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
CHROMA_DATA_PATH,
CHROMA_HTTP_HOST,
@@ -23,7 +28,7 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class ChromaClient:
class ChromaClient(VectorDBBase):
def __init__(self):
settings_dict = {
"allow_reset": True,
@@ -2,7 +2,12 @@ from elasticsearch import Elasticsearch, BadRequestError
from typing import Optional
import ssl
from elasticsearch.helpers import bulk, scan
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
ELASTICSEARCH_URL,
ELASTICSEARCH_CA_CERTS,
@@ -15,7 +20,7 @@ from open_webui.config import (
)
class ElasticsearchClient:
class ElasticsearchClient(VectorDBBase):
"""
Important:
in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating
@@ -4,7 +4,12 @@ import json
import logging
from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
MILVUS_URI,
MILVUS_DB,
@@ -16,7 +21,7 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class MilvusClient:
class MilvusClient(VectorDBBase):
def __init__(self):
self.collection_prefix = "open_webui"
if MILVUS_TOKEN is None:
@@ -2,7 +2,12 @@ from opensearchpy import OpenSearch
from opensearchpy.helpers import bulk
from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
OPENSEARCH_URI,
OPENSEARCH_SSL,
@@ -12,7 +17,7 @@ from open_webui.config import (
)
class OpenSearchClient:
class OpenSearchClient(VectorDBBase):
def __init__(self):
self.index_prefix = "open_webui"
self.client = OpenSearch(
@@ -22,7 +22,12 @@ from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
from open_webui.env import SRC_LOG_LEVELS
@@ -44,7 +49,7 @@ class DocumentChunk(Base):
vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
class PgvectorClient:
class PgvectorClient(VectorDBBase):
def __init__(self) -> None:
# if no pgvector uri, use the existing database connection
@@ -136,9 +141,8 @@ class PgvectorClient:
# Pad the vector with zeros
vector += [0.0] * (VECTOR_LENGTH - current_length)
elif current_length > VECTOR_LENGTH:
raise Exception(
f"Vector length {current_length} not supported. Max length must be <= {VECTOR_LENGTH}"
)
# Truncate the vector to VECTOR_LENGTH
vector = vector[:VECTOR_LENGTH]
return vector
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
@@ -0,0 +1,412 @@
from typing import Optional, List, Dict, Any, Union
import logging
from pinecone import Pinecone, ServerlessSpec
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
PINECONE_API_KEY,
PINECONE_ENVIRONMENT,
PINECONE_INDEX_NAME,
PINECONE_DIMENSION,
PINECONE_METRIC,
PINECONE_CLOUD,
)
from open_webui.env import SRC_LOG_LEVELS
NO_LIMIT = 10000 # Reasonable limit to avoid overwhelming the system
BATCH_SIZE = 100 # Recommended batch size for Pinecone operations
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class PineconeClient(VectorDBBase):
def __init__(self):
self.collection_prefix = "open-webui"
# Validate required configuration
self._validate_config()
# Store configuration values
self.api_key = PINECONE_API_KEY
self.environment = PINECONE_ENVIRONMENT
self.index_name = PINECONE_INDEX_NAME
self.dimension = PINECONE_DIMENSION
self.metric = PINECONE_METRIC
self.cloud = PINECONE_CLOUD
# Initialize Pinecone client
self.client = Pinecone(api_key=self.api_key)
# Create index if it doesn't exist
self._initialize_index()
def _validate_config(self) -> None:
"""Validate that all required configuration variables are set."""
missing_vars = []
if not PINECONE_API_KEY:
missing_vars.append("PINECONE_API_KEY")
if not PINECONE_ENVIRONMENT:
missing_vars.append("PINECONE_ENVIRONMENT")
if not PINECONE_INDEX_NAME:
missing_vars.append("PINECONE_INDEX_NAME")
if not PINECONE_DIMENSION:
missing_vars.append("PINECONE_DIMENSION")
if not PINECONE_CLOUD:
missing_vars.append("PINECONE_CLOUD")
if missing_vars:
raise ValueError(
f"Required configuration missing: {', '.join(missing_vars)}"
)
def _initialize_index(self) -> None:
"""Initialize the Pinecone index."""
try:
# Check if index exists
if self.index_name not in self.client.list_indexes().names():
log.info(f"Creating Pinecone index '{self.index_name}'...")
self.client.create_index(
name=self.index_name,
dimension=self.dimension,
metric=self.metric,
spec=ServerlessSpec(cloud=self.cloud, region=self.environment),
)
log.info(f"Successfully created Pinecone index '{self.index_name}'")
else:
log.info(f"Using existing Pinecone index '{self.index_name}'")
# Connect to the index
self.index = self.client.Index(self.index_name)
except Exception as e:
log.error(f"Failed to initialize Pinecone index: {e}")
raise RuntimeError(f"Failed to initialize Pinecone index: {e}")
def _create_points(
self, items: List[VectorItem], collection_name_with_prefix: str
) -> List[Dict[str, Any]]:
"""Convert VectorItem objects to Pinecone point format."""
points = []
for item in items:
# Start with any existing metadata or an empty dict
metadata = item.get("metadata", {}).copy() if item.get("metadata") else {}
# Add text to metadata if available
if "text" in item:
metadata["text"] = item["text"]
# Always add collection_name to metadata for filtering
metadata["collection_name"] = collection_name_with_prefix
point = {
"id": item["id"],
"values": item["vector"],
"metadata": metadata,
}
points.append(point)
return points
def _get_collection_name_with_prefix(self, collection_name: str) -> str:
"""Get the collection name with prefix."""
return f"{self.collection_prefix}_{collection_name}"
def _normalize_distance(self, score: float) -> float:
"""Normalize distance score based on the metric used."""
if self.metric.lower() == "cosine":
# Cosine similarity ranges from -1 to 1, normalize to 0 to 1
return (score + 1.0) / 2.0
elif self.metric.lower() in ["euclidean", "dotproduct"]:
# These are already suitable for ranking (smaller is better for Euclidean)
return score
else:
# For other metrics, use as is
return score
def _result_to_get_result(self, matches: list) -> GetResult:
"""Convert Pinecone matches to GetResult format."""
ids = []
documents = []
metadatas = []
for match in matches:
metadata = match.get("metadata", {})
ids.append(match["id"])
documents.append(metadata.get("text", ""))
metadatas.append(metadata)
return GetResult(
**{
"ids": [ids],
"documents": [documents],
"metadatas": [metadatas],
}
)
def has_collection(self, collection_name: str) -> bool:
"""Check if a collection exists by searching for at least one item."""
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
try:
# Search for at least 1 item with this collection name in metadata
response = self.index.query(
vector=[0.0] * self.dimension, # dummy vector
top_k=1,
filter={"collection_name": collection_name_with_prefix},
include_metadata=False,
)
return len(response.matches) > 0
except Exception as e:
log.exception(
f"Error checking collection '{collection_name_with_prefix}': {e}"
)
return False
def delete_collection(self, collection_name: str) -> None:
"""Delete a collection by removing all vectors with the collection name in metadata."""
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
try:
self.index.delete(filter={"collection_name": collection_name_with_prefix})
log.info(
f"Collection '{collection_name_with_prefix}' deleted (all vectors removed)."
)
except Exception as e:
log.warning(
f"Failed to delete collection '{collection_name_with_prefix}': {e}"
)
raise
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""Insert vectors into a collection."""
if not items:
log.warning("No items to insert")
return
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
points = self._create_points(items, collection_name_with_prefix)
# Insert in batches for better performance and reliability
for i in range(0, len(points), BATCH_SIZE):
batch = points[i : i + BATCH_SIZE]
try:
self.index.upsert(vectors=batch)
log.debug(
f"Inserted batch of {len(batch)} vectors into '{collection_name_with_prefix}'"
)
except Exception as e:
log.error(
f"Error inserting batch into '{collection_name_with_prefix}': {e}"
)
raise
log.info(
f"Successfully inserted {len(items)} vectors into '{collection_name_with_prefix}'"
)
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""Upsert (insert or update) vectors into a collection."""
if not items:
log.warning("No items to upsert")
return
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
points = self._create_points(items, collection_name_with_prefix)
# Upsert in batches
for i in range(0, len(points), BATCH_SIZE):
batch = points[i : i + BATCH_SIZE]
try:
self.index.upsert(vectors=batch)
log.debug(
f"Upserted batch of {len(batch)} vectors into '{collection_name_with_prefix}'"
)
except Exception as e:
log.error(
f"Error upserting batch into '{collection_name_with_prefix}': {e}"
)
raise
log.info(
f"Successfully upserted {len(items)} vectors into '{collection_name_with_prefix}'"
)
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
if not vectors or not vectors[0]:
log.warning("No vectors provided for search")
return None
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
if limit is None or limit <= 0:
limit = NO_LIMIT
try:
# Search using the first vector (assuming this is the intended behavior)
query_vector = vectors[0]
# Perform the search
query_response = self.index.query(
vector=query_vector,
top_k=limit,
include_metadata=True,
filter={"collection_name": collection_name_with_prefix},
)
if not query_response.matches:
# Return empty result if no matches
return SearchResult(
ids=[[]],
documents=[[]],
metadatas=[[]],
distances=[[]],
)
# Convert to GetResult format
get_result = self._result_to_get_result(query_response.matches)
# Calculate normalized distances based on metric
distances = [
[
self._normalize_distance(match.score)
for match in query_response.matches
]
]
return SearchResult(
ids=get_result.ids,
documents=get_result.documents,
metadatas=get_result.metadatas,
distances=distances,
)
except Exception as e:
log.error(f"Error searching in '{collection_name_with_prefix}': {e}")
return None
def query(
self, collection_name: str, filter: Dict, limit: Optional[int] = None
) -> Optional[GetResult]:
"""Query vectors by metadata filter."""
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
if limit is None or limit <= 0:
limit = NO_LIMIT
try:
# Create a zero vector for the dimension as Pinecone requires a vector
zero_vector = [0.0] * self.dimension
# Combine user filter with collection_name
pinecone_filter = {"collection_name": collection_name_with_prefix}
if filter:
pinecone_filter.update(filter)
# Perform metadata-only query
query_response = self.index.query(
vector=zero_vector,
filter=pinecone_filter,
top_k=limit,
include_metadata=True,
)
return self._result_to_get_result(query_response.matches)
except Exception as e:
log.error(f"Error querying collection '{collection_name}': {e}")
return None
def get(self, collection_name: str) -> Optional[GetResult]:
"""Get all vectors in a collection."""
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
try:
# Use a zero vector for fetching all entries
zero_vector = [0.0] * self.dimension
# Add filter to only get vectors for this collection
query_response = self.index.query(
vector=zero_vector,
top_k=NO_LIMIT,
include_metadata=True,
filter={"collection_name": collection_name_with_prefix},
)
return self._result_to_get_result(query_response.matches)
except Exception as e:
log.error(f"Error getting collection '{collection_name}': {e}")
return None
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict] = None,
) -> None:
"""Delete vectors by IDs or filter."""
collection_name_with_prefix = self._get_collection_name_with_prefix(
collection_name
)
try:
if ids:
# Delete by IDs (in batches for large deletions)
for i in range(0, len(ids), BATCH_SIZE):
batch_ids = ids[i : i + BATCH_SIZE]
# Note: When deleting by ID, we can't filter by collection_name
# This is a limitation of Pinecone - be careful with ID uniqueness
self.index.delete(ids=batch_ids)
log.debug(
f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'"
)
log.info(
f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'"
)
elif filter:
# Combine user filter with collection_name
pinecone_filter = {"collection_name": collection_name_with_prefix}
if filter:
pinecone_filter.update(filter)
# Delete by metadata filter
self.index.delete(filter=pinecone_filter)
log.info(
f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'"
)
else:
log.warning("No ids or filter provided for delete operation")
except Exception as e:
log.error(f"Error deleting from collection '{collection_name}': {e}")
raise
def reset(self) -> None:
"""Reset the database by deleting all collections."""
try:
self.index.delete(delete_all=True)
log.info("All vectors successfully deleted from the index.")
except Exception as e:
log.error(f"Failed to reset Pinecone index: {e}")
raise
@@ -1,12 +1,24 @@
from typing import Optional
import logging
from urllib.parse import urlparse
from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.models import PointStruct
from qdrant_client.models import models
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import QDRANT_URI, QDRANT_API_KEY
from open_webui.retrieval.vector.main import (
VectorDBBase,
VectorItem,
SearchResult,
GetResult,
)
from open_webui.config import (
QDRANT_URI,
QDRANT_API_KEY,
QDRANT_ON_DISK,
QDRANT_GRPC_PORT,
QDRANT_PREFER_GRPC,
)
from open_webui.env import SRC_LOG_LEVELS
NO_LIMIT = 999999999
@@ -15,16 +27,34 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
class QdrantClient:
class QdrantClient(VectorDBBase):
def __init__(self):
self.collection_prefix = "open-webui"
self.QDRANT_URI = QDRANT_URI
self.QDRANT_API_KEY = QDRANT_API_KEY
self.client = (
Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
if self.QDRANT_URI
else None
)
self.QDRANT_ON_DISK = QDRANT_ON_DISK
self.PREFER_GRPC = QDRANT_PREFER_GRPC
self.GRPC_PORT = QDRANT_GRPC_PORT
if not self.QDRANT_URI:
self.client = None
return
# Unified handling for either scheme
parsed = urlparse(self.QDRANT_URI)
host = parsed.hostname or self.QDRANT_URI
http_port = parsed.port or 6333 # default REST port
if self.PREFER_GRPC:
self.client = Qclient(
host=host,
port=http_port,
grpc_port=self.GRPC_PORT,
prefer_grpc=self.PREFER_GRPC,
api_key=self.QDRANT_API_KEY,
)
else:
self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
def _result_to_get_result(self, points) -> GetResult:
ids = []
@@ -50,7 +80,9 @@ class QdrantClient:
self.client.create_collection(
collection_name=collection_name_with_prefix,
vectors_config=models.VectorParams(
size=dimension, distance=models.Distance.COSINE
size=dimension,
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
)
+68 -1
View File
@@ -1,5 +1,6 @@
from pydantic import BaseModel
from typing import Optional, List, Any
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Union
class VectorItem(BaseModel):
@@ -17,3 +18,69 @@ class GetResult(BaseModel):
class SearchResult(GetResult):
distances: Optional[List[List[float | int]]]
class VectorDBBase(ABC):
"""
Abstract base class for all vector database backends.
Implementations of this class provide methods for collection management,
vector insertion, deletion, similarity search, and metadata filtering.
Any custom vector database integration must inherit from this class and
implement all abstract methods.
"""
@abstractmethod
def has_collection(self, collection_name: str) -> bool:
"""Check if the collection exists in the vector DB."""
pass
@abstractmethod
def delete_collection(self, collection_name: str) -> None:
"""Delete a collection from the vector DB."""
pass
@abstractmethod
def insert(self, collection_name: str, items: List[VectorItem]) -> None:
"""Insert a list of vector items into a collection."""
pass
@abstractmethod
def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
"""Insert or update vector items in a collection."""
pass
@abstractmethod
def search(
self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
pass
@abstractmethod
def query(
self, collection_name: str, filter: Dict, limit: Optional[int] = None
) -> Optional[GetResult]:
"""Query vectors from a collection using metadata filter."""
pass
@abstractmethod
def get(self, collection_name: str) -> Optional[GetResult]:
"""Retrieve all vectors from a collection."""
pass
@abstractmethod
def delete(
self,
collection_name: str,
ids: Optional[List[str]] = None,
filter: Optional[Dict] = None,
) -> None:
"""Delete vectors by ID or filter from a collection."""
pass
@abstractmethod
def reset(self) -> None:
"""Reset the vector database by removing all collections or those matching a condition."""
pass
@@ -0,0 +1,47 @@
import logging
from typing import Optional, List
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_external(
external_url: str,
external_api_key: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
) -> List[SearchResult]:
try:
response = requests.post(
external_url,
headers={
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Authorization": f"Bearer {external_api_key}",
},
json={
"query": query,
"count": count,
},
)
response.raise_for_status()
results = response.json()
if filter_list:
results = get_filtered_results(results, filter_list)
results = [
SearchResult(
link=result.get("link"),
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results[:count]
]
log.info(f"External search results: {results}")
return results
except Exception as e:
log.error(f"Error in External search: {e}")
return []
@@ -0,0 +1,49 @@
import logging
from typing import Optional, List
from urllib.parse import urljoin
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_firecrawl(
firecrawl_url: str,
firecrawl_api_key: str,
query: str,
count: int,
filter_list: Optional[List[str]] = None,
) -> List[SearchResult]:
try:
firecrawl_search_url = urljoin(firecrawl_url, "/v1/search")
response = requests.post(
firecrawl_search_url,
headers={
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Authorization": f"Bearer {firecrawl_api_key}",
},
json={
"query": query,
"limit": count,
},
)
response.raise_for_status()
results = response.json().get("data", [])
if filter_list:
results = get_filtered_results(results, filter_list)
results = [
SearchResult(
link=result.get("url"),
title=result.get("title"),
snippet=result.get("description"),
)
for result in results[:count]
]
log.info(f"External search results: {results}")
return results
except Exception as e:
log.error(f"Error in External search: {e}")
return []
+12 -5
View File
@@ -2,7 +2,7 @@ import logging
from typing import Optional
import requests
from open_webui.retrieval.web.main import SearchResult
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
@@ -21,18 +21,25 @@ def search_tavily(
Args:
api_key (str): A Tavily Search API key
query (str): The query to search for
count (int): The maximum number of results to return
Returns:
list[SearchResult]: A list of search results
"""
url = "https://api.tavily.com/search"
data = {"query": query, "api_key": api_key}
response = requests.post(url, json=data)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
data = {"query": query, "max_results": count}
response = requests.post(url, headers=headers, json=data)
response.raise_for_status()
json_response = response.json()
raw_search_results = json_response.get("results", [])
results = json_response.get("results", [])
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
@@ -40,5 +47,5 @@ def search_tavily(
title=result.get("title", ""),
snippet=result.get("content"),
)
for result in raw_search_results[:count]
for result in results
]
+15 -2
View File
@@ -25,6 +25,7 @@ from langchain_community.document_loaders.firecrawl import FireCrawlLoader
from langchain_community.document_loaders.base import BaseLoader
from langchain_core.documents import Document
from open_webui.retrieval.loaders.tavily import TavilyLoader
from open_webui.retrieval.loaders.external import ExternalLoader
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
ENABLE_RAG_LOCAL_WEB_FETCH,
@@ -35,6 +36,8 @@ from open_webui.config import (
FIRECRAWL_API_KEY,
TAVILY_API_KEY,
TAVILY_EXTRACT_DEPTH,
EXTERNAL_WEB_LOADER_URL,
EXTERNAL_WEB_LOADER_API_KEY,
)
from open_webui.env import SRC_LOG_LEVELS
@@ -167,7 +170,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
continue_on_failure: bool = True,
api_key: Optional[str] = None,
api_url: Optional[str] = None,
mode: Literal["crawl", "scrape", "map"] = "crawl",
mode: Literal["crawl", "scrape", "map"] = "scrape",
proxy: Optional[Dict[str, str]] = None,
params: Optional[Dict] = None,
):
@@ -225,7 +228,10 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
mode=self.mode,
params=self.params,
)
yield from loader.lazy_load()
for document in loader.lazy_load():
if not document.metadata.get("source"):
document.metadata["source"] = document.metadata.get("sourceURL")
yield document
except Exception as e:
if self.continue_on_failure:
log.exception(f"Error loading {url}: {e}")
@@ -245,6 +251,8 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
params=self.params,
)
async for document in loader.alazy_load():
if not document.metadata.get("source"):
document.metadata["source"] = document.metadata.get("sourceURL")
yield document
except Exception as e:
if self.continue_on_failure:
@@ -619,6 +627,11 @@ def get_web_loader(
web_loader_args["api_key"] = TAVILY_API_KEY.value
web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
if WEB_LOADER_ENGINE.value == "external":
WebLoaderClass = ExternalLoader
web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
if WebLoaderClass:
web_loader = WebLoaderClass(**web_loader_args)
+87
View File
@@ -0,0 +1,87 @@
import logging
from typing import Optional
import requests
from requests.auth import HTTPDigestAuth
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_yacy(
query_url: str,
username: Optional[str],
password: Optional[str],
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""
Search a Yacy instance for a given query and return the results as a list of SearchResult objects.
The function accepts username and password for authenticating to Yacy.
Args:
query_url (str): The base URL of the Yacy server.
username (str): Optional YaCy username.
password (str): Optional YaCy password.
query (str): The search term or question to find in the Yacy database.
count (int): The maximum number of results to retrieve from the search.
Returns:
list[SearchResult]: A list of SearchResults sorted by relevance score in descending order.
Raise:
requests.exceptions.RequestException: If a request error occurs during the search process.
"""
# Use authentication if either username or password is set
yacy_auth = None
if username or password:
yacy_auth = HTTPDigestAuth(username, password)
params = {
"query": query,
"contentdom": "text",
"resource": "global",
"maximumRecords": count,
"nav": "none",
}
# Check if provided a json API URL
if not query_url.endswith("yacysearch.json"):
# Strip all query parameters from the URL
query_url = query_url.rstrip("/") + "/yacysearch.json"
log.debug(f"searching {query_url}")
response = requests.get(
query_url,
auth=yacy_auth,
headers={
"User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
"Accept": "text/html",
"Accept-Encoding": "gzip, deflate",
"Accept-Language": "en-US,en;q=0.5",
"Connection": "keep-alive",
},
params=params,
)
response.raise_for_status() # Raise an exception for HTTP errors.
json_response = response.json()
results = json_response.get("channels", [{}])[0].get("items", [])
sorted_results = sorted(results, key=lambda x: x.get("ranking", 0), reverse=True)
if filter_list:
sorted_results = get_filtered_results(sorted_results, filter_list)
return [
SearchResult(
link=result["link"],
title=result.get("title"),
snippet=result.get("description"),
)
for result in sorted_results[:count]
]
+32 -4
View File
@@ -33,6 +33,7 @@ from open_webui.config import (
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_MODEL_DIR,
CACHE_DIR,
WHISPER_LANGUAGE,
)
from open_webui.constants import ERROR_MESSAGES
@@ -150,6 +151,8 @@ class STTConfigForm(BaseModel):
AZURE_API_KEY: str
AZURE_REGION: str
AZURE_LOCALES: str
AZURE_BASE_URL: str
AZURE_MAX_SPEAKERS: str
class AudioConfigUpdateForm(BaseModel):
@@ -181,6 +184,8 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
"AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION,
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
},
}
@@ -210,6 +215,10 @@ async def update_audio_config(
request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY
request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION
request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES
request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL
request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = (
form_data.stt.AZURE_MAX_SPEAKERS
)
if request.app.state.config.STT_ENGINE == "":
request.app.state.faster_whisper_model = set_faster_whisper_model(
@@ -238,6 +247,8 @@ async def update_audio_config(
"AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
"AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION,
"AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
"AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
"AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
},
}
@@ -497,7 +508,12 @@ def transcribe(request: Request, file_path):
)
model = request.app.state.faster_whisper_model
segments, info = model.transcribe(file_path, beam_size=5)
segments, info = model.transcribe(
file_path,
beam_size=5,
vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
language=WHISPER_LANGUAGE,
)
log.info(
"Detected language '%s' with probability %f"
% (info.language, info.language_probability)
@@ -637,6 +653,8 @@ def transcribe(request: Request, file_path):
api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
region = request.app.state.config.AUDIO_STT_AZURE_REGION
locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES
base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS
# IF NO LOCALES, USE DEFAULTS
if len(locales) < 2:
@@ -660,7 +678,13 @@ def transcribe(request: Request, file_path):
if not api_key or not region:
raise HTTPException(
status_code=400,
detail="Azure API key and region are required for Azure STT",
detail="Azure API key is required for Azure STT",
)
if not base_url and not region:
raise HTTPException(
status_code=400,
detail="Azure region or base url is required for Azure STT",
)
r = None
@@ -670,13 +694,17 @@ def transcribe(request: Request, file_path):
"definition": json.dumps(
{
"locales": locales.split(","),
"diarization": {"maxSpeakers": 3, "enabled": True},
"diarization": {"maxSpeakers": max_speakers, "enabled": True},
}
if locales
else {}
)
}
url = f"https://{region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
url = (
base_url
or f"https://{region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
)
# Use context manager to ensure file is properly closed
with open(file_path, "rb") as audio_file:
+52 -19
View File
@@ -27,20 +27,24 @@ from open_webui.env import (
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
SRC_LOG_LEVELS,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.auth import (
decode_token,
create_api_key,
create_token,
get_admin_user,
get_verified_user,
get_current_user,
get_password_hash,
get_http_authorization_cred,
)
from open_webui.utils.webhook import post_webhook
from open_webui.utils.access_control import get_permissions
@@ -72,27 +76,29 @@ class SessionUserResponse(Token, UserResponse):
async def get_session_user(
request: Request, response: Response, user=Depends(get_current_user)
):
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=expires_delta,
)
auth_header = request.headers.get("Authorization")
auth_token = get_http_authorization_cred(auth_header)
token = auth_token.credentials
data = decode_token(token)
datetime_expires_at = (
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
)
expires_at = data.get("exp")
if (expires_at is not None) and int(time.time()) > expires_at:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=datetime_expires_at,
expires=(
datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
if expires_at
else None
),
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
@@ -230,13 +236,17 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
entry = connection_app.entries[0]
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
email = entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"]
email = entry[
f"{LDAP_ATTRIBUTE_FOR_MAIL}"
].value # retrieve the Attribute value
if not email:
raise HTTPException(400, "User does not have a valid email address.")
elif isinstance(email, str):
email = email.lower()
elif isinstance(email, list):
email = email[0].lower()
else:
email = str(email).lower()
cn = str(entry["cn"])
user_dn = entry.entry_dn
@@ -286,18 +296,30 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
user = Auths.authenticate_user_by_trusted_header(email)
if user:
expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
expires_at = None
if expires_delta:
expires_at = int(time.time()) + int(expires_delta.total_seconds())
token = create_token(
data={"id": user.id},
expires_delta=parse_duration(
request.app.state.config.JWT_EXPIRES_IN
),
expires_delta=expires_delta,
)
# Set the cookie token
response.set_cookie(
key="token",
value=token,
expires=(
datetime.datetime.fromtimestamp(
expires_at, datetime.timezone.utc
)
if expires_at
else None
),
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
@@ -307,6 +329,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
return {
"token": token,
"token_type": "Bearer",
"expires_at": expires_at,
"id": user.id,
"email": user.email,
"name": user.name,
@@ -564,6 +587,12 @@ async def signout(request: Request, response: Response):
detail="Failed to sign out from the OpenID provider.",
)
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
return RedirectResponse(
headers=response.headers,
url=WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
)
return {"status": True}
@@ -662,6 +691,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
}
@@ -678,6 +708,7 @@ class AdminConfig(BaseModel):
ENABLE_COMMUNITY_SHARING: bool
ENABLE_MESSAGE_RATING: bool
ENABLE_CHANNELS: bool
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
@@ -698,6 +729,7 @@ async def update_admin_config(
)
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
@@ -722,11 +754,12 @@ async def update_admin_config(
"ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
"ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
"API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
"JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
"ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
"ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
"ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
"ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
"ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
}
+10 -1
View File
@@ -638,8 +638,17 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/share", response_model=Optional[ChatResponse])
async def share_chat_by_id(id: str, user=Depends(get_verified_user)):
async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if not has_permission(
user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chat = Chats.get_chat_by_id_and_user_id(id, user.id)
if chat:
if chat.share_id:
shared_chat = Chats.update_shared_chat_by_chat_id(chat.id)
+3 -5
View File
@@ -56,7 +56,7 @@ async def update_config(
}
class FeedbackUserReponse(BaseModel):
class UserResponse(BaseModel):
id: str
name: str
email: str
@@ -68,7 +68,7 @@ class FeedbackUserReponse(BaseModel):
class FeedbackUserResponse(FeedbackResponse):
user: Optional[FeedbackUserReponse] = None
user: Optional[UserResponse] = None
@router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
@@ -77,9 +77,7 @@ async def get_all_feedbacks(user=Depends(get_admin_user)):
return [
FeedbackUserResponse(
**feedback.model_dump(),
user=FeedbackUserReponse(
**Users.get_user_by_id(feedback.user_id).model_dump()
),
user=UserResponse(**Users.get_user_by_id(feedback.user_id).model_dump()),
)
for feedback in feedbacks
]
+42 -11
View File
@@ -19,6 +19,8 @@ from fastapi import (
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.users import Users
from open_webui.models.files import (
FileForm,
FileModel,
@@ -83,10 +85,12 @@ def upload_file(
request: Request,
file: UploadFile = File(...),
user=Depends(get_verified_user),
file_metadata: dict = {},
file_metadata: dict = None,
process: bool = Query(True),
):
log.info(f"file.content_type: {file.content_type}")
file_metadata = file_metadata if file_metadata else {}
try:
unsanitized_filename = file.filename
filename = os.path.basename(unsanitized_filename)
@@ -95,7 +99,13 @@ def upload_file(
id = str(uuid.uuid4())
name = filename
filename = f"{id}_{filename}"
contents, file_path = Storage.upload_file(file.file, filename)
tags = {
"OpenWebUI-User-Email": user.email,
"OpenWebUI-User-Id": user.id,
"OpenWebUI-User-Name": user.name,
"OpenWebUI-File-Id": id,
}
contents, file_path = Storage.upload_file(file.file, filename, tags)
file_item = Files.insert_new_file(
user.id,
@@ -115,12 +125,16 @@ def upload_file(
)
if process:
try:
if file.content_type in [
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
]:
if file.content_type.startswith(
(
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/x-m4a",
"audio/webm",
)
):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path)
@@ -129,7 +143,15 @@ def upload_file(
ProcessFileForm(file_id=id, content=result.get("text", "")),
user=user,
)
elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]:
elif file.content_type not in [
"image/png",
"image/jpeg",
"image/gif",
"video/mp4",
"video/ogg",
"video/quicktime",
"video/webm",
]:
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
@@ -173,7 +195,8 @@ async def list_files(user=Depends(get_verified_user), content: bool = Query(True
if not content:
for file in files:
del file.data["content"]
if "content" in file.data:
del file.data["content"]
return files
@@ -214,7 +237,8 @@ async def search_files(
if not content:
for file in matching_files:
del file.data["content"]
if "content" in file.data:
del file.data["content"]
return matching_files
@@ -431,6 +455,13 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
file_user = Users.get_user_by_id(file.user_id)
if not file_user.role == "admin":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
file.user_id == user.id
or user.role == "admin"
+5 -1
View File
@@ -500,7 +500,11 @@ async def image_generations(
if form_data.size
else request.app.state.config.IMAGE_SIZE
),
"response_format": "b64_json",
**(
{"response_format": "b64_json"}
if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL
else {}
),
}
# Use asyncio.to_thread for the requests.post call
+34 -20
View File
@@ -9,7 +9,7 @@ from open_webui.models.knowledge import (
KnowledgeResponse,
KnowledgeUserResponse,
)
from open_webui.models.files import Files, FileModel
from open_webui.models.files import Files, FileModel, FileMetadataResponse
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.routers.retrieval import (
process_file,
@@ -178,10 +178,26 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases")
for knowledge_base in knowledge_bases:
try:
files = Files.get_files_by_ids(knowledge_base.data.get("file_ids", []))
deleted_knowledge_bases = []
for knowledge_base in knowledge_bases:
# -- Robust error handling for missing or invalid data
if not knowledge_base.data or not isinstance(knowledge_base.data, dict):
log.warning(
f"Knowledge base {knowledge_base.id} has no data or invalid data ({knowledge_base.data!r}). Deleting."
)
try:
Knowledges.delete_knowledge_by_id(id=knowledge_base.id)
deleted_knowledge_bases.append(knowledge_base.id)
except Exception as e:
log.error(
f"Failed to delete invalid knowledge base {knowledge_base.id}: {e}"
)
continue
try:
file_ids = knowledge_base.data.get("file_ids", [])
files = Files.get_files_by_ids(file_ids)
try:
if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
VECTOR_DB_CLIENT.delete_collection(
@@ -189,10 +205,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
)
except Exception as e:
log.error(f"Error deleting collection {knowledge_base.id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error deleting vector DB collection",
)
continue # Skip, don't raise
failed_files = []
for file in files:
@@ -213,10 +226,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
except Exception as e:
log.error(f"Error processing knowledge base {knowledge_base.id}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error processing knowledge base",
)
# Don't raise, just continue
continue
if failed_files:
log.warning(
@@ -225,7 +236,9 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
for failed in failed_files:
log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}")
log.info("Reindexing completed successfully")
log.info(
f"Reindexing completed. Deleted {len(deleted_knowledge_bases)} invalid knowledge bases: {deleted_knowledge_bases}"
)
return True
@@ -235,7 +248,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
class KnowledgeFilesResponse(KnowledgeResponse):
files: list[FileModel]
files: list[FileMetadataResponse]
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
@@ -251,7 +264,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
):
file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
files = Files.get_files_by_ids(file_ids)
files = Files.get_file_metadatas_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
@@ -379,7 +392,7 @@ def add_file_to_knowledge_by_id(
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
if knowledge:
files = Files.get_files_by_ids(file_ids)
files = Files.get_file_metadatas_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
@@ -456,7 +469,7 @@ def update_file_from_knowledge_by_id(
data = knowledge.data or {}
file_ids = data.get("file_ids", [])
files = Files.get_files_by_ids(file_ids)
files = Files.get_file_metadatas_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
@@ -538,7 +551,7 @@ def remove_file_from_knowledge_by_id(
knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
if knowledge:
files = Files.get_files_by_ids(file_ids)
files = Files.get_file_metadatas_by_ids(file_ids)
return KnowledgeFilesResponse(
**knowledge.model_dump(),
@@ -734,7 +747,7 @@ def add_files_to_knowledge_batch(
error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Files.get_files_by_ids(existing_file_ids),
files=Files.get_file_metadatas_by_ids(existing_file_ids),
warnings={
"message": "Some files failed to process",
"errors": error_details,
@@ -742,5 +755,6 @@ def add_files_to_knowledge_batch(
)
return KnowledgeFilesResponse(
**knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids)
**knowledge.model_dump(),
files=Files.get_file_metadatas_by_ids(existing_file_ids),
)
+212
View File
@@ -0,0 +1,212 @@
import json
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel
from open_webui.models.users import Users, UserResponse
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
router = APIRouter()
############################
# GetNotes
############################
@router.get("/", response_model=list[NoteUserResponse])
async def get_notes(request: Request, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_user_id(user.id, "write")
]
return notes
@router.get("/list", response_model=list[NoteUserResponse])
async def get_note_list(request: Request, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_user_id(user.id, "read")
]
return notes
############################
# CreateNewNote
############################
@router.post("/create", response_model=Optional[NoteModel])
async def create_new_note(
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
try:
note = Notes.insert_new_note(form_data, user.id)
return note
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# GetNoteById
############################
@router.get("/{id}", response_model=Optional[NoteModel])
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=note.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
return note
############################
# UpdateNoteById
############################
@router.post("/{id}/update", response_model=Optional[NoteModel])
async def update_note_by_id(
request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user)
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="write", access_control=note.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
note = Notes.update_note_by_id(id, form_data)
return note
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# DeleteNoteById
############################
@router.delete("/{id}/delete", response_model=bool)
async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
note = Notes.get_note_by_id(id)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="write", access_control=note.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
note = Notes.delete_note_by_id(id)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
+20 -4
View File
@@ -54,6 +54,7 @@ from open_webui.config import (
from open_webui.env import (
ENV,
SRC_LOG_LEVELS,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
BYPASS_MODEL_ACCESS_CONTROL,
@@ -91,6 +92,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
except Exception as e:
@@ -141,6 +143,7 @@ async def send_post_request(
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
@@ -216,7 +219,8 @@ async def verify_connection(
key = form_data.key
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
async with session.get(
@@ -234,6 +238,7 @@ async def verify_connection(
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
detail = f"HTTP Error: {r.status}"
@@ -880,6 +885,10 @@ async def embed(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
form_data.model = form_data.model.replace(f"{prefix_id}.", "")
try:
r = requests.request(
method="POST",
@@ -959,6 +968,10 @@ async def embeddings(
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
form_data.model = form_data.model.replace(f"{prefix_id}.", "")
try:
r = requests.request(
method="POST",
@@ -1006,7 +1019,7 @@ class GenerateCompletionForm(BaseModel):
prompt: str
suffix: Optional[str] = None
images: Optional[list[str]] = None
format: Optional[str] = None
format: Optional[Union[dict, str]] = None
options: Optional[dict] = None
system: Optional[str] = None
template: Optional[str] = None
@@ -1482,7 +1495,9 @@ async def download_file_stream(
timeout = aiohttp.ClientTimeout(total=600) # Set the timeout
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(file_url, headers=headers) as response:
async with session.get(
file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as response:
total_size = int(response.headers.get("content-length", 0)) + current_size
with open(file_path, "ab+") as file:
@@ -1497,7 +1512,8 @@ async def download_file_stream(
if done:
file.seek(0)
hashed = calculate_sha256(file)
chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
file.seek(0)
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
+19 -12
View File
@@ -21,6 +21,7 @@ from open_webui.config import (
CACHE_DIR,
)
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
ENABLE_FORWARD_USER_INFO_HEADERS,
@@ -74,6 +75,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await response.json()
except Exception as e:
@@ -92,20 +94,19 @@ async def cleanup_response(
await session.close()
def openai_o1_o3_handler(payload):
def openai_o_series_handler(payload):
"""
Handle o1, o3 specific parameters
Handle "o" series specific parameters
"""
if "max_tokens" in payload:
# Remove "max_tokens" from the payload
# Convert "max_tokens" to "max_completion_tokens" for all o-series models
payload["max_completion_tokens"] = payload["max_tokens"]
del payload["max_tokens"]
# Fix: o1 and o3 do not support the "system" role directly.
# For older models like "o1-mini" or "o1-preview", use role "user".
# For newer o1/o3 models, replace "system" with "developer".
# Handle system role conversion based on model type
if payload["messages"][0]["role"] == "system":
model_lower = payload["model"].lower()
# Legacy models use "user" role instead of "system"
if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"):
payload["messages"][0]["role"] = "user"
else:
@@ -462,7 +463,8 @@ async def get_models(
r = None
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
async with session.get(
@@ -481,6 +483,7 @@ async def get_models(
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
@@ -542,7 +545,8 @@ async def verify_connection(
key = form_data.key
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
trust_env=True,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
async with session.get(
@@ -561,6 +565,7 @@ async def verify_connection(
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
# Extract response error details if available
@@ -666,10 +671,10 @@ async def generate_chat_completion(
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
# Fix: o1,o3 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens"
is_o1_o3 = payload["model"].lower().startswith(("o1", "o3-"))
if is_o1_o3:
payload = openai_o1_o3_handler(payload)
# Check if model is from "o" series
is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4"))
if is_o_series:
payload = openai_o_series_handler(payload)
elif "api.openai.com" not in url:
# Remove "max_completion_tokens" from the payload for backward compatibility
if "max_completion_tokens" in payload:
@@ -723,6 +728,7 @@ async def generate_chat_completion(
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
# Check if response is SSE
@@ -802,6 +808,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
else {}
),
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
r.raise_for_status()
+2 -2
View File
@@ -66,7 +66,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
if "pipeline" in model:
sorted_filters.append(model)
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
for filter in sorted_filters:
urlIdx = filter.get("urlIdx")
if urlIdx is None:
@@ -115,7 +115,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
if "pipeline" in model:
sorted_filters = [model] + sorted_filters
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
for filter in sorted_filters:
urlIdx = filter.get("urlIdx")
if urlIdx is None:
+118 -17
View File
@@ -53,6 +53,7 @@ from open_webui.retrieval.web.jina_search import search_jina
from open_webui.retrieval.web.searchapi import search_searchapi
from open_webui.retrieval.web.serpapi import search_serpapi
from open_webui.retrieval.web.searxng import search_searxng
from open_webui.retrieval.web.yacy import search_yacy
from open_webui.retrieval.web.serper import search_serper
from open_webui.retrieval.web.serply import search_serply
from open_webui.retrieval.web.serpstack import search_serpstack
@@ -61,6 +62,8 @@ from open_webui.retrieval.web.bing import search_bing
from open_webui.retrieval.web.exa import search_exa
from open_webui.retrieval.web.perplexity import search_perplexity
from open_webui.retrieval.web.sougou import search_sougou
from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.external import search_external
from open_webui.retrieval.utils import (
get_embedding_function,
@@ -90,7 +93,12 @@ from open_webui.env import (
SRC_LOG_LEVELS,
DEVICE_TYPE,
DOCKER,
SENTENCE_TRANSFORMERS_BACKEND,
SENTENCE_TRANSFORMERS_MODEL_KWARGS,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
)
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
@@ -117,6 +125,8 @@ def get_ef(
get_model_path(embedding_model, auto_update),
device=DEVICE_TYPE,
trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
backend=SENTENCE_TRANSFORMERS_BACKEND,
model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS,
)
except Exception as e:
log.debug(f"Error loading SentenceTransformer: {e}")
@@ -150,6 +160,8 @@ def get_rf(
get_model_path(reranking_model, auto_update),
device=DEVICE_TYPE,
trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
)
except Exception as e:
log.error(f"CrossEncoder: {e}")
@@ -366,6 +378,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
"MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
@@ -389,6 +403,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
"YACY_PASSWORD": request.app.state.config.YACY_PASSWORD,
"GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY,
"GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID,
"BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY,
@@ -418,6 +435,10 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
"FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
"TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
"EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
"EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
"EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL,
"EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY,
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
@@ -434,6 +455,9 @@ class WebConfig(BaseModel):
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
SEARXNG_QUERY_URL: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None
YACY_PASSWORD: Optional[str] = None
GOOGLE_PSE_API_KEY: Optional[str] = None
GOOGLE_PSE_ENGINE_ID: Optional[str] = None
BRAVE_SEARCH_API_KEY: Optional[str] = None
@@ -463,6 +487,10 @@ class WebConfig(BaseModel):
FIRECRAWL_API_KEY: Optional[str] = None
FIRECRAWL_API_BASE_URL: Optional[str] = None
TAVILY_EXTRACT_DEPTH: Optional[str] = None
EXTERNAL_WEB_SEARCH_URL: Optional[str] = None
EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None
EXTERNAL_WEB_LOADER_URL: Optional[str] = None
EXTERNAL_WEB_LOADER_API_KEY: Optional[str] = None
YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None
YOUTUBE_LOADER_PROXY_URL: Optional[str] = None
YOUTUBE_LOADER_TRANSLATION: Optional[str] = None
@@ -485,6 +513,8 @@ class ConfigForm(BaseModel):
PDF_EXTRACT_IMAGES: Optional[bool] = None
TIKA_SERVER_URL: Optional[str] = None
DOCLING_SERVER_URL: Optional[str] = None
DOCLING_OCR_ENGINE: Optional[str] = None
DOCLING_OCR_LANG: Optional[str] = None
DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None
DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None
MISTRAL_OCR_API_KEY: Optional[str] = None
@@ -574,6 +604,16 @@ async def update_rag_config(
if form_data.DOCLING_SERVER_URL is not None
else request.app.state.config.DOCLING_SERVER_URL
)
request.app.state.config.DOCLING_OCR_ENGINE = (
form_data.DOCLING_OCR_ENGINE
if form_data.DOCLING_OCR_ENGINE is not None
else request.app.state.config.DOCLING_OCR_ENGINE
)
request.app.state.config.DOCLING_OCR_LANG = (
form_data.DOCLING_OCR_LANG
if form_data.DOCLING_OCR_LANG is not None
else request.app.state.config.DOCLING_OCR_LANG
)
request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
form_data.DOCUMENT_INTELLIGENCE_ENDPOINT
if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None
@@ -651,6 +691,9 @@ async def update_rag_config(
form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
)
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY
request.app.state.config.GOOGLE_PSE_ENGINE_ID = (
form_data.web.GOOGLE_PSE_ENGINE_ID
@@ -697,6 +740,18 @@ async def update_rag_config(
request.app.state.config.FIRECRAWL_API_BASE_URL = (
form_data.web.FIRECRAWL_API_BASE_URL
)
request.app.state.config.EXTERNAL_WEB_SEARCH_URL = (
form_data.web.EXTERNAL_WEB_SEARCH_URL
)
request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = (
form_data.web.EXTERNAL_WEB_SEARCH_API_KEY
)
request.app.state.config.EXTERNAL_WEB_LOADER_URL = (
form_data.web.EXTERNAL_WEB_LOADER_URL
)
request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = (
form_data.web.EXTERNAL_WEB_LOADER_API_KEY
)
request.app.state.config.TAVILY_EXTRACT_DEPTH = (
form_data.web.TAVILY_EXTRACT_DEPTH
)
@@ -726,6 +781,8 @@ async def update_rag_config(
"PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
"DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
"MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
@@ -749,6 +806,9 @@ async def update_rag_config(
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME,
"YACY_PASSWORD": request.app.state.config.YACY_PASSWORD,
"GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY,
"GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID,
"BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY,
@@ -778,6 +838,10 @@ async def update_rag_config(
"FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
"FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
"TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
"EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
"EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
"EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL,
"EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY,
"YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
"YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
"YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
@@ -1032,6 +1096,8 @@ def process_file(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_OCR_ENGINE=request.app.state.config.DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG=request.app.state.config.DOCLING_OCR_LANG,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
@@ -1266,6 +1332,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
"""Search the web using a search engine and return the results as a list of SearchResult objects.
Will look for a search engine API key in environment variables in the following order:
- SEARXNG_QUERY_URL
- YACY_QUERY_URL + YACY_USERNAME + YACY_PASSWORD
- GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID
- BRAVE_SEARCH_API_KEY
- KAGI_SEARCH_API_KEY
@@ -1295,6 +1362,18 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
)
else:
raise Exception("No SEARXNG_QUERY_URL found in environment variables")
elif engine == "yacy":
if request.app.state.config.YACY_QUERY_URL:
return search_yacy(
request.app.state.config.YACY_QUERY_URL,
request.app.state.config.YACY_USERNAME,
request.app.state.config.YACY_PASSWORD,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No YACY_QUERY_URL found in environment variables")
elif engine == "google_pse":
if (
request.app.state.config.GOOGLE_PSE_API_KEY
@@ -1465,6 +1544,22 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
raise Exception(
"No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables"
)
elif engine == "firecrawl":
return search_firecrawl(
request.app.state.config.FIRECRAWL_API_BASE_URL,
request.app.state.config.FIRECRAWL_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
elif engine == "external":
return search_external(
request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No search engine API key found in environment variables")
@@ -1477,8 +1572,11 @@ async def process_web_search(
logging.info(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.query}"
)
web_results = search_web(
request, request.app.state.config.WEB_SEARCH_ENGINE, form_data.query
web_results = await run_in_threadpool(
search_web,
request,
request.app.state.config.WEB_SEARCH_ENGINE,
form_data.query,
)
except Exception as e:
log.exception(e)
@@ -1500,8 +1598,8 @@ async def process_web_search(
)
docs = await loader.aload()
urls = [
doc.metadata["source"] for doc in docs
] # only keep URLs which could be retrieved
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep URLs
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
@@ -1520,20 +1618,23 @@ async def process_web_search(
else:
collection_names = []
for doc_idx, doc in enumerate(docs):
collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[
:63
]
if doc and doc.page_content:
try:
collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[
:63
]
collection_names.append(collection_name)
await run_in_threadpool(
save_docs_to_vector_db,
request,
[doc],
collection_name,
overwrite=True,
user=user,
)
collection_names.append(collection_name)
await run_in_threadpool(
save_docs_to_vector_db,
request,
[doc],
collection_name,
overwrite=True,
user=user,
)
except Exception as e:
log.debug(f"error saving doc {doc_idx}: {e}")
return {
"status": True,
+67 -4
View File
@@ -6,6 +6,7 @@ from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
UserModel,
UserListResponse,
UserRoleUpdateForm,
Users,
UserSettings,
@@ -33,13 +34,38 @@ router = APIRouter()
############################
@router.get("/", response_model=list[UserModel])
PAGE_ITEM_COUNT = 10
@router.get("/", response_model=UserListResponse)
async def get_users(
skip: Optional[int] = None,
limit: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_admin_user),
):
return Users.get_users(skip, limit)
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
return Users.get_users(filter=filter, skip=skip, limit=limit)
@router.get("/all", response_model=UserListResponse)
async def get_all_users(
user=Depends(get_admin_user),
):
return Users.get_users()
############################
@@ -88,6 +114,11 @@ class ChatPermissions(BaseModel):
file_upload: bool = True
delete: bool = True
edit: bool = True
share: bool = True
export: bool = True
stt: bool = True
tts: bool = True
call: bool = True
multiple_models: bool = True
temporary: bool = True
temporary_enforced: bool = False
@@ -98,6 +129,7 @@ class FeaturesPermissions(BaseModel):
web_search: bool = True
image_generation: bool = True
code_interpreter: bool = True
notes: bool = True
class UserPermissions(BaseModel):
@@ -285,6 +317,21 @@ async def update_user_by_id(
form_data: UserUpdateForm,
session_user=Depends(get_admin_user),
):
# Prevent modification of the primary admin user by other admins
try:
first_user = Users.get_first_user()
if first_user and user_id == first_user.id and session_user.id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
except Exception as e:
log.error(f"Error checking primary admin status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not verify primary admin status.",
)
user = Users.get_user_by_id(user_id)
if user:
@@ -332,6 +379,21 @@ async def update_user_by_id(
@router.delete("/{user_id}", response_model=bool)
async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
# Prevent deletion of the primary admin user
try:
first_user = Users.get_first_user()
if first_user and user_id == first_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
except Exception as e:
log.error(f"Error checking primary admin status: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not verify primary admin status.",
)
if user.id != user_id:
result = Auths.delete_auth_by_id(user_id)
@@ -343,6 +405,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
detail=ERROR_MESSAGES.DELETE_USER_ERROR,
)
# Prevent self-deletion
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+19 -11
View File
@@ -192,6 +192,9 @@ async def connect(sid, environ, auth):
# print(f"user {user.name}({user.id}) connected with session ID {sid}")
await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
await sio.emit("usage", {"models": get_models_in_use()})
return True
return False
@sio.on("user-join")
@@ -314,8 +317,8 @@ def get_event_emitter(request_info, update_db=True):
)
)
for session_id in session_ids:
await sio.emit(
emit_tasks = [
sio.emit(
"chat-events",
{
"chat_id": request_info.get("chat_id", None),
@@ -324,6 +327,10 @@ def get_event_emitter(request_info, update_db=True):
},
to=session_id,
)
for session_id in session_ids
]
await asyncio.gather(*emit_tasks)
if update_db:
if "type" in event_data and event_data["type"] == "status":
@@ -339,16 +346,17 @@ def get_event_emitter(request_info, update_db=True):
request_info["message_id"],
)
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
if message:
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
+25 -9
View File
@@ -3,7 +3,7 @@ import shutil
import json
import logging
from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple
from typing import BinaryIO, Tuple, Dict
import boto3
from botocore.config import Config
@@ -44,7 +44,9 @@ class StorageProvider(ABC):
pass
@abstractmethod
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
def upload_file(
self, file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]:
pass
@abstractmethod
@@ -58,7 +60,9 @@ class StorageProvider(ABC):
class LocalStorageProvider(StorageProvider):
@staticmethod
def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]:
def upload_file(
file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]:
contents = file.read()
if not contents:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
@@ -131,12 +135,20 @@ class S3StorageProvider(StorageProvider):
self.bucket_name = S3_BUCKET_NAME
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
def upload_file(
self, file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
_, file_path = LocalStorageProvider.upload_file(file, filename)
_, file_path = LocalStorageProvider.upload_file(file, filename, tags)
tagging = {"TagSet": [{"Key": k, "Value": v} for k, v in tags.items()]}
try:
s3_key = os.path.join(self.key_prefix, filename)
self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
self.s3_client.put_object_tagging(
Bucket=self.bucket_name,
Key=s3_key,
Tagging=tagging,
)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + s3_key,
@@ -207,9 +219,11 @@ class GCSStorageProvider(StorageProvider):
self.gcs_client = storage.Client()
self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME)
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
def upload_file(
self, file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]:
"""Handles uploading of the file to GCS storage."""
contents, file_path = LocalStorageProvider.upload_file(file, filename)
contents, file_path = LocalStorageProvider.upload_file(file, filename, tags)
try:
blob = self.bucket.blob(filename)
blob.upload_from_filename(file_path)
@@ -277,9 +291,11 @@ class AzureStorageProvider(StorageProvider):
self.container_name
)
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
def upload_file(
self, file: BinaryIO, filename: str, tags: Dict[str, str]
) -> Tuple[bytes, str]:
"""Handles uploading of the file to Azure Blob Storage."""
contents, file_path = LocalStorageProvider.upload_file(file, filename)
contents, file_path = LocalStorageProvider.upload_file(file, filename, tags)
try:
blob_client = self.container_client.get_blob_client(filename)
blob_client.upload_blob(contents, overwrite=True)
+44 -10
View File
@@ -37,7 +37,7 @@ if TYPE_CHECKING:
class AuditLogEntry:
# `Metadata` audit level properties
id: str
user: dict[str, Any]
user: Optional[dict[str, Any]]
audit_level: str
verb: str
request_uri: str
@@ -190,21 +190,40 @@ class AuditLoggingMiddleware:
finally:
await self._log_audit_entry(request, context)
async def _get_authenticated_user(self, request: Request) -> UserModel:
async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
auth_header = request.headers.get("Authorization")
assert auth_header
user = get_current_user(request, None, get_http_authorization_cred(auth_header))
return user
try:
user = get_current_user(
request, None, get_http_authorization_cred(auth_header)
)
return user
except Exception as e:
logger.debug(f"Failed to get authenticated user: {str(e)}")
return None
def _should_skip_auditing(self, request: Request) -> bool:
if (
request.method not in {"POST", "PUT", "PATCH", "DELETE"}
or AUDIT_LOG_LEVEL == "NONE"
or not request.headers.get("authorization")
):
return True
ALWAYS_LOG_ENDPOINTS = {
"/api/v1/auths/signin",
"/api/v1/auths/signout",
"/api/v1/auths/signup",
}
path = request.url.path.lower()
for endpoint in ALWAYS_LOG_ENDPOINTS:
if path.startswith(endpoint):
return False # Do NOT skip logging for auth endpoints
# Skip logging if the request is not authenticated
if not request.headers.get("authorization"):
return True
# match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...
pattern = re.compile(
r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b"
@@ -231,17 +250,32 @@ class AuditLoggingMiddleware:
try:
user = await self._get_authenticated_user(request)
user = (
user.model_dump(include={"id", "name", "email", "role"}) if user else {}
)
request_body = context.request_body.decode("utf-8", errors="replace")
response_body = context.response_body.decode("utf-8", errors="replace")
# Redact sensitive information
if "password" in request_body:
request_body = re.sub(
r'"password":\s*"(.*?)"',
'"password": "********"',
request_body,
)
entry = AuditLogEntry(
id=str(uuid.uuid4()),
user=user.model_dump(include={"id", "name", "email", "role"}),
user=user,
audit_level=self.audit_level.value,
verb=request.method,
request_uri=str(request.url),
response_status_code=context.metadata.get("response_status_code", None),
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
request_object=context.request_body.decode("utf-8", errors="replace"),
response_object=context.response_body.decode("utf-8", errors="replace"),
request_object=request_body,
response_object=response_body,
)
self.audit_logger.write(entry)
+1 -1
View File
@@ -50,7 +50,7 @@ class JupyterCodeExecuter:
self.password = password
self.timeout = timeout
self.kernel_id = ""
self.session = aiohttp.ClientSession(base_url=self.base_url)
self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url)
self.params = {}
self.result = ResultModel()
+67 -36
View File
@@ -355,29 +355,33 @@ async def chat_web_search_handler(
all_results = []
for searchQuery in queries:
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": 'Searching "{{searchQuery}}"',
"query": searchQuery,
"done": False,
},
}
)
await event_emitter(
{
"type": "status",
"data": {
"action": "web_search",
"description": "Searching the web",
"done": False,
},
}
)
try:
results = await process_web_search(
gathered_results = await asyncio.gather(
*(
process_web_search(
request,
SearchForm(
**{
"query": searchQuery,
}
),
SearchForm(**{"query": searchQuery}),
user=user,
)
for searchQuery in queries
),
return_exceptions=True,
)
for searchQuery, results in zip(queries, gathered_results):
try:
if isinstance(results, Exception):
raise Exception(f"Error searching {searchQuery}: {str(results)}")
if results:
all_results.append(results)
@@ -534,13 +538,20 @@ async def chat_image_generation_handler(
}
)
for image in images:
await __event_emitter__(
{
"type": "message",
"data": {"content": f"![Generated Image]({image['url']})\n"},
}
)
await __event_emitter__(
{
"type": "files",
"data": {
"files": [
{
"type": "image",
"url": image["url"],
}
for image in images
]
},
}
)
system_message_content = "<context>User is shown the generated image, tell the user that the image has been generated</context>"
except Exception as e:
@@ -881,16 +892,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# If context is not empty, insert it into the messages
if len(sources) > 0:
context_string = ""
citated_file_idx = {}
for _, source in enumerate(sources, 1):
citation_idx = {}
for source in sources:
if "document" in source:
for doc_context, doc_meta in zip(
source["document"], source["metadata"]
):
file_id = doc_meta.get("file_id")
if file_id not in citated_file_idx:
citated_file_idx[file_id] = len(citated_file_idx) + 1
context_string += f'<source id="{citated_file_idx[file_id]}">{doc_context}</source>\n'
citation_id = (
doc_meta.get("source", None)
or source.get("source", {}).get("id", None)
or "N/A"
)
if citation_id not in citation_idx:
citation_idx[citation_id] = len(citation_idx) + 1
context_string += f'<source id="{citation_idx[citation_id]}">{doc_context}</source>\n'
context_string = context_string.strip()
prompt = get_last_user_message(form_data["messages"])
@@ -923,7 +938,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
# If there are citations, add them to the data_items
sources = [source for source in sources if source.get("source", {}).get("name", "")]
sources = [
source
for source in sources
if source.get("source", {}).get("name", "")
or source.get("source", {}).get("id", "")
]
if len(sources) > 0:
events.append({"sources": sources})
@@ -1122,7 +1142,7 @@ async def process_chat_response(
)
# Send a webhook notification if the user is not active
if get_active_status_by_user_id(user.id) is None:
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
@@ -1411,8 +1431,10 @@ async def process_chat_response(
if after_tag:
content_blocks[-1]["content"] = after_tag
content = after_tag
break
elif content_blocks[-1]["type"] == content_type:
if content and content_blocks[-1]["type"] == content_type:
start_tag = content_blocks[-1]["start_tag"]
end_tag = content_blocks[-1]["end_tag"]
# Match end tag e.g., </tag>
@@ -1660,6 +1682,15 @@ async def process_chat_response(
if current_response_tool_call is None:
# Add the new tool call
delta_tool_call.setdefault(
"function", {}
)
delta_tool_call[
"function"
].setdefault("name", "")
delta_tool_call[
"function"
].setdefault("arguments", "")
response_tool_calls.append(
delta_tool_call
)
@@ -2204,7 +2235,7 @@ async def process_chat_response(
)
# Send a webhook notification if the user is not active
if get_active_status_by_user_id(user.id) is None:
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
post_webhook(
+1
View File
@@ -203,6 +203,7 @@ async def get_all_models(request, user: UserModel = None):
else:
function_module, _, _ = load_function_module_by_id(function_id)
request.app.state.FUNCTIONS[function_id] = function_module
return function_module
for model in models:
action_ids = [
+65 -4
View File
@@ -3,6 +3,7 @@ import logging
import mimetypes
import sys
import uuid
import json
import aiohttp
from authlib.integrations.starlette_client import OAuth
@@ -15,7 +16,7 @@ from starlette.responses import RedirectResponse
from open_webui.models.auths import Auths
from open_webui.models.users import Users
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
from open_webui.config import (
DEFAULT_USER_ROLE,
ENABLE_OAUTH_SIGNUP,
@@ -23,6 +24,8 @@ from open_webui.config import (
OAUTH_PROVIDERS,
ENABLE_OAUTH_ROLE_MANAGEMENT,
ENABLE_OAUTH_GROUP_MANAGEMENT,
ENABLE_OAUTH_GROUP_CREATION,
OAUTH_BLOCKED_GROUPS,
OAUTH_ROLES_CLAIM,
OAUTH_GROUPS_CLAIM,
OAUTH_EMAIL_CLAIM,
@@ -57,6 +60,8 @@ auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
@@ -140,6 +145,12 @@ class OAuthManager:
log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
try:
blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS)
except Exception as e:
log.exception(f"Error loading OAUTH_BLOCKED_GROUPS: {e}")
blocked_groups = []
user_oauth_groups = []
# Nested claim search for groups claim
if oauth_claim:
@@ -152,6 +163,51 @@ class OAuthManager:
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_groups()
# Create groups if they don't exist and creation is enabled
if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION:
log.debug("Checking for missing groups to create...")
all_group_names = {g.name for g in all_available_groups}
groups_created = False
# Determine creator ID: Prefer admin, fallback to current user if no admin exists
admin_user = Users.get_admin_user()
creator_id = admin_user.id if admin_user else user.id
log.debug(f"Using creator ID {creator_id} for potential group creation.")
for group_name in user_oauth_groups:
if group_name not in all_group_names:
log.info(
f"Group '{group_name}' not found via OAuth claim. Creating group..."
)
try:
new_group_form = GroupForm(
name=group_name,
description=f"Group '{group_name}' created automatically via OAuth.",
permissions=default_permissions, # Use default permissions from function args
user_ids=[], # Start with no users, user will be added later by subsequent logic
)
# Use determined creator ID (admin or fallback to current user)
created_group = Groups.insert_new_group(
creator_id, new_group_form
)
if created_group:
log.info(
f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}"
)
groups_created = True
# Add to local set to prevent duplicate creation attempts in this run
all_group_names.add(group_name)
else:
log.error(
f"Failed to create group '{group_name}' via OAuth."
)
except Exception as e:
log.error(f"Error creating group '{group_name}' via OAuth: {e}")
# Refresh the list of all available groups if any were created
if groups_created:
all_available_groups = Groups.get_groups()
log.debug("Refreshed list of all available groups after creation.")
log.debug(f"Oauth Groups claim: {oauth_claim}")
log.debug(f"User oauth groups: {user_oauth_groups}")
log.debug(f"User's current groups: {[g.name for g in user_current_groups]}")
@@ -161,7 +217,11 @@ class OAuthManager:
# Remove groups that user is no longer a part of
for group_model in user_current_groups:
if user_oauth_groups and group_model.name not in user_oauth_groups:
if (
user_oauth_groups
and group_model.name not in user_oauth_groups
and group_model.name not in blocked_groups
):
# Remove group from user
log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
@@ -191,6 +251,7 @@ class OAuthManager:
user_oauth_groups
and group_model.name in user_oauth_groups
and not any(gm.name == group_model.name for gm in user_current_groups)
and group_model.name not in blocked_groups
):
# Add user to group
log.debug(
@@ -257,7 +318,7 @@ class OAuthManager:
try:
access_token = token.get("access_token")
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.github.com/user/emails", headers=headers
) as resp:
@@ -339,7 +400,7 @@ class OAuthManager:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
picture_url, **get_kwargs
) as resp:
+31 -1
View File
@@ -157,7 +157,8 @@ def load_function_module_by_id(function_id, content=None):
raise Exception("No Function class found in the module")
except Exception as e:
log.error(f"Error loading module: {function_id}: {e}")
del sys.modules[module_name] # Cleanup by removing the module in case of error
# Cleanup by removing the module in case of error
del sys.modules[module_name]
Functions.update_function_by_id(function_id, {"is_active": False})
raise e
@@ -182,3 +183,32 @@ def install_frontmatter_requirements(requirements: str):
else:
log.info("No requirements found in frontmatter.")
def install_tool_and_function_dependencies():
"""
Install all dependencies for all admin tools and active functions.
By first collecting all dependencies from the frontmatter of each tool and function,
and then installing them using pip. Duplicates or similar version specifications are
handled by pip as much as possible.
"""
function_list = Functions.get_functions(active_only=True)
tool_list = Tools.get_tools()
all_dependencies = ""
try:
for function in function_list:
frontmatter = extract_frontmatter(replace_imports(function.content))
if dependencies := frontmatter.get("requirements"):
all_dependencies += f"{dependencies}, "
for tool in tool_list:
# Only install requirements for admin tools
if tool.user.role == "admin":
frontmatter = extract_frontmatter(replace_imports(tool.content))
if dependencies := frontmatter.get("requirements"):
all_dependencies += f"{dependencies}, "
install_frontmatter_requirements(all_dependencies.strip(", "))
except Exception as e:
log.error(f"Error installing requirements: {e}")
+80 -53
View File
@@ -36,7 +36,10 @@ from langchain_core.utils.function_calling import (
from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA
from open_webui.env import (
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
)
import copy
@@ -276,8 +279,8 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
docstring = func.__doc__
description = parse_description(docstring)
function_descriptions = parse_docstring(docstring)
function_description = parse_description(docstring)
function_param_descriptions = parse_docstring(docstring)
field_defs = {}
for name, param in parameters.items():
@@ -285,15 +288,17 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
type_hint = type_hints.get(name, Any)
default_value = param.default if param.default is not param.empty else ...
description = function_descriptions.get(name, None)
param_description = function_param_descriptions.get(name, None)
if description:
field_defs[name] = type_hint, Field(default_value, description=description)
if param_description:
field_defs[name] = type_hint, Field(
default_value, description=param_description
)
else:
field_defs[name] = type_hint, default_value
model = create_model(func.__name__, **field_defs)
model.__doc__ = description
model.__doc__ = function_description
return model
@@ -371,51 +376,64 @@ def convert_openapi_to_tool_payload(openapi_spec):
for path, methods in openapi_spec.get("paths", {}).items():
for method, operation in methods.items():
tool = {
"type": "function",
"name": operation.get("operationId"),
"description": operation.get(
"description", operation.get("summary", "No description available.")
),
"parameters": {"type": "object", "properties": {}, "required": []},
}
# Extract path and query parameters
for param in operation.get("parameters", []):
param_name = param["name"]
param_schema = param.get("schema", {})
tool["parameters"]["properties"][param_name] = {
"type": param_schema.get("type"),
"description": param_schema.get("description", ""),
if operation.get("operationId"):
tool = {
"type": "function",
"name": operation.get("operationId"),
"description": operation.get(
"description",
operation.get("summary", "No description available."),
),
"parameters": {"type": "object", "properties": {}, "required": []},
}
if param.get("required"):
tool["parameters"]["required"].append(param_name)
# Extract and resolve requestBody if available
request_body = operation.get("requestBody")
if request_body:
content = request_body.get("content", {})
json_schema = content.get("application/json", {}).get("schema")
if json_schema:
resolved_schema = resolve_schema(
json_schema, openapi_spec.get("components", {})
)
if resolved_schema.get("properties"):
tool["parameters"]["properties"].update(
resolved_schema["properties"]
# Extract path and query parameters
for param in operation.get("parameters", []):
param_name = param["name"]
param_schema = param.get("schema", {})
description = param_schema.get("description", "")
if not description:
description = param.get("description") or ""
if param_schema.get("enum") and isinstance(
param_schema.get("enum"), list
):
description += (
f". Possible values: {', '.join(param_schema.get('enum'))}"
)
if "required" in resolved_schema:
tool["parameters"]["required"] = list(
set(
tool["parameters"]["required"]
+ resolved_schema["required"]
)
)
elif resolved_schema.get("type") == "array":
tool["parameters"] = resolved_schema # special case for array
tool["parameters"]["properties"][param_name] = {
"type": param_schema.get("type"),
"description": description,
}
if param.get("required"):
tool["parameters"]["required"].append(param_name)
tool_payload.append(tool)
# Extract and resolve requestBody if available
request_body = operation.get("requestBody")
if request_body:
content = request_body.get("content", {})
json_schema = content.get("application/json", {}).get("schema")
if json_schema:
resolved_schema = resolve_schema(
json_schema, openapi_spec.get("components", {})
)
if resolved_schema.get("properties"):
tool["parameters"]["properties"].update(
resolved_schema["properties"]
)
if "required" in resolved_schema:
tool["parameters"]["required"] = list(
set(
tool["parameters"]["required"]
+ resolved_schema["required"]
)
)
elif resolved_schema.get("type") == "array":
tool["parameters"] = (
resolved_schema # special case for array
)
tool_payload.append(tool)
return tool_payload
@@ -431,8 +449,10 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
error = None
try:
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as response:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
) as response:
if response.status != 200:
error_body = await response.json()
raise Exception(error_body)
@@ -573,19 +593,26 @@ async def execute_tool_server(
if token:
headers["Authorization"] = f"Bearer {token}"
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
request_method = getattr(session, http_method.lower())
if http_method in ["post", "put", "patch"]:
async with request_method(
final_url, json=body_params, headers=headers
final_url,
json=body_params,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) as response:
if response.status >= 400:
text = await response.text()
raise Exception(f"HTTP error {response.status}: {text}")
return await response.json()
else:
async with request_method(final_url, headers=headers) as response:
async with request_method(
final_url,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) as response:
if response.status >= 400:
text = await response.text()
raise Exception(f"HTTP error {response.status}: {text}")
+19 -19
View File
@@ -31,7 +31,7 @@ APScheduler==3.10.4
RestrictedPython==8.0
loguru==0.7.2
loguru==0.7.3
asgiref==3.8.1
# AI libraries
@@ -40,8 +40,8 @@ anthropic
google-generativeai==0.8.4
tiktoken
langchain==0.3.19
langchain-community==0.3.18
langchain==0.3.24
langchain-community==0.3.23
fake-useragent==2.1.0
chromadb==0.6.3
@@ -49,11 +49,11 @@ pymilvus==2.5.0
qdrant-client~=1.12.0
opensearch-py==2.8.0
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==8.17.1
elasticsearch==9.0.1
pinecone==6.0.2
transformers
sentence-transformers==3.3.1
sentence-transformers==4.1.0
accelerate
colbert-ai==0.2.21
einops==0.8.1
@@ -81,7 +81,7 @@ azure-ai-documentintelligence==1.0.0
pillow==11.1.0
opencv-python-headless==4.11.0.86
rapidocr-onnxruntime==1.3.24
rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2
onnxruntime==1.20.1
@@ -107,7 +107,7 @@ google-auth-oauthlib
## Tests
docker~=7.1.0
pytest~=8.3.2
pytest~=8.3.5
pytest-docker~=3.1.1
googleapis-common-protos==1.63.2
@@ -127,14 +127,14 @@ firecrawl-py==1.12.0
tencentcloud-sdk-python==3.0.1336
## Trace
opentelemetry-api==1.31.1
opentelemetry-sdk==1.31.1
opentelemetry-exporter-otlp==1.31.1
opentelemetry-instrumentation==0.52b1
opentelemetry-instrumentation-fastapi==0.52b1
opentelemetry-instrumentation-sqlalchemy==0.52b1
opentelemetry-instrumentation-redis==0.52b1
opentelemetry-instrumentation-requests==0.52b1
opentelemetry-instrumentation-logging==0.52b1
opentelemetry-instrumentation-httpx==0.52b1
opentelemetry-instrumentation-aiohttp-client==0.52b1
opentelemetry-api==1.32.1
opentelemetry-sdk==1.32.1
opentelemetry-exporter-otlp==1.32.1
opentelemetry-instrumentation==0.53b1
opentelemetry-instrumentation-fastapi==0.53b1
opentelemetry-instrumentation-sqlalchemy==0.53b1
opentelemetry-instrumentation-redis==0.53b1
opentelemetry-instrumentation-requests==0.53b1
opentelemetry-instrumentation-logging==0.53b1
opentelemetry-instrumentation-httpx==0.53b1
opentelemetry-instrumentation-aiohttp-client==0.53b1
+1 -1
View File
@@ -1,6 +1,6 @@
# Hosting UI and Models separately
Sometimes, its beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
# Open WebUI Configuration
+845 -844
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.6.4",
"version": "0.6.6",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -28,8 +28,8 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.13",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"cypress": "^13.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
@@ -61,8 +61,8 @@
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^2.10.0",
"@tiptap/extension-code-block-lowlight": "^2.10.0",
"@tiptap/core": "^2.11.9",
"@tiptap/extension-code-block-lowlight": "^2.11.9",
"@tiptap/extension-highlight": "^2.10.0",
"@tiptap/extension-placeholder": "^2.10.0",
"@tiptap/extension-typography": "^2.10.0",
@@ -72,11 +72,11 @@
"async": "^3.2.5",
"bits-ui": "^0.19.7",
"codemirror": "^6.0.1",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"codemirror-lang-elixir": "^4.0.0",
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
"dompurify": "^3.2.5",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5",
"fuse.js": "^7.0.0",
@@ -92,7 +92,7 @@
"katex": "^0.16.21",
"kokoro-js": "^1.1.1",
"marked": "^9.1.0",
"mermaid": "^10.9.3",
"mermaid": "^11.6.0",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
"prosemirror-commands": "^1.6.0",
@@ -102,7 +102,7 @@
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3",
+7 -6
View File
@@ -40,7 +40,7 @@ dependencies = [
"RestrictedPython==8.0",
"loguru==0.7.2",
"loguru==0.7.3",
"asgiref==3.8.1",
"openai",
@@ -48,8 +48,8 @@ dependencies = [
"google-generativeai==0.8.4",
"tiktoken",
"langchain==0.3.19",
"langchain-community==0.3.18",
"langchain==0.3.24",
"langchain-community==0.3.23",
"fake-useragent==2.1.0",
"chromadb==0.6.3",
@@ -57,10 +57,11 @@ dependencies = [
"qdrant-client~=1.12.0",
"opensearch-py==2.8.0",
"playwright==1.49.1",
"elasticsearch==8.17.1",
"elasticsearch==9.0.1",
"pinecone==6.0.2",
"transformers",
"sentence-transformers==3.3.1",
"sentence-transformers==4.1.0",
"accelerate",
"colbert-ai==0.2.21",
"einops==0.8.1",
@@ -87,7 +88,7 @@ dependencies = [
"pillow==11.1.0",
"opencv-python-headless==4.11.0.86",
"rapidocr-onnxruntime==1.3.24",
"rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2",
"onnxruntime==1.20.1",
+4 -2
View File
@@ -354,7 +354,8 @@ export const addUser = async (
name: string,
email: string,
password: string,
role: string = 'pending'
role: string = 'pending',
profile_image_url: null | string = null
) => {
let error = null;
@@ -368,7 +369,8 @@ export const addUser = async (
name: name,
email: email,
password: password,
role: role
role: role,
...(profile_image_url && { profile_image_url: profile_image_url })
})
})
.then(async (res) => {
-1
View File
@@ -1,5 +1,4 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { t } from 'i18next';
type ChannelForm = {
name: string;
+34 -2
View File
@@ -539,7 +539,7 @@ export const updateTaskConfig = async (token: string, config: object) => {
export const generateTitle = async (
token: string = '',
model: string,
messages: string[],
messages: object[],
chat_id?: string
) => {
let error = null;
@@ -573,7 +573,39 @@ export const generateTitle = async (
throw error;
}
return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
try {
// Step 1: Safely extract the response string
const response = res?.choices[0]?.message?.content ?? '';
// Step 2: Attempt to fix common JSON format issues like single quotes
const sanitizedResponse = response.replace(/['‘’`]/g, '"'); // Convert single quotes to double quotes for valid JSON
// Step 3: Find the relevant JSON block within the response
const jsonStartIndex = sanitizedResponse.indexOf('{');
const jsonEndIndex = sanitizedResponse.lastIndexOf('}');
// Step 4: Check if we found a valid JSON block (with both `{` and `}`)
if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1);
// Step 5: Parse the JSON block
const parsed = JSON.parse(jsonResponse);
// Step 6: If there's a "tags" key, return the tags array; otherwise, return an empty array
if (parsed && parsed.title) {
return parsed.title;
} else {
return null;
}
}
// If no valid JSON block found, return an empty array
return null;
} catch (e) {
// Catch and safely return empty array on any parsing errors
console.error('Failed to parse response: ', e);
return null;
}
};
export const generateTags = async (
+187
View File
@@ -0,0 +1,187 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { getTimeRange } from '$lib/utils';
type NoteItem = {
title: string;
data: object;
meta?: null | object;
access_control?: null | object;
};
export const createNewNote = async (token: string, note: NoteItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNotes = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
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.log(err);
return null;
});
if (error) {
throw error;
}
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
// Build the grouped object
const grouped: Record<string, any[]> = {};
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
}
grouped[timeRange].push({
...note,
timeRange
});
}
return grouped;
};
export const getNoteById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, {
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.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({
...note
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteNoteById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
+54 -3
View File
@@ -116,10 +116,33 @@ export const updateUserRole = async (token: string, id: string, role: string) =>
return res;
};
export const getUsers = async (token: string) => {
export const getUsers = async (
token: string,
query?: string,
orderBy?: string,
direction?: string,
page = 1
) => {
let error = null;
let res = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/`, {
let searchParams = new URLSearchParams();
searchParams.set('page', `${page}`);
if (query) {
searchParams.set('query', query);
}
if (orderBy) {
searchParams.set('order_by', orderBy);
}
if (direction) {
searchParams.set('direction', direction);
}
res = await fetch(`${WEBUI_API_BASE_URL}/users/?${searchParams.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -140,7 +163,35 @@ export const getUsers = async (token: string) => {
throw error;
}
return res ? res : [];
return res;
};
export const getAllUsers = async (token: string) => {
let error = null;
let res = null;
res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getUserSettings = async (token: string) => {
@@ -21,7 +21,7 @@
{#if content}
{content}
{:else}
{$i18n.t('Drop any files here to add to the conversation')}
{$i18n.t('Drop any files here to upload')}
{/if}
</div>
</slot>
+1 -1
View File
@@ -192,7 +192,7 @@
<svelte:head>
<title>
{$i18n.t('Functions')} | {$WEBUI_NAME}
{$i18n.t('Functions')} • {$WEBUI_NAME}
</title>
</svelte:head>
@@ -387,7 +387,7 @@ class Pipe:
<div class="flex-1 pr-3">
<div class="text-xs text-gray-500 line-clamp-2">
<span class=" font-semibold dark:text-gray-200">{$i18n.t('Warning:')}</span>
{$i18n.t('Functions allow arbitrary code execution')} <br />—
{$i18n.t('Functions allow arbitrary code execution.')} <br />—
<span class=" font-medium dark:text-gray-400"
>{$i18n.t(`don't install random functions from sources you don't trust.`)}</span
>
+34 -3
View File
@@ -42,6 +42,8 @@
let STT_AZURE_API_KEY = '';
let STT_AZURE_REGION = '';
let STT_AZURE_LOCALES = '';
let STT_AZURE_BASE_URL = '';
let STT_AZURE_MAX_SPEAKERS = '';
let STT_DEEPGRAM_API_KEY = '';
let STT_WHISPER_MODEL_LOADING = false;
@@ -114,7 +116,9 @@
DEEPGRAM_API_KEY: STT_DEEPGRAM_API_KEY,
AZURE_API_KEY: STT_AZURE_API_KEY,
AZURE_REGION: STT_AZURE_REGION,
AZURE_LOCALES: STT_AZURE_LOCALES
AZURE_LOCALES: STT_AZURE_LOCALES,
AZURE_BASE_URL: STT_AZURE_BASE_URL,
AZURE_MAX_SPEAKERS: STT_AZURE_MAX_SPEAKERS
}
});
@@ -157,6 +161,8 @@
STT_AZURE_API_KEY = res.stt.AZURE_API_KEY;
STT_AZURE_REGION = res.stt.AZURE_REGION;
STT_AZURE_LOCALES = res.stt.AZURE_LOCALES;
STT_AZURE_BASE_URL = res.stt.AZURE_BASE_URL;
STT_AZURE_MAX_SPEAKERS = res.stt.AZURE_MAX_SPEAKERS;
STT_DEEPGRAM_API_KEY = res.stt.DEEPGRAM_API_KEY;
}
@@ -288,6 +294,32 @@
</div>
</div>
</div>
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Base URL')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="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={STT_AZURE_BASE_URL}
placeholder={$i18n.t('(leave blank for Azure Commercial URL auto-generation)')}
/>
</div>
</div>
</div>
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Max Speakers')}</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="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={STT_AZURE_MAX_SPEAKERS}
placeholder={$i18n.t('e.g., 3, 4, 5 (leave blank for default)')}
/>
</div>
</div>
</div>
</div>
{:else if STT_ENGINE === ''}
<div>
@@ -468,8 +500,7 @@
<option
value={voice.voiceURI}
class="bg-gray-100 dark:bg-gray-700"
selected={TTS_VOICE === voice.voiceURI}
>{voice.name.replace('+', ', ')}</option
selected={TTS_VOICE === voice.voiceURI}>{voice.name}</option
>
{/each}
</select>
@@ -161,6 +161,16 @@
toast.error($i18n.t('Docling Server URL required.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') ||
(RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === ''))
) {
toast.error(
$i18n.t('Both Docling OCR Engine and Language(s) must be provided or both left empty.')
);
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence' &&
@@ -326,6 +336,18 @@
bind:value={RAGConfig.DOCLING_SERVER_URL}
/>
</div>
<div class="flex w-full mt-2">
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Engine')}
bind:value={RAGConfig.DOCLING_OCR_ENGINE}
/>
<input
class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
bind:value={RAGConfig.DOCLING_OCR_LANG}
/>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
<div class="my-0.5 flex gap-2 pr-2">
<input
@@ -1,7 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import { getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
import {
getAdminConfig,
getLdapConfig,
@@ -601,6 +601,14 @@
<Switch bind:state={adminConfig.ENABLE_MESSAGE_RATING} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes')} ({$i18n.t('Beta')})
</div>
<Switch bind:state={adminConfig.ENABLE_NOTES} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Channels')} ({$i18n.t('Beta')})
@@ -14,6 +14,7 @@
let webSearchEngines = [
'searxng',
'yacy',
'google_pse',
'brave',
'kagi',
@@ -30,9 +31,11 @@
'bing',
'exa',
'perplexity',
'sougou'
'sougou',
'firecrawl',
'external'
];
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily'];
let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external'];
let webConfig = null;
@@ -143,6 +146,53 @@
</div>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'yacy'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Yacy Instance URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter Yacy URL (e.g. http://yacy.example.com:8090)')}
bind:value={webConfig.YACY_QUERY_URL}
autocomplete="off"
/>
</div>
</div>
</div>
</div>
<div class="mb-2.5 flex w-full flex-col">
<div class="flex gap-2">
<div class="w-full">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Yacy Username')}
</div>
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
placeholder={$i18n.t('Enter Yacy Username')}
bind:value={webConfig.YACY_USERNAME}
required
/>
</div>
<div class="w-full">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Yacy Password')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Yacy Password')}
bind:value={webConfig.YACY_PASSWORD}
/>
</div>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'google_pse'}
<div class="mb-2.5 flex w-full flex-col">
<div>
@@ -431,6 +481,68 @@
/>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'firecrawl'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Firecrawl API Base URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter Firecrawl API Base URL')}
bind:value={webConfig.FIRECRAWL_API_BASE_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Firecrawl API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter Firecrawl API Key')}
bind:value={webConfig.FIRECRAWL_API_KEY}
/>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'external'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('External Web Search URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter External Web Search URL')}
bind:value={webConfig.EXTERNAL_WEB_SEARCH_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('External Web Search API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter External Web Search API Key')}
bind:value={webConfig.EXTERNAL_WEB_SEARCH_API_KEY}
/>
</div>
</div>
{/if}
{/if}
@@ -588,7 +700,7 @@
</div>
</div>
</div>
{:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl'}
{:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl' && webConfig.WEB_SEARCH_ENGINE !== 'firecrawl'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
@@ -652,6 +764,37 @@
</div>
{/if}
</div>
{:else if webConfig.WEB_LOADER_ENGINE === 'external'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('External Web Loader URL')}
</div>
<div class="flex w-full">
<div class="flex-1">
<input
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t('Enter External Web Loader URL')}
bind:value={webConfig.EXTERNAL_WEB_LOADER_URL}
autocomplete="off"
/>
</div>
</div>
</div>
<div class="mt-2">
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('External Web Loader API Key')}
</div>
<SensitiveInput
placeholder={$i18n.t('Enter External Web Loader API Key')}
bind:value={webConfig.EXTERNAL_WEB_LOADER_API_KEY}
/>
</div>
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
+3 -16
View File
@@ -5,32 +5,19 @@
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
import { getUsers } from '$lib/apis/users';
import UserList from './Users/UserList.svelte';
import Groups from './Users/Groups.svelte';
const i18n = getContext('i18n');
let users = [];
let selectedTab = 'overview';
let loaded = false;
$: if (selectedTab) {
getUsersHandler();
}
const getUsersHandler = async () => {
users = await getUsers(localStorage.token);
};
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
users = await getUsers(localStorage.token);
}
loaded = true;
const containerElement = document.getElementById('users-tabs-container');
@@ -102,9 +89,9 @@
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
{#if selectedTab === 'overview'}
<UserList {users} />
<UserList />
{:else if selectedTab === 'groups'}
<Groups {users} />
<Groups />
{/if}
</div>
</div>
+29 -6
View File
@@ -23,13 +23,18 @@
import GroupItem from './Groups/GroupItem.svelte';
import AddGroupModal from './Groups/AddGroupModal.svelte';
import { createNewGroup, getGroups } from '$lib/apis/groups';
import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
import {
getUserDefaultPermissions,
getAllUsers,
updateUserDefaultPermissions
} from '$lib/apis/users';
const i18n = getContext('i18n');
let loaded = false;
export let users = [];
let users = [];
let total = 0;
let groups = [];
let filteredGroups;
@@ -63,6 +68,11 @@
file_upload: true,
delete: true,
edit: true,
share: true,
export: true,
stt: true,
tts: true,
call: true,
multiple_models: true,
temporary: true,
temporary_enforced: false
@@ -71,7 +81,8 @@
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true
code_interpreter: true,
notes: true
}
};
@@ -113,10 +124,22 @@
onMount(async () => {
if ($user?.role !== 'admin') {
await goto('/');
} else {
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
return;
}
const res = await getAllUsers(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
users = res.users;
total = res.total;
}
await setGroups();
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
loaded = true;
});
</script>
@@ -21,9 +21,14 @@
},
chat: {
controls: true,
file_upload: true,
delete: true,
edit: true,
file_upload: true,
share: true,
export: true,
stt: true,
tts: true,
call: true,
multiple_models: true,
temporary: true,
temporary_enforced: false
@@ -32,7 +37,8 @@
direct_tool_servers: false,
web_search: true,
image_generation: true,
code_interpreter: true
code_interpreter: true,
notes: true
}
};
@@ -241,14 +247,6 @@
<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=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow File Upload')}
@@ -257,6 +255,14 @@
<Switch bind:state={permissions.chat.file_upload} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Controls')}
</div>
<Switch bind:state={permissions.chat.controls} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Delete')}
@@ -273,6 +279,45 @@
<Switch bind:state={permissions.chat.edit} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Share')}
</div>
<Switch bind:state={permissions.chat.share} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Export')}
</div>
<Switch bind:state={permissions.chat.export} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Speech to Text')}
</div>
<Switch bind:state={permissions.chat.stt} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Text to Speech')}
</div>
<Switch bind:state={permissions.chat.tts} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Call')}
</div>
<Switch bind:state={permissions.chat.call} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Multiple Models in Chat')}
@@ -336,5 +381,13 @@
<Switch bind:state={permissions.features.code_interpreter} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes')}
</div>
<Switch bind:state={permissions.features.notes} />
</div>
</div>
</div>
+98 -63
View File
@@ -23,6 +23,8 @@
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
@@ -33,19 +35,33 @@
const i18n = getContext('i18n');
export let users = [];
let search = '';
let selectedUser = null;
let page = 1;
let users = [];
let total = 0;
let query = '';
let orderBy = 'created_at'; // default sort key
let direction = 'asc'; // default sort order
let selectedUser = null;
let showDeleteConfirmDialog = false;
let showAddUserModal = false;
let showUserChatsModal = false;
let showEditUserModal = false;
let showUpdateRoleModal = false;
const onUpdateRole = (user) => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
};
const updateRoleHandler = async (id, role) => {
const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
toast.error(`${error}`);
@@ -53,7 +69,7 @@
});
if (res) {
users = await getUsers(localStorage.token);
getUserList();
}
};
@@ -62,42 +78,51 @@
toast.error(`${error}`);
return null;
});
// if the user is deleted and the current page has only one user, go back to the previous page
if (users.length === 1 && page > 1) {
page -= 1;
}
if (res) {
users = await getUsers(localStorage.token);
getUserList();
}
};
let sortKey = 'created_at'; // default sort key
let sortOrder = 'asc'; // default sort order
function setSortKey(key) {
if (sortKey === key) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortOrder = 'asc';
orderBy = key;
direction = 'asc';
}
};
const getUserList = async () => {
try {
const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
users = res.users;
total = res.total;
}
} catch (err) {
console.error(err);
}
};
$: if (page) {
getUserList();
}
let filteredUsers;
$: filteredUsers = users
.filter((user) => {
if (search === '') {
return true;
} else {
let name = user.name.toLowerCase();
let email = user.email.toLowerCase();
const query = search.toLowerCase();
return name.includes(query) || email.includes(query);
}
})
.sort((a, b) => {
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
return 0;
})
.slice((page - 1) * 20, page * 20);
$: if (query !== null && orderBy && direction) {
getUserList();
}
</script>
<ConfirmDialog
@@ -107,13 +132,28 @@
}}
/>
<RoleUpdateConfirmDialog
bind:show={showUpdateRoleModal}
on:confirm={() => {
onUpdateRole(selectedUser);
}}
message={$i18n.t(`Are you sure you want to update this user\'s role to **{{ROLE}}**?`, {
ROLE:
selectedUser?.role === 'user'
? 'admin'
: selectedUser?.role === 'pending'
? 'user'
: 'pending'
})}
/>
{#key selectedUser}
<EditUserModal
bind:show={showEditUserModal}
{selectedUser}
sessionUser={$user}
on:save={async () => {
users = await getUsers(localStorage.token);
getUserList();
}}
/>
{/key}
@@ -121,7 +161,7 @@
<AddUserModal
bind:show={showAddUserModal}
on:save={async () => {
users = await getUsers(localStorage.token);
getUserList();
}}
/>
<UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
@@ -149,19 +189,19 @@
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
{#if ($config?.license_metadata?.seats ?? null) !== null}
{#if users.length > $config?.license_metadata?.seats}
{#if total > $config?.license_metadata?.seats}
<span class="text-lg font-medium text-red-500"
>{users.length} of {$config?.license_metadata?.seats}
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">available users</span></span
>
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
>{users.length} of {$config?.license_metadata?.seats}
>{total} of {$config?.license_metadata?.seats}
<span class="text-sm font-normal">available users</span></span
>
{/if}
{:else}
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
{/if}
</div>
@@ -184,7 +224,7 @@
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={search}
bind:value={query}
placeholder={$i18n.t('Search')}
/>
</div>
@@ -223,9 +263,9 @@
<div class="flex gap-1.5 items-center">
{$i18n.t('Role')}
{#if sortKey === 'role'}
{#if orderBy === 'role'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
@@ -246,9 +286,9 @@
<div class="flex gap-1.5 items-center">
{$i18n.t('Name')}
{#if sortKey === 'name'}
{#if orderBy === 'name'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
@@ -269,9 +309,9 @@
<div class="flex gap-1.5 items-center">
{$i18n.t('Email')}
{#if sortKey === 'email'}
{#if orderBy === 'email'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
@@ -293,9 +333,9 @@
<div class="flex gap-1.5 items-center">
{$i18n.t('Last Active')}
{#if sortKey === 'last_active_at'}
{#if orderBy === 'last_active_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
@@ -315,9 +355,9 @@
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Created at')}
{#if sortKey === 'created_at'}
{#if orderBy === 'created_at'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
@@ -339,9 +379,9 @@
<div class="flex gap-1.5 items-center">
{$i18n.t('OAuth ID')}
{#if sortKey === 'oauth_sub'}
{#if orderBy === 'oauth_sub'}
<span class="font-normal"
>{#if sortOrder === 'asc'}
>{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
@@ -359,19 +399,14 @@
</tr>
</thead>
<tbody class="">
{#each filteredUsers as user, userIdx}
{#each users as user, userIdx}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 min-w-[7rem] w-28">
<button
class=" translate-y-0.5"
on:click={() => {
if (user.role === 'user') {
updateRoleHandler(user.id, 'admin');
} else if (user.role === 'pending') {
updateRoleHandler(user.id, 'user');
} else {
updateRoleHandler(user.id, 'pending');
}
selectedUser = user;
showUpdateRoleModal = true;
}}
>
<Badge
@@ -486,10 +521,10 @@
ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
</div>
<Pagination bind:page count={users.length} />
<Pagination bind:page count={total} perPage={10} />
{#if !$config?.license_metadata}
{#if users.length > 50}
{#if total > 50}
<div class="text-sm">
<Markdown
content={`
@@ -7,6 +7,7 @@
import { WEBUI_BASE_URL } from '$lib/constants';
import Modal from '$lib/components/common/Modal.svelte';
import { generateInitialsImage } from '$lib/utils';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@@ -47,7 +48,8 @@
_user.name,
_user.email,
_user.password,
_user.role
_user.role,
generateInitialsImage(_user.name)
).catch((error) => {
toast.error(`${error}`);
});
@@ -83,7 +85,8 @@
columns[0],
columns[1],
columns[2],
columns[3].toLowerCase()
columns[3].toLowerCase(),
generateInitialsImage(columns[0])
).catch((error) => {
toast.error(`Row ${idx + 1}: ${error}`);
return null;
@@ -109,7 +112,7 @@
stopLoading();
};
reader.readAsText(file);
reader.readAsText(file, 'utf-8');
} else {
toast.error($i18n.t('File not found.'));
}
@@ -45,7 +45,7 @@
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Edit User')}</div>
<button
class="self-center"
@@ -65,9 +65,8 @@
</svg>
</button>
</div>
<hr class="border-gray-100 dark:border-gray-850" />
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
<div class="flex flex-col md:flex-row w-full 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"
@@ -75,7 +74,7 @@
submitHandler();
}}
>
<div class=" flex items-center rounded-md py-2 px-4 w-full">
<div class=" flex items-center rounded-md px-5 py-2 w-full">
<div class=" self-center mr-5">
<img
src={selectedUser.profile_image_url}
@@ -94,59 +93,62 @@
</div>
</div>
<hr class="border-gray-100 dark:border-gray-850 my-3 w-full" />
<div class=" px-5 pt-3 pb-5">
<div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class=" flex flex-col space-y-1.5">
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
<div class="flex-1">
<input
class="w-full rounded-sm text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
placeholder={$i18n.t('Enter Your Email')}
autocomplete="off"
required
disabled={_user.id == sessionUser.id}
/>
</div>
</div>
<div class="flex-1">
<input
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
type="email"
bind:value={_user.email}
autocomplete="off"
required
disabled={_user.id == sessionUser.id}
/>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full rounded-sm text-sm bg-transparent outline-hidden"
type="text"
bind:value={_user.name}
placeholder={$i18n.t('Enter Your Name')}
autocomplete="off"
required
/>
</div>
</div>
<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">
<input
class="w-full rounded-sm text-sm bg-transparent outline-hidden"
type="password"
placeholder={$i18n.t('Enter New Password')}
bind:value={_user.password}
autocomplete="new-password"
/>
</div>
</div>
</div>
<div class="flex flex-col w-full">
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
type="text"
bind:value={_user.name}
autocomplete="off"
required
/>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<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"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
<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">
<input
class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
type="password"
bind:value={_user.password}
autocomplete="new-password"
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
</div>
+2 -2
View File
@@ -195,7 +195,7 @@
</script>
<svelte:head>
<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
<title>#{channel?.name ?? 'Channel'} • Open WebUI</title>
</svelte:head>
<div
@@ -262,7 +262,7 @@
{#if threadId !== null}
<Drawer
show={threadId !== null}
on:close={() => {
onClose={() => {
threadId = null;
}}
>
@@ -357,14 +357,14 @@
{#if recording}
<VoiceRecording
bind:recording
on:cancel={async () => {
onCancel={async () => {
recording = false;
await tick();
document.getElementById(`chat-input-${id}`)?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
onConfirm={async (data) => {
const { text, filename } = data;
content = `${content}${text} `;
recording = false;
+35 -31
View File
@@ -236,9 +236,11 @@
await tick();
await tick();
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' });
if ($settings?.scrollOnBranchChange ?? true) {
const messageElement = document.getElementById(`message-${message.id}`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' });
}
}
await tick();
@@ -262,6 +264,21 @@
} else {
message.statusHistory = [data];
}
} else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content;
} else if (type === 'chat:message' || type === 'replace') {
message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files;
} else if (type === 'chat:title') {
chatTitle.set(data);
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
} else if (type === 'source' || type === 'citation') {
if (data?.type === 'code_execution') {
// Code execution; update existing code execution by ID, or add new one.
@@ -288,19 +305,19 @@
message.sources = [data];
}
}
} else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:title') {
chatTitle.set(data);
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
} else if (type === 'chat:tags') {
chat = await getChatById(localStorage.token, $chatId);
allTags.set(await getAllTags(localStorage.token));
} else if (type === 'chat:message:delta' || type === 'message') {
message.content += data.content;
} else if (type === 'chat:message' || type === 'replace') {
message.content = data.content;
} else if (type === 'notification') {
const toastType = data?.type ?? 'info';
const toastContent = data?.content ?? '';
if (toastType === 'success') {
toast.success(toastContent);
} else if (toastType === 'error') {
toast.error(toastContent);
} else if (toastType === 'warning') {
toast.warning(toastContent);
} else {
toast.info(toastContent);
}
} else if (type === 'confirmation') {
eventCallback = cb;
@@ -333,19 +350,6 @@
eventConfirmationMessage = data.message;
eventConfirmationInputPlaceholder = data.placeholder;
eventConfirmationInputValue = data?.value ?? '';
} else if (type === 'notification') {
const toastType = data?.type ?? 'info';
const toastContent = data?.content ?? '';
if (toastType === 'success') {
toast.success(toastContent);
} else if (toastType === 'error') {
toast.error(toastContent);
} else if (toastType === 'warning') {
toast.warning(toastContent);
} else {
toast.info(toastContent);
}
} else {
console.log('Unknown message type', data);
}
@@ -1917,7 +1921,7 @@
<svelte:head>
<title>
{$chatTitle
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
: `${$WEBUI_NAME}`}
</title>
</svelte:head>
@@ -2036,7 +2040,7 @@
{stopResponse}
{createMessagePair}
onChange={(input) => {
if (input.prompt) {
if (input.prompt !== null) {
localStorage.setItem(`chat-input-${$chatId}`, JSON.stringify(input));
} else {
localStorage.removeItem(`chat-input-${$chatId}`);
+1 -1
View File
@@ -140,7 +140,7 @@
{#if $showControls}
<Drawer
show={$showControls}
on:close={() => {
onClose={() => {
showControls.set(false);
}}
>
+33 -35
View File
@@ -395,39 +395,37 @@
</div>
<div class="w-full relative">
{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || ($settings?.webSearch ?? false) === 'always' || imageGenerationEnabled || codeInterpreterEnabled}
{#if atSelectedModel !== undefined}
<div
class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-linear-to-t from-white dark:from-gray-900 z-10"
>
{#if atSelectedModel !== undefined}
<div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
<img
crossorigin="anonymous"
alt="model profile"
class="size-3.5 max-w-[28px] object-cover rounded-full"
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
/>
<div class="translate-y-[0.5px]">
Talking to <span class=" font-medium">{atSelectedModel.name}</span>
</div>
</div>
<div>
<button
class="flex items-center dark:text-gray-500"
on:click={() => {
atSelectedModel = undefined;
}}
>
<XMark />
</button>
<div class="flex items-center justify-between w-full">
<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
<img
crossorigin="anonymous"
alt="model profile"
class="size-3.5 max-w-[28px] object-cover rounded-full"
src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
?.profile_image_url ??
($i18n.language === 'dg-DG'
? `/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
/>
<div class="translate-y-[0.5px]">
Talking to <span class=" font-medium">{atSelectedModel.name}</span>
</div>
</div>
{/if}
<div>
<button
class="flex items-center dark:text-gray-500"
on:click={() => {
atSelectedModel = undefined;
}}
>
<XMark />
</button>
</div>
</div>
</div>
{/if}
@@ -481,14 +479,14 @@
{#if recording}
<VoiceRecording
bind:recording
on:cancel={async () => {
onCancel={async () => {
recording = false;
await tick();
document.getElementById('chat-input')?.focus();
}}
on:confirm={async (e) => {
const { text, filename } = e.detail;
onConfirm={async (data) => {
const { text, filename } = data;
prompt = `${prompt}${text} `;
recording = false;
@@ -1063,9 +1061,9 @@
);
}
}}
uploadOneDriveHandler={async () => {
uploadOneDriveHandler={async (authorityType) => {
try {
const fileData = await pickAndDownloadFile();
const fileData = await pickAndDownloadFile(authorityType);
if (fileData) {
const file = new File([fileData.blob], fileData.name, {
type: fileData.blob.type || 'application/octet-stream'
@@ -1189,7 +1187,7 @@
</div>
<div class="self-end flex space-x-1 mr-1 shrink-0">
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
<Tooltip content={$i18n.t('Record voice')}>
<button
id="voice-input-button"
@@ -1262,7 +1260,7 @@
</button>
</Tooltip>
</div>
{:else if prompt === '' && files.length === 0}
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Call')}>
<button
@@ -265,7 +265,7 @@
{/each}
{:else}
<div class=" text-gray-500 text-xs mt-1 mb-2">
{$i18n.t('No files found.')}
{$i18n.t('File not found.')}
</div>
{/if}
</div> -->
@@ -146,7 +146,7 @@
{/if}
<Tooltip
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files.') : ''}
className="w-full"
>
<DropdownMenu.Item
@@ -173,7 +173,7 @@
</Tooltip>
<Tooltip
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files.') : ''}
className="w-full"
>
<DropdownMenu.Item
@@ -229,94 +229,119 @@
{/if}
{#if $config?.features?.enable_onedrive_integration}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
<mask
id="mask0_87_7796"
style="mask-type:alpha"
maskUnits="userSpaceOnUse"
x="0"
y="6"
width="32"
height="20"
>
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
side={$mobile ? 'bottom' : 'right'}
sideOffset={$mobile ? 5 : 0}
alignOffset={$mobile ? 0 : -8}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('personal');
}}
>
<path
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
fill="#C4C4C4"
/>
</mask>
<g mask="url(#mask0_87_7796)">
<path
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('OneDrive')}</div>
</DropdownMenu.Item>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('organizations');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (work/school)')}</div>
<div class="text-xs text-gray-500">Includes SharePoint</div>
</div>
</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
</DropdownMenu.Content>
</div>
@@ -1,18 +1,26 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { config, settings } from '$lib/stores';
import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
import { transcribeAudio } from '$lib/apis/audio';
import dayjs from 'dayjs';
import LocalizedFormat from 'dayjs/plugin/localizedFormat';
dayjs.extend(LocalizedFormat);
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let recording = false;
export let transcribe = true;
export let displayMedia = false;
export let className = ' p-2.5 w-full max-w-full';
export let onCancel = () => {};
export let onConfirm = (data) => {};
let loading = false;
let confirmed = false;
@@ -130,45 +138,73 @@
detectSound();
};
const transcribeHandler = async (audioBlob) => {
const onStopHandler = async (audioBlob, ext: string = 'wav') => {
// Create a blob from the audio chunks
await tick();
const file = blobToFile(audioBlob, 'recording.wav');
const file = blobToFile(audioBlob, `Recording-${dayjs().format('L LT')}.${ext}`);
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (transcribe) {
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
// with web stt, we don't need to send the file to the server
return;
}
if (res) {
console.log(res);
dispatch('confirm', res);
const res = await transcribeAudio(localStorage.token, file).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
console.log(res);
onConfirm(res);
}
} else {
onConfirm({
file: file,
blob: audioBlob
});
}
};
const saveRecording = (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
document.body.appendChild(a);
a.style = 'display: none';
a.href = url;
a.download = 'recording.wav';
a.click();
window.URL.revokeObjectURL(url);
};
const startRecording = async () => {
loading = true;
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
try {
if (displayMedia) {
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
audio: true
});
stream = new MediaStream();
for (const track of mediaStream.getAudioTracks()) {
stream.addTrack(track);
}
for (const track of mediaStream.getVideoTracks()) {
track.stop();
}
} else {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
}
} catch (err) {
console.error('Error accessing media devices.', err);
toast.error($i18n.t('Error accessing media devices.'));
loading = false;
recording = false;
return;
}
mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm; codecs=opus'
});
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.onstart = () => {
console.log('Recording started');
loading = false;
@@ -180,77 +216,99 @@
mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
mediaRecorder.onstop = async () => {
console.log('Recording stopped');
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
audioChunks = [];
} else {
if (confirmed) {
const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
await transcribeHandler(audioBlob);
if (confirmed) {
// Use the actual type provided by MediaRecorder
let type = audioChunks[0]?.type || mediaRecorder.mimeType || 'audio/webm';
confirmed = false;
loading = false;
// split `/` and `;` to get the extension
let ext = type.split('/')[1].split(';')[0] || 'webm';
// If not audio, default to audio/webm
if (!type.startsWith('audio/')) {
ext = 'webm';
}
audioChunks = [];
recording = false;
const audioBlob = new Blob(audioChunks, { type: type });
await onStopHandler(audioBlob, ext);
confirmed = false;
loading = false;
}
audioChunks = [];
recording = false;
};
mediaRecorder.start();
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
// Create a SpeechRecognition object
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
// Set continuous to true for continuous recognition
speechRecognition.continuous = true;
try {
mediaRecorder.start();
} catch (error) {
console.error('Error starting recording:', error);
toast.error($i18n.t('Error starting recording.'));
loading = false;
recording = false;
return;
}
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
const inactivityTimeout = 2000; // 3 seconds
if (transcribe) {
if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
// Create a SpeechRecognition object
speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
let timeoutId;
// Start recognition
speechRecognition.start();
// Set continuous to true for continuous recognition
speechRecognition.continuous = true;
// Event triggered when speech is recognized
speechRecognition.onresult = async (event) => {
// Clear the inactivity timeout
clearTimeout(timeoutId);
// Set the timeout for turning off the recognition after inactivity (in milliseconds)
const inactivityTimeout = 2000; // 3 seconds
// Handle recognized speech
console.log(event);
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
let timeoutId;
// Start recognition
speechRecognition.start();
transcription = `${transcription}${transcript}`;
// Event triggered when speech is recognized
speechRecognition.onresult = async (event) => {
// Clear the inactivity timeout
clearTimeout(timeoutId);
await tick();
document.getElementById('chat-input')?.focus();
// Handle recognized speech
console.log(event);
const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
console.log('Speech recognition turned off due to inactivity.');
speechRecognition.stop();
}, inactivityTimeout);
};
transcription = `${transcription}${transcript}`;
// Event triggered when recognition is ended
speechRecognition.onend = function () {
// Restart recognition after it ends
console.log('recognition ended');
await tick();
document.getElementById('chat-input')?.focus();
confirmRecording();
dispatch('confirm', { text: transcription });
confirmed = false;
loading = false;
};
// Restart the inactivity timeout
timeoutId = setTimeout(() => {
console.log('Speech recognition turned off due to inactivity.');
speechRecognition.stop();
}, inactivityTimeout);
};
// Event triggered when an error occurs
speechRecognition.onerror = function (event) {
console.log(event);
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
dispatch('cancel');
// Event triggered when recognition is ended
speechRecognition.onend = function () {
// Restart recognition after it ends
console.log('recognition ended');
stopRecording();
};
confirmRecording();
onConfirm({
text: transcription
});
confirmed = false;
loading = false;
};
// Event triggered when an error occurs
speechRecognition.onerror = function (event) {
console.log(event);
toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
onCancel();
stopRecording();
};
}
}
}
};
@@ -339,7 +397,7 @@
rounded-full"
on:click={async () => {
stopRecording();
dispatch('cancel');
onCancel();
}}
>
<svg
@@ -83,6 +83,7 @@
});
return acc;
}, []);
console.log('citations', citations);
showRelevance = calculateShowRelevance(citations);
showPercentage = shouldShowPercentage(citations);
@@ -139,13 +139,16 @@
{percentage.toFixed(2)}%
</span>
{/if}
{#if typeof document?.distance === 'number'}
<span class="text-gray-500 dark:text-gray-500">
({(document?.distance ?? 0).toFixed(4)})
</span>
{/if}
{:else if typeof document?.distance === 'number'}
<span class="text-gray-500 dark:text-gray-500">
({(document?.distance ?? 0).toFixed(4)})
</span>
{:else}
<span class="text-gray-500 dark:text-gray-500">
{(document?.distance ?? 0).toFixed(4)}
</span>
{/if}
</div>
</Tooltip>
@@ -154,11 +154,11 @@
}, [])}
{onSourceClick}
{onTaskClick}
on:update={(e) => {
dispatch('update', e.detail);
onUpdate={(value) => {
dispatch('update', value);
}}
on:code={(e) => {
const { lang, code } = e.detail;
onCode={(value) => {
const { lang, code } = value;
if (
($settings?.detectArtifacts ?? true) &&
@@ -7,9 +7,6 @@
import markedKatexExtension from '$lib/utils/marked/katex-extension';
import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let id = '';
export let content;
@@ -18,6 +15,9 @@
export let sourceIds = [];
export let onUpdate = () => {};
export let onCode = () => {};
export let onSourceClick = () => {};
export let onTaskClick = () => {};
@@ -40,17 +40,5 @@
</script>
{#key id}
<MarkdownTokens
{tokens}
{id}
{save}
{onTaskClick}
{onSourceClick}
on:update={(e) => {
dispatch('update', e.detail);
}}
on:code={(e) => {
dispatch('code', e.detail);
}}
/>
<MarkdownTokens {tokens} {id} {save} {onTaskClick} {onSourceClick} {onUpdate} {onCode} />
{/key}
@@ -0,0 +1,80 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import type { Token } from 'marked';
import { WEBUI_BASE_URL } from '$lib/constants';
import Source from './Source.svelte';
import { settings } from '$lib/stores';
export let id: string;
export let token: Token;
export let onSourceClick: Function = () => {};
let html: string | null = null;
$: if (token.type === 'html' && token?.text) {
html = DOMPurify.sanitize(token.text);
} else {
html = null;
}
</script>
{#if token.type === 'html'}
{#if html && html.includes('<video')}
{@const video = html.match(/<video[^>]*>([\s\S]*?)<\/video>/)}
{@const videoSrc = video && video[1]}
{#if videoSrc}
<!-- svelte-ignore a11y-media-has-caption -->
<video
class="w-full my-2"
src={videoSrc}
title="Video player"
frameborder="0"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></video>
{:else}
{token.text}
{/if}
{:else if token.text && token.text.match(/<iframe\s+[^>]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/)}
{@const match = token.text.match(
/<iframe\s+[^>]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/
)}
{@const ytId = match && match[1]}
{#if ytId}
<iframe
class="w-full aspect-video my-2"
src={`https://www.youtube.com/embed/${ytId}`}
title="YouTube video player"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
>
</iframe>
{/if}
{:else if token.text.includes(`<file type="html"`)}
{@const match = token.text.match(/<file type="html" id="([^"]+)"/)}
{@const fileId = match && match[1]}
{#if fileId}
<iframe
class="w-full my-2"
src={`${WEBUI_BASE_URL}/api/v1/files/${fileId}/content/html`}
title="Content"
frameborder="0"
sandbox="allow-scripts{($settings?.iframeSandboxAllowForms ?? false)
? ' allow-forms'
: ''}{($settings?.iframeSandboxAllowSameOrigin ?? false) ? ' allow-same-origin' : ''}"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
width="100%"
onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
></iframe>
{/if}
{:else if token.text.includes(`<source_id`)}
<Source {id} {token} onClick={onSourceClick} />
{:else}
{token.text}
{/if}
{/if}
@@ -13,6 +13,7 @@
import Image from '$lib/components/common/Image.svelte';
import KatexRenderer from './KatexRenderer.svelte';
import Source from './Source.svelte';
import HtmlToken from './HTMLToken.svelte';
export let id: string;
export let tokens: Token[];
@@ -23,16 +24,7 @@
{#if token.type === 'escape'}
{unescapeHtml(token.text)}
{:else if token.type === 'html'}
{@const html = DOMPurify.sanitize(token.text)}
{#if html && html.includes('<video')}
{@html html}
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
{@html `${token.text}`}
{:else if token.text.includes(`<source_id`)}
<Source {id} {token} onClick={onSourceClick} />
{:else}
{@html html}
{/if}
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'link'}
{#if token.tokens}
<a href={token.href} target="_blank" rel="nofollow" title={token.title}>
@@ -1,6 +1,6 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import fileSaver from 'file-saver';
@@ -21,8 +21,7 @@
import Source from './Source.svelte';
import { settings } from '$lib/stores';
const dispatch = createEventDispatcher();
import HtmlToken from './HTMLToken.svelte';
export let id: string;
export let tokens: Token[];
@@ -31,6 +30,9 @@
export let save = false;
export let onUpdate: Function = () => {};
export let onCode: Function = () => {};
export let onTaskClick: Function = () => {};
export let onSourceClick: Function = () => {};
@@ -93,11 +95,9 @@
code={token?.text ?? ''}
{attributes}
{save}
onCode={(value) => {
dispatch('code', value);
}}
{onCode}
onSave={(value) => {
dispatch('update', {
onUpdate({
raw: token.raw,
oldContent: token.text,
newContent: value
@@ -267,16 +267,7 @@
</div>
</Collapsible>
{:else if token.type === 'html'}
{@const html = DOMPurify.sanitize(token.text)}
{#if html && html.includes('<video')}
{@html html}
{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
{@html `${token.text}`}
{:else if token.text.includes(`<source_id`)}
<Source {id} {token} onClick={onSourceClick} />
{:else}
{token.text}
{/if}
<HtmlToken {id} {token} {onSourceClick} />
{:else if token.type === 'iframe'}
<iframe
src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content"
@@ -200,9 +200,11 @@
await initHandler();
await tick();
const messageElement = document.getElementById(`message-${messageId}`);
if (messageElement) {
messageElement.scrollIntoView({ block: 'start' });
if ($settings?.scrollOnBranchChange ?? true) {
const messageElement = document.getElementById(`message-${messageId}`);
if (messageElement) {
messageElement.scrollIntoView({ block: 'start' });
}
}
});
</script>
@@ -238,10 +240,9 @@
messageChildrenIds = history.messages[currentMessageId].childrenIds;
}
history.currentId = currentMessageId;
await tick();
await updateChat();
triggerScroll();
// await tick();
// await updateChat();
// triggerScroll();
}
}}
>
@@ -293,7 +294,7 @@
<div class="w-full rounded-xl pl-5 pr-2 py-2">
<Name>
Merged Response
{$i18n.t('Merged Response')}
{#if message.timestamp}
<span
@@ -47,6 +47,7 @@
import CodeExecutions from './CodeExecutions.svelte';
import ContentRenderer from './ContentRenderer.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
import FileItem from '$lib/components/common/FileItem.svelte';
interface MessageType {
id: string;
@@ -155,7 +156,8 @@
const copyToClipboard = async (text) => {
text = removeAllDetails(text);
const res = await _copyToClipboard(text);
const res = await _copyToClipboard(text, $settings?.copyFormatted ?? false);
if (res) {
toast.success($i18n.t('Copying to clipboard was successful!'));
}
@@ -613,18 +615,6 @@
</Name>
<div>
{#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message.files as file}
<div>
{#if file.type === 'image'}
<Image src={file.url} alt={message.content} />
{/if}
</div>
{/each}
</div>
{/if}
<div class="chat-{message.role} w-full min-w-full markdown-prose">
<div>
{#if (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length > 0}
@@ -633,12 +623,6 @@
).at(-1)}
{#if !status?.hidden}
<div class="status-description flex items-center gap-2 py-0.5">
{#if status?.done === false}
<div class="">
<Spinner className="size-4" />
</div>
{/if}
{#if status?.action === 'web_search' && status?.urls}
<WebSearchResults {status}>
<div class="flex flex-col justify-center -space-y-0.5">
@@ -693,6 +677,8 @@
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else if status?.description === 'Searching the web'}
{$i18n.t('Searching the web...')}
{:else}
{status?.description}
{/if}
@@ -703,6 +689,27 @@
{/if}
{/if}
{#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0}
<div class="my-1 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message.files as file}
<div>
{#if file.type === 'image'}
<Image src={file.url} alt={message.content} />
{:else}
<FileItem
item={file}
url={file.url}
name={file.name}
type={file.type}
size={file?.size}
colorClassName="bg-white dark:bg-gray-850 "
/>
{/if}
</div>
{/each}
</div>
{/if}
{#if edit === true}
<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
<textarea
@@ -766,7 +773,7 @@
</div>
{:else}
<div class="w-full flex flex-col relative" id="response-content-container">
{#if message.content === '' && !message.error}
{#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0}
<Skeleton />
{:else if message.content && message.error !== true}
<!-- always show message contents even if there's an error -->
@@ -1009,83 +1016,85 @@
</button>
</Tooltip>
<Tooltip content={$i18n.t('Read Aloud')} placement="bottom">
<button
id="speak-button-{message.id}"
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
if (!loadingSpeech) {
toggleSpeakMessage();
}
}}
>
{#if loadingSpeech}
<svg
class=" w-4 h-4"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_S1WN {
animation: spinner_MGfb 0.8s linear infinite;
animation-delay: -0.8s;
}
.spinner_Km9P {
animation-delay: -0.65s;
}
.spinner_JApP {
animation-delay: -0.5s;
}
@keyframes spinner_MGfb {
93.75%,
100% {
opacity: 0.2;
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.tts ?? true)}
<Tooltip content={$i18n.t('Read Aloud')} placement="bottom">
<button
id="speak-button-{message.id}"
class="{isLastMessage
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
if (!loadingSpeech) {
toggleSpeakMessage();
}
}}
>
{#if loadingSpeech}
<svg
class=" w-4 h-4"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<style>
.spinner_S1WN {
animation: spinner_MGfb 0.8s linear infinite;
animation-delay: -0.8s;
}
}
</style>
<circle class="spinner_S1WN" cx="4" cy="12" r="3" />
<circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3" />
<circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" />
</svg>
{:else if speaking}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
/>
</svg>
{/if}
</button>
</Tooltip>
.spinner_Km9P {
animation-delay: -0.65s;
}
.spinner_JApP {
animation-delay: -0.5s;
}
@keyframes spinner_MGfb {
93.75%,
100% {
opacity: 0.2;
}
}
</style>
<circle class="spinner_S1WN" cx="4" cy="12" r="3" />
<circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3" />
<circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3" />
</svg>
{:else if speaking}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 9.75 19.5 12m0 0 2.25 2.25M19.5 12l2.25-2.25M19.5 12l-2.25 2.25m-10.5-6 4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
/>
</svg>
{/if}
</button>
</Tooltip>
{/if}
{#if $config?.features.enable_image_generation && ($user?.role === 'admin' || $user?.permissions?.features?.image_generation) && !readOnly}
<Tooltip content={$i18n.t('Generate Image')} placement="bottom">
@@ -65,10 +65,12 @@
await tick();
messageEditTextAreaElement.style.height = '';
messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
if (messageEditTextAreaElement) {
messageEditTextAreaElement.style.height = '';
messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
messageEditTextAreaElement?.focus();
messageEditTextAreaElement?.focus();
}
};
const editMessageConfirmHandler = async (submit = true) => {
@@ -634,7 +634,7 @@
{/if}
</button>
{:else}
<div>
<div class="">
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
@@ -778,6 +778,8 @@
</div>
</button>
</div>
{:else}
<div class="mb-3"></div>
{/if}
<div class="hidden w-[42rem]" />
+2 -2
View File
@@ -47,8 +47,8 @@
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
<nav class="sticky top-0 z-30 w-full py-1.5 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full px-1.5">
<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full pl-1.5 pr-1">
<div
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
></div>
+11 -5
View File
@@ -159,15 +159,21 @@ Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+120 -113
View File
@@ -87,7 +87,7 @@
</script>
<div class="flex flex-col h-full justify-between text-sm">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
<input
id="profile-image-input"
bind:this={profileImageInputElement}
@@ -236,7 +236,7 @@
<div class="flex-1">
<input
class="w-full text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
type="text"
bind:value={name}
required
@@ -265,98 +265,46 @@
{/if}
</div>
<div class="py-0.5">
<hr class="border-gray-50 dark:border-gray-850 my-2" />
<div class="my-2">
<UpdatePassword />
</div>
<hr class="border-gray-50 dark:border-gray-850 my-2" />
{#if ($config?.features?.enable_api_key ?? true) || $user?.role === 'admin'}
<div class="flex justify-between items-center text-sm mb-2">
<div class=" font-medium">{$i18n.t('API keys')}</div>
<button
class=" text-xs font-medium text-gray-500"
type="button"
on:click={() => {
showAPIKeys = !showAPIKeys;
}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div>
<div class="flex justify-between items-center text-sm">
<div class=" font-medium">{$i18n.t('API keys')}</div>
<button
class=" text-xs font-medium text-gray-500"
type="button"
on:click={() => {
showAPIKeys = !showAPIKeys;
}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
>
</div>
{#if showAPIKeys}
<div class="flex flex-col gap-4">
{#if $user?.role === 'admin'}
<div class="justify-between w-full">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-medium mb-1">{$i18n.t('JWT Token')}</div>
</div>
{#if showAPIKeys}
<div class="flex flex-col gap-4">
<div class="justify-between w-full">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-medium">{$i18n.t('JWT Token')}</div>
</div>
<div class="flex mt-2">
<SensitiveInput value={localStorage.token} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
on:click={() => {
copyToClipboard(localStorage.token);
JWTTokenCopied = true;
setTimeout(() => {
JWTTokenCopied = false;
}, 2000);
}}
>
{#if JWTTokenCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
{:else}
<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="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>
</div>
{#if $config?.features?.enable_api_key ?? true}
<div class="justify-between w-full">
<div class="flex justify-between w-full">
<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
</div>
<div class="flex mt-2">
{#if APIKey}
<SensitiveInput value={APIKey} readOnly={true} />
<div class="flex">
<SensitiveInput value={localStorage.token} readOnly={true} />
<button
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
on:click={() => {
copyToClipboard(APIKey);
APIKeyCopied = true;
copyToClipboard(localStorage.token);
JWTTokenCopied = true;
setTimeout(() => {
APIKeyCopied = false;
JWTTokenCopied = false;
}, 2000);
}}
>
{#if APIKeyCopied}
{#if JWTTokenCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@@ -389,46 +337,105 @@
</svg>
{/if}
</button>
</div>
</div>
{/if}
{#if $config?.features?.enable_api_key ?? true}
<div class="justify-between w-full">
{#if $user?.role === 'admin'}
<div class="flex justify-between w-full">
<div class="self-center text-xs font-medium mb-1">{$i18n.t('API Key')}</div>
</div>
{/if}
<div class="flex">
{#if APIKey}
<SensitiveInput value={APIKey} readOnly={true} />
<Tooltip content={$i18n.t('Create new key')}>
<button
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
on:click={() => {
copyToClipboard(APIKey);
APIKeyCopied = true;
setTimeout(() => {
APIKeyCopied = false;
}, 2000);
}}
>
{#if APIKeyCopied}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
{:else}
<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="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
clip-rule="evenodd"
/>
<path
fill-rule="evenodd"
d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
<Tooltip content={$i18n.t('Create new key')}>
<button
class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
on:click={() => {
createAPIKeyHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
on:click={() => {
createAPIKeyHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{:else}
<button
class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
on:click={() => {
createAPIKeyHandler();
}}
>
<Plus strokeWidth="2" className=" size-3.5" />
<Plus strokeWidth="2" className=" size-3.5" />
{$i18n.t('Create new secret key')}</button
>
{/if}
{$i18n.t('Create new secret key')}</button
>
{/if}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
{/if}
</div>
@@ -334,7 +334,7 @@
class="w-full rounded-lg pl-2 py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
type="text"
placeholder={$i18n.t(
'Enter comma-seperated "token:bias_value" pairs (example: 5432:100, 413:-100)'
'Enter comma-separated "token:bias_value" pairs (example: 5432:100, 413:-100)'
)}
bind:value={params.logit_bias}
autocomplete="off"
+25 -22
View File
@@ -140,28 +140,31 @@
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Import Chats')}</div>
</button>
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
exportChats();
}}
>
<div class=" self-center mr-3">
<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>
<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
</button>
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<button
class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
on:click={() => {
exportChats();
}}
>
<div class=" self-center mr-3">
<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>
<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
</button>
{/if}
</div>
<hr class=" border-gray-100 dark:border-gray-850" />

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