Compare commits

...
312 Commits
Author SHA1 Message Date
Tim Jaeryang Baek 918f507d8c Merge pull request #17070 from open-webui/dev
0.6.27
2025-09-09 18:34:15 +04:00
Timothy Jaeryang Baek f7e85cd0bf refac 2025-09-09 18:31:06 +04:00
Timothy Jaeryang Baek 4b4583df62 refac: styling 2025-09-09 18:28:22 +04:00
Timothy Jaeryang Baek bcf76dfa74 refac: changelog 2025-09-09 18:24:16 +04:00
Timothy Jaeryang Baek 485392fe63 chore: format 2025-09-09 18:19:31 +04:00
Timothy Jaeryang Baek cbb4684b16 chore 2025-09-09 18:16:24 +04:00
Timothy Jaeryang Baek 0531ca6530 refac/fix 2025-09-09 18:10:48 +04:00
Timothy Jaeryang Baek 32cb9df3c4 refac/enh: knowledge ac backend validation 2025-09-09 18:08:31 +04:00
Tim Jaeryang Baek 71fd483fba Merge pull request #17276 from Elettrotecnica/extend-docling-configuration
feat: Extend docling configuration options
2025-09-09 18:04:30 +04:00
Timothy Jaeryang Baek 63f38c584f refac 2025-09-09 18:02:51 +04:00
Timothy Jaeryang Baek 77779b30d4 refac 2025-09-09 18:01:59 +04:00
Timothy Jaeryang Baek d0f338bb99 refac/enh: ability to export/sync function valves 2025-09-09 17:48:41 +04:00
Timothy Jaeryang Baek 63ca0b8cba refac 2025-09-09 17:36:18 +04:00
Timothy Jaeryang Baek d0b20df46c refac: citations display 2025-09-09 17:27:38 +04:00
Timothy Jaeryang Baek 0214c1e66c refac 2025-09-09 16:48:59 +04:00
Tim Jaeryang Baek 015342e669 Merge pull request #17281 from Classic298/changelog-final
chore: Changelog 0.6.27 - New Changelog Style
2025-09-09 16:43:22 +04:00
Classic298 0cd32b015b Update CHANGELOG.md 2025-09-09 14:06:21 +02:00
Timothy Jaeryang Baek 5f0d262c59 fix: yt embed 2025-09-09 16:00:42 +04:00
Timothy Jaeryang Baek 7fc3ac38ac chore 2025-09-09 15:58:58 +04:00
Tim Jaeryang Baek a04e8ee84e Merge pull request #17295 from ShirasawaSama/i18n/improve-chinese-translation
i18n: improve Chinese translation
2025-09-09 15:46:57 +04:00
Shirasawa 3cce416cab feat: improve Chinese translation 2025-09-09 18:56:45 +08:00
Timothy Jaeryang Baek f17d8b5d19 refac: forward cred to static paths 2025-09-09 14:11:17 +04:00
Tim Jaeryang Baek c881e033b2 Merge pull request #17285 from ShirasawaSama/patch-15
feat: change default permission check for regenerate and delete actions
2025-09-09 14:06:28 +04:00
Timothy Jaeryang Baek 3ae547f796 refac 2025-09-09 14:03:47 +04:00
Timothy Jaeryang Baek 79e92378de refac 2025-09-09 14:02:08 +04:00
Timothy Jaeryang Baek 53ecfc2b5d chore: bump authlib 2025-09-09 13:46:21 +04:00
Shirasawa d783708745 feat: change default permission check for regenerate and delete actions 2025-09-09 15:42:58 +08:00
Classic298 218701e617 Update CHANGELOG.md 2025-09-08 23:42:58 +02:00
Classic298 c6a46195c0 Changelog dev (#18)
* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md

* Update CHANGELOG.md
2025-09-08 23:40:39 +02:00
Timothy Jaeryang Baek 4a76bea80c refac 2025-09-08 21:03:29 +04:00
Timothy Jaeryang Baek fcebc82ec2 refac: chat controls panel resize logic 2025-09-08 20:54:18 +04:00
Antonio Pisano daa2a036f8 Extend docling configuration options to include:
* do_ocr
* force_ocr
* pdf_backend
* table_mode
* pipeline

as per https://github.com/docling-project/docling-serve/blob/main/docs/usage.md

See https://github.com/open-webui/open-webui/issues/17148
2025-09-08 18:51:33 +02:00
Timothy Jaeryang Baek 41f9a8caff refac 2025-09-08 20:25:19 +04:00
Timothy Jaeryang Baek 6f6412dd16 refac 2025-09-08 19:53:44 +04:00
Timothy Jaeryang Baek 2b2d123531 refac: oauth auth type in openai connection 2025-09-08 19:42:50 +04:00
Timothy Jaeryang Baek 474df5e534 refac 2025-09-08 19:18:55 +04:00
Timothy Jaeryang Baek 30d1dc2c60 refac 2025-09-08 19:12:20 +04:00
Timothy Jaeryang Baek 4cea3a57be refac 2025-09-08 19:09:26 +04:00
Timothy Jaeryang Baek 8a9f862701 refac 2025-09-08 19:07:00 +04:00
Timothy Jaeryang Baek 001dab0439 refac: wording 2025-09-08 18:55:57 +04:00
Timothy Jaeryang Baek b786d1e3f3 refac 2025-09-08 18:52:59 +04:00
Timothy Jaeryang Baek b5bb6ae177 refac 2025-09-08 18:50:23 +04:00
Timothy Jaeryang Baek f71834720e refac 2025-09-08 18:35:09 +04:00
Timothy Jaeryang Baek f11bdc6ab5 refac 2025-09-08 18:23:44 +04:00
Timothy Jaeryang Baek 35c1c48fd2 refac 2025-09-08 18:18:04 +04:00
Timothy Jaeryang Baek fc11e4384f refac 2025-09-08 18:17:11 +04:00
Timothy Jaeryang Baek 7693d0e2b0 refac 2025-09-08 18:09:01 +04:00
Timothy Jaeryang BaekandClassic298 217f4daef0 feat: server-side OAuth token management system
Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
2025-09-08 18:05:43 +04:00
Timothy Jaeryang Baek 6d38ac41b6 refac 2025-09-08 14:36:00 +04:00
Timothy Jaeryang Baek 91755309ce refac 2025-09-08 14:18:25 +04:00
Timothy Jaeryang Baek a28ca30519 refac/fix: source citation 2025-09-08 02:35:51 +04:00
Timothy Jaeryang Baek e023a98f11 refac: submit suggestion prompt by default 2025-09-07 19:24:32 +04:00
Timothy Jaeryang Baek 6dc0df2473 refac 2025-09-07 05:17:38 +04:00
Timothy Jaeryang Baek cd5e2be27b refac 2025-09-07 05:09:14 +04:00
Timothy Jaeryang Baek 3d37e4a42d refac 2025-09-07 05:06:03 +04:00
Timothy Jaeryang Baek 7f523de408 refac 2025-09-07 04:27:42 +04:00
Timothy Jaeryang Baek f2525ebc44 refac 2025-09-07 04:25:52 +04:00
Timothy Jaeryang Baek 1cdb7aed1e refac/enh: status history 2025-09-07 04:21:46 +04:00
Timothy Jaeryang Baek 16090bc272 refac 2025-09-07 03:01:25 +04:00
Timothy Jaeryang Baek 0a85dd4bca refac: web search link display 2025-09-07 03:00:28 +04:00
Timothy Jaeryang Baek 33f04a7714 refac: styling 2025-09-07 02:36:32 +04:00
Timothy Jaeryang Baek c9282135c4 refac 2025-09-07 02:02:21 +04:00
Timothy Jaeryang Baek 3d6d050ad8 refac/enh: display oauth error as toast 2025-09-07 01:48:52 +04:00
Timothy Jaeryang Baek 9368d0ac75 refac/fix: web search always on setting 2025-09-07 01:15:10 +04:00
Timothy Jaeryang Baek 40e40d1ddd enh/sec: verify folder data integrity
#17182
2025-09-07 01:04:56 +04:00
Timothy Jaeryang Baek d9f97c832c refac 2025-09-07 00:28:52 +04:00
Timothy Jaeryang Baek 4f2b821088 refac/enh: oauth redirect support 2025-09-07 00:22:52 +04:00
Timothy Jaeryang Baek eb10ff2ae6 refac: rm substandard code 2025-09-07 00:16:07 +04:00
Tim Jaeryang Baek b70e910e4b Merge pull request #17232 from open-webui/dependabot/pip/pip-83da9e8e4c
build(deps): bump the pip group across 2 directories with 2 updates
2025-09-07 00:00:14 +04:00
Timothy Jaeryang Baek 955fc5c736 refac: styling 2025-09-06 23:58:49 +04:00
Timothy Jaeryang Baek 9aac148908 fix: knowledge update backend issue 2025-09-06 23:44:29 +04:00
Tim Jaeryang Baek 183ba83920 Merge pull request #17245 from Classic298/patch-1
chore: changes to bug report issue template
2025-09-06 23:31:51 +04:00
Classic298 85cc067599 chore: changes to bug report issue template 2025-09-06 21:01:28 +02:00
Tim Jaeryang Baek fe0665605a Merge pull request #17229 from ShirasawaSama/feat/dynamic-load-heic2any
feat: dynamically load heic2any in channel MessageInput
2025-09-06 18:26:57 +04:00
Tim Jaeryang Baek e593ea8935 Merge pull request #17228 from ShirasawaSama/patch-13
feat: improve Chinese translation
2025-09-06 18:26:46 +04:00
dependabot[bot] e114a40bb8 build(deps): bump the pip group across 2 directories with 2 updates
Bumps the pip group with 2 updates in the / directory: [langchain-community](https://github.com/langchain-ai/langchain) and [pypdf](https://github.com/py-pdf/pypdf).
Bumps the pip group with 2 updates in the /backend directory: [langchain-community](https://github.com/langchain-ai/langchain) and [pypdf](https://github.com/py-pdf/pypdf).


Updates `langchain-community` from 0.3.26 to 0.3.27
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain==0.3.26...langchain==0.3.27)

Updates `pypdf` from 4.3.1 to 6.0.0
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/4.3.1...6.0.0)

Updates `langchain-community` from 0.3.26 to 0.3.27
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain==0.3.26...langchain==0.3.27)

Updates `pypdf` from 4.3.1 to 6.0.0
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/4.3.1...6.0.0)

---
updated-dependencies:
- dependency-name: langchain-community
  dependency-version: 0.3.27
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: pypdf
  dependency-version: 6.0.0
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: langchain-community
  dependency-version: 0.3.27
  dependency-type: direct:production
  dependency-group: pip
- dependency-name: pypdf
  dependency-version: 6.0.0
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-05 15:50:11 +00:00
Shirasawa eb64f7bdb3 feat: dynamically load heic2any in channel MessageInput 2025-09-05 21:34:17 +08:00
Timothy Jaeryang Baek 1bc9711afd refac 2025-09-05 17:28:05 +04:00
Shirasawa de48a32ea2 feat: improve Chinese translation 2025-09-05 21:26:01 +08:00
Tim Jaeryang Baek 5b3eae3855 Merge pull request #17225 from ShirasawaSama/feat/dynamic-load-heic2any
feat: dynamically load heic2any to accelerate initial page loading speed and fix heic convert bug
2025-09-05 16:42:45 +04:00
Tim Jaeryang Baek 9764bf3b78 Merge pull request #17222 from ShirasawaSama/patch-14
feat: dynamically load PDFjs to accelerate initial page loading speed
2025-09-05 16:42:09 +04:00
Shirasawa a74ec200b3 feat: dynamically load heic2any to accelerate initial page loading speed 2025-09-05 20:07:12 +08:00
Shirasawa 49e045ea3d feat: dynamically load pdfjs 2025-09-05 17:55:04 +08:00
Timothy Jaeryang Baek c0a47169fa refac: emoji picker case sensitivity 2025-09-04 22:10:46 +04:00
Tim Jaeryang Baek a140cfc521 Merge pull request #17191 from Ithanil/fix_chat_button_text
fix: don't clip descending characters on chat buttons + don't capitalize
2025-09-04 20:23:40 +04:00
Tim Jaeryang Baek ea2f97004b Merge pull request #17186 from Viruzaum/tooltip-fix
fix: remove duplicated tooltip from follow-up
2025-09-04 20:22:59 +04:00
Tim Jaeryang Baek 93132abb40 Merge pull request #17195 from ShirasawaSama/patch-14
fix: fix error when stopping non-existent task
2025-09-04 20:21:09 +04:00
Tim Jaeryang Baek df0b1e5552 Merge pull request #17190 from aREversez/patch-17185-clean
i18n: improve Chinese localization
2025-09-04 20:13:46 +04:00
Shirasawa e5ea595425 fix: fix error when stopping non-existent task 2025-09-04 16:52:19 +08:00
Jan Kessler a5b781f3d8 prevent clipping of descending glyphs on chat button + don't capitalize 2025-09-04 09:28:16 +02:00
aREversez 5128402282 Update translation.json 2025-09-04 12:32:17 +08:00
Timothy Jaeryang Baek 11dea8795b refac 2025-09-04 02:56:21 +04:00
Timothy Jaeryang Baek b70c0f36c0 enh: emoji folder icon 2025-09-04 02:50:50 +04:00
Timothy Jaeryang Baek e42ee34672 refac 2025-09-03 20:56:25 +04:00
Timothy Jaeryang Baek 71b6a942fe refac: styling 2025-09-03 20:53:50 +04:00
Timothy Jaeryang Baek 5bdd334b74 enh: azure openai toggle 2025-09-03 20:28:29 +04:00
viruz 6d6794aa85 fix: remove duplicated tooltip from follow-up button in ResponseMessage component 2025-09-03 13:18:45 -03:00
Timothy Jaeryang Baek 1588f42fe7 refac 2025-09-03 20:17:43 +04:00
Timothy Jaeryang Baek 029db5c635 refac: styling 2025-09-03 19:59:27 +04:00
Timothy Jaeryang Baek b85700f6ca refac: styling 2025-09-03 19:13:17 +04:00
Timothy Jaeryang Baek fbbe1117ae refac/enh: file count in knowledge 2025-09-03 19:00:35 +04:00
Timothy Jaeryang Baek df66e21472 enh: regex pattern support for groups 2025-09-03 18:50:02 +04:00
Timothy Jaeryang Baek 51fc792501 refac: styling 2025-09-03 18:47:50 +04:00
Timothy Jaeryang Baek 7a166152d9 refac: styling 2025-09-03 18:40:25 +04:00
Timothy Jaeryang Baek 926954f93b refac: styling 2025-09-03 18:37:25 +04:00
Tim Jaeryang Baek 5a596ae40c Merge pull request #17180 from Viruzaum/untitled-translation-fix
fix/i18n: Fix missing translation of default title of AddTextContentModel
2025-09-03 18:05:56 +04:00
Tim Jaeryang Baek 048f30aa97 Merge pull request #17158 from sihyeonn/fix/sh-cache
perf: fix cache key generation for model list caching
2025-09-03 16:18:33 +04:00
Timothy Jaeryang Baek 66bf28cd85 refac 2025-09-03 15:48:07 +04:00
Timothy Jaeryang Baek bbe1167958 refac/fix: pyodide import issue 2025-09-03 15:46:42 +04:00
Tim Jaeryang Baek 357a53eb6f Merge pull request #17147 from anfuerer/dev
feat: Azure OpenAI image generation support
2025-09-03 14:12:48 +04:00
Timothy Jaeryang Baek 37bf0087e5 refac: tool message format 2025-09-03 13:57:14 +04:00
Tim Jaeryang Baek e5829572ff Merge pull request #17175 from garylab/bugfix/api-config
Bugfix: Add verify token from headers also for /api/config endpoint
2025-09-03 13:39:25 +04:00
Timothy Jaeryang Baek 4ca936f0bf refac 2025-09-03 13:38:07 +04:00
Tim Jaeryang Baek 0a351580f8 Merge pull request #17137 from acwoo97/feat/knowledge-update-race-condition
fix: prevent double-save race by awaiting API calls and adding isSaving guard
2025-09-03 13:36:51 +04:00
Tim Jaeryang Baek 472b71f331 Merge pull request #17166 from sihyeonn/perf/sh-model-layer
perf: fix N+1 query issues in user group access control validation
2025-09-03 13:36:01 +04:00
Tim Jaeryang Baek 308f4d6b26 Merge pull request #17159 from sihyeonn/perf/sh-prompts
perf: fix N+1 query issue in get_prompts method
2025-09-03 13:26:29 +04:00
Gary Meng 2d62796616 Allow user get /api/config with auth header 2025-09-03 13:25:09 +04:00
Tim Jaeryang Baek 330bec67b7 Merge pull request #17162 from sihyeonn/perf/sh-tools
perf: fix N+1 query issue in get_tools method
2025-09-03 13:22:37 +04:00
Tim Jaeryang Baek 2890c6d62d Merge pull request #17161 from sihyeonn/perf/sh-models
perf: fix N+1 query issue in get_models method
2025-09-03 13:22:27 +04:00
Tim Jaeryang Baek 9119db001d Merge pull request #17160 from sihyeonn/perf/sh-knowledge-base
perf: fix N+1 query issue in get_knowledge_bases method
2025-09-03 13:22:19 +04:00
Tim Jaeryang Baek c7faccd5fa Merge pull request #17165 from sihyeonn/perf/sh-tools-router
perf: fix N+1 query issue in tools access control checking
2025-09-03 13:13:49 +04:00
Sihyeon Jang eff06538a6 perf: fix N+1 query issues in user group access control validation
- Pre-fetch user group IDs in get_*_by_user_id methods across models layer
- Pass user_group_ids to has_access to avoid repeated group queries
- Reduce query count from 1+N to 1+1 pattern for access control validation
- Apply consistent optimization across knowledge, models, notes, prompts, and tools

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:56:48 +09:00
Sihyeon Jang 0503fbd2e3 perf: fix N+1 query issue in tools access control checking
- Pre-fetch user group IDs once per request in get_tools endpoint
- Pass user_group_ids to has_access to avoid repeated group queries
- Optimize access control validation from 1+N to 1+1 query pattern
- Reduce database load when checking multiple tools access permissions

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:49:53 +09:00
Sihyeon Jang 03d1d2a88b perf: fix N+1 query issue in get_tools method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern for tools listing

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:35:35 +09:00
Sihyeon Jang c0b3db38a5 perf: fix N+1 query issue in get_models method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern for models with base_model_id

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:33:41 +09:00
Sihyeon Jang f588655f7f perf: fix N+1 query issue in get_knowledge_bases method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:29:47 +09:00
Sihyeon Jang c45201a8a2 perf: fix N+1 query issue in get_prompts method
- Replace individual user queries with batch fetching
- Use single query to fetch all required users at once
- Implement O(1) user lookup with dictionary mapping
- Reduce query count from 1+N to 1+1 pattern

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:25:26 +09:00
Sihyeon Jang 3ccbb46938 perf: fix cache key generation for model list caching
- Replace Request object with user.id in cache key for get_all_models
- Request objects are new instances per HTTP request, preventing cache hits
- Cache keys now use user.id ensuring proper cache functionality
- Affects both Ollama and OpenAI model list endpoints

Signed-off-by: Sihyeon Jang <sihyeon.jang@navercorp.com>
2025-09-03 05:17:41 +09:00
viruz a980af3ca4 fix: update default name to use i18n translation 2025-09-02 15:47:16 -03:00
Timothy Jaeryang Baek 22c4ef4fb0 enh: delete_file query param 2025-09-02 21:32:07 +04:00
Andreas Fuerer df0d29c81c style: fix formatting issues 2025-09-02 19:16:02 +02:00
Andreas Fuerer bc6afc9057 feature: Azure OpenAI image generation support
The image generation API used on Azure OpenAI requires to specify the API
version by appending an `api-version` query parameter to the endpoint URL.
Added the environment variable `IMAGES_OPENAI_API_VERSION` with
configuration functionality in the administration UI.
2025-09-02 15:51:45 +02:00
Timothy Jaeryang Baek 182408a52e refac 2025-09-02 17:03:59 +04:00
Chanwoo An 513cab94b1 fix: prevent double-save race by awaiting API calls and adding isSaving guard 2025-09-02 20:53:04 +09:00
Tim Jaeryang Baek d01439d144 Merge pull request #17093 from open-webui/dependabot/pip/backend/dev/argon2-cffi-25.1.0
build(deps): bump argon2-cffi from 23.1.0 to 25.1.0 in /backend
2025-09-02 02:41:49 +04:00
Tim Jaeryang Baek 0a535a6fed Merge pull request #17107 from rgaricano/dev-es_ES
UPD: i18n es-ES Translation v.0.6.26
2025-09-01 22:52:14 +04:00
Tim Jaeryang Baek 3a38173c66 Merge pull request #17098 from open-webui/dependabot/github_actions/dev/actions/download-artifact-5
build(deps): bump actions/download-artifact from 4 to 5
2025-09-01 22:51:34 +04:00
Tim Jaeryang Baek adbaaa65f9 Merge pull request #17097 from open-webui/dependabot/npm_and_yarn/dev/tiptap/extension-highlight-3.3.0
build(deps): bump @tiptap/extension-highlight from 3.0.7 to 3.3.0
2025-09-01 22:51:23 +04:00
Tim Jaeryang Baek 379592dae4 Merge pull request #17096 from open-webui/dependabot/pip/backend/dev/pytest-approx-eq-8.4.1
build(deps): update pytest requirement from ~=8.3.5 to ~=8.4.1 in /backend
2025-09-01 22:51:02 +04:00
Tim Jaeryang Baek 22ff239e3a Merge pull request #17094 from open-webui/dependabot/pip/backend/dev/youtube-transcript-api-1.2.2
build(deps): bump youtube-transcript-api from 1.1.0 to 1.2.2 in /backend
2025-09-01 22:50:57 +04:00
Timothy Jaeryang Baek e830b4959e enh: llama cpp timing stats 2025-09-01 22:49:49 +04:00
Tim Jaeryang Baek 1c1b32b390 Merge pull request #17099 from open-webui/dependabot/github_actions/dev/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-09-01 22:48:04 +04:00
Timothy Jaeryang Baek e6daad2ab9 chore: bump mermaid 2025-09-01 22:45:06 +04:00
Timothy Jaeryang Baek 4f2e426fc7 refac 2025-09-01 14:27:20 +04:00
Timothy Jaeryang Baek 609a6a3721 refac 2025-09-01 14:22:02 +04:00
Timothy Jaeryang Baek 85153afda8 refac 2025-09-01 14:21:17 +04:00
Timothy Jaeryang Baek f56889c5c7 fix: fillter exception handling 2025-09-01 14:14:20 +04:00
_00_ cef4028c1c UPD: i18n Translation es-ES v.0.6.27
### Update of i18n Translation es-ES v.0.6.27

Added new strings
2025-09-01 09:48:05 +02:00
Timothy Jaeryang Baek 4b97884fce refac 2025-09-01 11:46:52 +04:00
Tim Jaeryang Baek 9ac7f2635c Merge pull request #17101 from open-webui/dependabot/npm_and_yarn/dev/dompurify-3.2.6
build(deps): bump dompurify from 3.2.5 to 3.2.6
2025-09-01 10:37:39 +04:00
Tim Jaeryang Baek 2d6f68eb2d Merge pull request #17095 from open-webui/dependabot/npm_and_yarn/dev/pyodide-0.28.2
build(deps): bump pyodide from 0.27.7 to 0.28.2
2025-09-01 10:37:28 +04:00
Tim Jaeryang Baek 61e7aa2229 Merge pull request #17089 from open-webui/dependabot/npm_and_yarn/dev/pdfjs-dist-5.4.149
build(deps): bump pdfjs-dist from 5.3.93 to 5.4.149
2025-09-01 10:35:49 +04:00
dependabot[bot] 9f5df72d7c build(deps): bump dompurify from 3.2.5 to 3.2.6
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.2.5 to 3.2.6.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.2.5...3.2.6)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:16:43 +00:00
dependabot[bot] 4dd9484b48 build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:03:14 +00:00
dependabot[bot] dbe36e841a build(deps): bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:02:57 +00:00
dependabot[bot] 201eaa9655 build(deps): bump @tiptap/extension-highlight from 3.0.7 to 3.3.0
Bumps [@tiptap/extension-highlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-highlight) from 3.0.7 to 3.3.0.
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-highlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.3.0/packages/extension-highlight)

---
updated-dependencies:
- dependency-name: "@tiptap/extension-highlight"
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:02:48 +00:00
dependabot[bot] 4cd550d1d0 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.5...8.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:02:31 +00:00
dependabot[bot] 94dae76d0f build(deps): bump pyodide from 0.27.7 to 0.28.2
Bumps [pyodide](https://github.com/pyodide/pyodide) from 0.27.7 to 0.28.2.
- [Release notes](https://github.com/pyodide/pyodide/releases)
- [Commits](https://github.com/pyodide/pyodide/compare/0.27.7...0.28.2)

---
updated-dependencies:
- dependency-name: pyodide
  dependency-version: 0.28.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 06:01:03 +00:00
dependabot[bot] 3e69f10af7 build(deps): bump youtube-transcript-api from 1.1.0 to 1.2.2 in /backend
Bumps [youtube-transcript-api](https://github.com/jdepoix/youtube-transcript-api) from 1.1.0 to 1.2.2.
- [Release notes](https://github.com/jdepoix/youtube-transcript-api/releases)
- [Commits](https://github.com/jdepoix/youtube-transcript-api/compare/v1.1.0...v1.2.2)

---
updated-dependencies:
- dependency-name: youtube-transcript-api
  dependency-version: 1.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 05:59:27 +00:00
dependabot[bot] bcc0cbf895 build(deps): bump argon2-cffi from 23.1.0 to 25.1.0 in /backend
Bumps [argon2-cffi](https://github.com/hynek/argon2-cffi) from 23.1.0 to 25.1.0.
- [Release notes](https://github.com/hynek/argon2-cffi/releases)
- [Changelog](https://github.com/hynek/argon2-cffi/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hynek/argon2-cffi/compare/23.1.0...25.1.0)

---
updated-dependencies:
- dependency-name: argon2-cffi
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 05:59:19 +00:00
dependabot[bot] 0e0c1c4ca4 build(deps): bump pdfjs-dist from 5.3.93 to 5.4.149
Bumps [pdfjs-dist](https://github.com/mozilla/pdf.js) from 5.3.93 to 5.4.149.
- [Release notes](https://github.com/mozilla/pdf.js/releases)
- [Commits](https://github.com/mozilla/pdf.js/compare/v5.3.93...v5.4.149)

---
updated-dependencies:
- dependency-name: pdfjs-dist
  dependency-version: 5.4.149
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-01 05:53:53 +00:00
Timothy Jaeryang Baek ed5d95f434 refac 2025-09-01 02:41:29 +04:00
Tim Jaeryang Baek 3d2fcef28f Merge pull request #17085 from ShirasawaSama/patch-10
fix: fix event binding for composition end in MessageInput
2025-09-01 02:10:04 +04:00
Timothy Jaeryang Baek 77b65ccbfb refac/enh: forward headers to tool server 2025-09-01 01:52:10 +04:00
Timothy Jaeryang Baek 487979859a fix: web/youtube attachements 2025-09-01 01:22:50 +04:00
Timothy Jaeryang Baek ac0243e8b7 refac 2025-09-01 00:57:13 +04:00
Shirasawa 5037197541 fix: fix event binding for composition end in MessageInput 2025-09-01 04:49:06 +08:00
Timothy Jaeryang Baek b45219c8b1 refac 2025-09-01 00:04:26 +04:00
Tim Jaeryang Baek ebfc887869 Merge pull request #17061 from AdamJohnSwan/dev
fix: for forming TTS request URL when there is a slash at the end of the base URL
2025-09-01 00:01:43 +04:00
Tim Jaeryang Baek 719d115d49 Merge pull request #17049 from rgaricano/dev-FIX_lex-sem
FIX: Hybrid Search
2025-09-01 00:00:25 +04:00
Tim Jaeryang Baek 4e7b0ea4b4 Merge pull request #17013 from athoik/fix-17000
fix: handle unicode filenames in external document loader
2025-08-31 23:58:52 +04:00
Timothy Jaeryang Baek c2b4976c82 enh: PGVECTOR_CREATE_EXTENSION env var 2025-08-31 23:58:18 +04:00
Timothy Jaeryang Baek e0ab5adb97 refac 2025-08-31 23:52:50 +04:00
Timothy Jaeryang Baek b0f6f24ca8 refac 2025-08-31 23:42:34 +04:00
Timothy Jaeryang Baek 61f530ff4b refac: styling 2025-08-31 23:06:58 +04:00
Tim Jaeryang Baek 7df1a059d6 Merge pull request #17012 from thomascooper/fork/feature/20250828-auth-oidc-support-pkce
fix: PKCE requires no secret, with no secret the login button does not eve…
2025-08-31 14:29:05 +04:00
Tim Jaeryang Baek 3e18187805 Merge pull request #17029 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-08-31 13:59:40 +04:00
Tim Jaeryang Baek aa13ed885d Merge pull request #17019 from ShirasawaSama/patch-8
i18n: improve Chinese translations
2025-08-31 13:59:29 +04:00
Tim Jaeryang Baek 2a27db2d3b Merge pull request #17064 from hadadarjt/dev
fix: Resolve admin account creation on Hugging Face Spaces.
2025-08-31 13:34:20 +04:00
Tim Jaeryang Baek 89f005ece6 Merge pull request #17017 from joaoback/patch-3
Update translation.json (pt-BR)
2025-08-31 13:33:28 +04:00
Hadad 48afd424a3 fix: Resolve admin account creation on Hugging Face Spaces.
Signed-off-by: Hadad <hadad@linuxmail.org>
2025-08-31 14:33:48 +07:00
Adam 39ae9167ee removed test code. 2025-08-30 20:31:13 -04:00
Adam 20b6902b9f whitespace 2025-08-30 20:30:34 -04:00
Adam 08b958cfc9 re-add used var 2025-08-30 20:29:16 -04:00
Adam c62f30e22c remove whitespace 2025-08-30 20:12:46 -04:00
Adam 562710fe33 join the url instead of concatenating a string in case the user adds a slash to the end of their configured url. 2025-08-30 20:05:53 -04:00
_00_ 647e38f701 Revert bypass hybrid search when BM25_weight=0
Revert PR https://github.com/open-webui/open-webui/commit/74b1c801
2025-08-30 10:45:35 +02:00
_00_ 292cb62d4a FIX: Hybrid Search lexical-semantic tags
FIX Error in Hybrid Search lexical-semantic terms places

I was reviewing and I noticed that the lexical-semantic terms are inverted.

BM25 weight=1 --> lexical
BM25 weight=0 --> semantic
2025-08-30 01:48:31 +02:00
Aleix Dorca 32a303f392 Update catalan translation.json 2025-08-29 11:59:09 +02:00
Shirasawa 1d1a83b754 i18n: improve zh-CN translation 2025-08-29 03:49:15 +00:00
joaoback 1ca5ad47b1 Update translation.json (pt-BR)
Some minor translations improvements
2025-08-28 19:49:23 -03:00
Timothy Jaeryang Baek 3111d1bf61 refac 2025-08-29 02:07:31 +04:00
Athanasios Oikonomou d735b036fe fix: handle unicode filenames in external document loader
Files with special characters in their names (e.g., ü.pdf) caused issues since HTTP headers only allow Latin-1 characters.
This change URL-encodes `X-Filename` before adding it to request headers, preventing failures when uploading or processing such files.

Fixes: #17000
2025-08-28 22:19:50 +03:00
Thomas Cooper 9d80cc3b2d PKCE requires no secret, with no secret the login button does not ever show 2025-08-28 14:47:13 -04:00
Timothy Jaeryang Baek 0ebe4f8f84 refac: conditional USE_PERMISSION_HARDENING 2025-08-28 20:19:47 +04:00
Timothy Jaeryang Baek be373e9fd4 refac: dockerfile 2025-08-28 19:42:28 +04:00
Timothy Jaeryang Baek 898826dc22 Update dependabot.yml 2025-08-28 19:16:50 +04:00
Timothy Jaeryang Baek 0bca4e230e refac: rename tools to external tools for clarity 2025-08-28 15:08:13 +04:00
Timothy Jaeryang Baek ef5374a34e typo 2025-08-28 14:46:47 +04:00
Tim Jaeryang Baek 2407d9b905 Merge pull request #16859 from open-webui/dev
0.6.26
2025-08-28 14:40:19 +04:00
Timothy Jaeryang Baek 25d9a728f4 refac 2025-08-28 14:24:49 +04:00
Timothy Jaeryang Baek 103688936e doc: changelog 2025-08-28 14:19:12 +04:00
Tim Jaeryang Baek ac73f97604 Merge pull request #16993 from Classic298/patch-1
Changelog for 0.6.26
2025-08-28 14:05:53 +04:00
Classic298 0d09571ca2 Changelog for 0.6.26 2025-08-28 12:00:40 +02:00
Tim Jaeryang Baek 5b4409814b Merge pull request #16982 from a1cd/main
ci: slim image
2025-08-28 13:29:15 +04:00
Timothy Jaeryang Baek 8cca648efa refac 2025-08-28 13:25:48 +04:00
Timothy Jaeryang Baek a8222e391b chore: dep bump 2025-08-28 13:05:06 +04:00
Timothy Jaeryang Baek dca7ce9420 refac 2025-08-28 03:41:25 +04:00
Timothy Jaeryang Baek bcd5783752 chore: format 2025-08-28 03:38:06 +04:00
Timothy Jaeryang Baek 12bd04d24d refac/enh 2025-08-28 03:24:26 +04:00
Timothy Jaeryang Baek 2bb6063dcb refac/fix: marker 2025-08-28 03:03:31 +04:00
Timothy Jaeryang Baek 4d7c8b4a6a refac 2025-08-28 03:01:53 +04:00
Timothy Jaeryang Baek 40617b9e0e refac: file item modal 2025-08-28 02:59:45 +04:00
Timothy Jaeryang Baek 52030a241c refac 2025-08-28 02:50:19 +04:00
Timothy Jaeryang Baek 3d6605bbfd refac: hide steps in images 2025-08-28 02:48:08 +04:00
Everett Wilber a60b0a108a Ensure data directory exists before chown 2025-08-27 18:46:31 -04:00
Timothy Jaeryang Baek 48635ced35 refac 2025-08-28 02:45:06 +04:00
Everett Wilber f4dde86b36 Fix syntax error in Dockerfile pip install command 2025-08-27 18:40:17 -04:00
Everett Wilber fcc1e2729c Fix Dockerfile syntax for conditional installation 2025-08-27 18:37:49 -04:00
Timothy Jaeryang Baek e7c7c65227 refac/fix: error message 2025-08-28 02:35:29 +04:00
Everett Wilber b2d1aa3c6e Fix syntax error in conditional for Ollama installation 2025-08-27 18:35:00 -04:00
Everett Wilber 24767bb0fa Merge pull request #2 from a1cd/slimify
Slimify
2025-08-27 18:31:39 -04:00
Everett Wilber c000188728 Merge pull request #1 from a1cd/slimify-patch-1
Slimify patch 1
2025-08-27 18:29:28 -04:00
Everett Wilber db4adc0e89 Add build-slim-image job to Docker workflow 2025-08-27 18:27:16 -04:00
Timothy Jaeryang Baek cf08d34879 refac 2025-08-28 02:24:21 +04:00
Everett Wilber d2fdf6999b Add USE_SLIM argument to Dockerfile 2025-08-27 18:20:23 -04:00
Timothy Jaeryang Baek d9c8a2508f chore: format 2025-08-28 01:42:45 +04:00
Tim Jaeryang Baek df2428b356 Merge pull request #16965 from joaoback/patch-2
Update translation.json (pt-BR)
2025-08-27 17:25:54 +04:00
Timothy Jaeryang Baek e39ce16a86 enh: custom reasoning tags 2025-08-27 17:24:16 +04:00
joaoback d67cf5d9f2 Update translation.json (pt-BR)
I made some adjustments to some translations and also translated new items added.
2025-08-27 09:18:50 -03:00
Tim Jaeryang Baek 688bfac4ba Merge pull request #16946 from ShirasawaSama/patch-7
i18n: improve Chinese translation
2025-08-27 15:46:05 +04:00
Timothy Jaeryang Baek 7c2cb5899c refac 2025-08-27 15:25:24 +04:00
Shirasawa b16ad094be i18n: improve zh-CN translation 2025-08-27 17:06:45 +08:00
Tim Jaeryang Baek d5715cec6f Merge pull request #16944 from rgaricano/dev-FIX_RTL_in_messages-LTR_codeblock
FIX: Auto RTL-LTR text orientation in Messages & Reversion previous PR
2025-08-27 12:55:18 +04:00
Shirasawa c7caa268ce i18n: improve zh-TW translation 2025-08-27 14:08:32 +08:00
Shirasawa de8e8af989 i18n: improve zh-CN translation 2025-08-27 12:04:20 +08:00
_00_ ddc88d615c FIX: Auto RTL-LTR text orientation
FIX: Auto RTL-LTR text orientation

 This tag was a `<div>` and changed by `<li>` in commit https://github.com/open-webui/open-webui/commit/bb6864dd127e8bc0b0467dfb9eb87f460acecfa8#r164667886
& this change broke the previous behavior of auto text orientation.
2025-08-27 03:58:18 +02:00
_00_ 7644d66902 Revert last change in ResponseMessage.svelte 2025-08-27 03:53:51 +02:00
_00_ 177788540b Revert last changes in UserMessage.svelte 2025-08-27 03:51:16 +02:00
_00_ 65a9a7eae0 Revert changes for fix orientation in CodeBlock.svelte
Revert changes for fix orientation in CodeBlock.svelte
2025-08-27 03:46:07 +02:00
_00_ 804340ee26 Merge branch 'open-webui:main' into dev-FIX_RTL_in_messages-LTR_codeblock 2025-08-27 03:40:01 +02:00
Timothy Jaeryang Baek 937f97d7d6 chore: format 2025-08-27 04:28:24 +04:00
Timothy Jaeryang Baek f6637cc78e refac 2025-08-27 04:18:18 +04:00
Timothy Jaeryang Baek 29cd1bc739 enh: pull to refresh 2025-08-27 04:16:02 +04:00
Timothy Jaeryang BaekandJacob Leksan 31485835a7 enh: query caching
Co-Authored-By: Jacob Leksan <63938553+jmleksan@users.noreply.github.com>
2025-08-27 03:07:21 +04:00
Timothy Jaeryang Baek e4b6855984 enh: CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES 2025-08-27 02:58:25 +04:00
Timothy Jaeryang Baek bbd3e38e25 enh: a11y 2025-08-27 00:52:18 +04:00
Timothy Jaeryang Baek b29d5b80cd refac: pypi optional-dependencies 2025-08-26 23:05:44 +04:00
Timothy Jaeryang Baek edf7a3d142 refac 2025-08-26 22:05:27 +04:00
Timothy Jaeryang Baek 8b425a6295 refac 2025-08-26 21:56:52 +04:00
Timothy Jaeryang Baek d42e3fce4a refac 2025-08-26 21:26:18 +04:00
Timothy Jaeryang Baek 58cc57e8a4 refac 2025-08-26 17:39:52 +04:00
Timothy Jaeryang Baek d3a952877a refac: pdf export 2025-08-26 17:34:33 +04:00
Timothy Jaeryang Baekand_00_ 07357afcf6 refac
Co-Authored-By: _00_ <131402327+rgaricano@users.noreply.github.com>
2025-08-26 16:54:36 +04:00
Timothy Jaeryang Baek ceb4948a28 refac 2025-08-26 16:14:43 +04:00
Timothy Jaeryang Baek 86a8eb1023 refac/enh: pdf export 2025-08-26 16:05:02 +04:00
Timothy Jaeryang Baek dced9e4094 refac 2025-08-26 15:06:50 +04:00
Timothy Jaeryang BaekandG30 803b2e35be enh: delete_message, continue_response, regenerate_response, rate_response user permissions
Co-Authored-By: G30 <50341825+silentoplayz@users.noreply.github.com>
2025-08-26 15:05:35 +04:00
Timothy Jaeryang Baek 23a9731899 refac/fix: hybrid search 2025-08-26 15:04:46 +04:00
Tim Jaeryang Baek 4267e22d4a Merge pull request #16826 from selenecodes/feat/azure-document-intelligence-azure-entra-auth
feat: Authenticate Azure Document Intelligence using DefaultAzureCredential
2025-08-26 14:32:04 +04:00
Tim Jaeryang Baek e3207f35d7 Merge pull request #16885 from ShirasawaSama/patch-5
fix: fix Safari IME composition bug (#16615)
2025-08-26 14:26:12 +04:00
Tim Jaeryang Baek 85264e76de Merge pull request #16918 from ShirasawaSama/patch-6
fix: fix Windows sidebar button cursor icon
2025-08-26 13:52:29 +04:00
Tim Jaeryang Baek 1dc8056d84 Merge pull request #16853 from yuliang615/dev
Fix:Copy button in code blocks copies the original AI output instead of the edited content
2025-08-26 13:51:30 +04:00
Tim Jaeryang Baek 853c14b4c2 Merge pull request #16897 from ibuki2003/fix_translation_ja
i18n: improve ja-JP translation
2025-08-26 13:48:46 +04:00
Tim Jaeryang Baek 01dba25466 Merge pull request #16915 from SantiagoDePolonia/pl-translation-fix
i18n: updated Polish translation
2025-08-26 13:47:09 +04:00
Shirasawa 9c3f54cf1c fix: fix Windows sidebar button cursor style 2025-08-26 09:23:52 +00:00
Timothy Jaeryang Baek f4047eea77 fix: direct tool server 2025-08-26 13:15:47 +04:00
Jakub A. W 0583847943 fix: updated Polish translation 2025-08-26 06:45:01 +02:00
Tim Jaeryang Baek 4b22aa819c Merge pull request #16878 from rgaricano/dev-FIX_RTL_in_messages-LTR_codeblock
FIX- RTL text orientation in Messages, and LTR allways for CodeBlock
2025-08-26 02:25:01 +04:00
Timothy Jaeryang Baek 630cea105e fix: empty content in file modal 2025-08-25 18:22:28 +04:00
Timothy Jaeryang Baek c61698efcf enh: process_in_background query param for file upload endpoint 2025-08-25 18:18:52 +04:00
Timothy Jaeryang Baek a37d411dcf refac 2025-08-25 18:12:47 +04:00
ibuki2003 eacec793e9 i18n: improve ja-JP translation 2025-08-25 22:18:10 +09:00
Timothy Jaeryang Baek 0e46f8091f fix: __tools__ param issue 2025-08-25 14:29:05 +04:00
Shirasawa 7b9dc69362 fix: fix Safari IME composition bug 2025-08-25 14:37:51 +08:00
_00_ 0a16af4e96 rerun checks 2025-08-25 00:20:20 +02:00
_00_ 24e696e341 Format Update ResponseMessage.svelte 2025-08-25 00:01:01 +02:00
Timothy Jaeryang Baek 0ea421ea20 refac 2025-08-25 01:12:14 +04:00
Tim Jaeryang Baek 3da6049a4a Merge pull request #16862 from rgaricano/dev-es_ES
UPD: i18n es-ES Translation v.0.6.25
2025-08-25 01:11:16 +04:00
_00_ 3839f18bb1 FIX- LTR in Update CodeBlock.svelte
FIX- LTR in Update CodeBlock.svelte
For CodeBlock allways LTR
2025-08-24 23:07:32 +02:00
_00_ 2b0e90c032 FIX RTL in Update ResponseMessage.svelte
FIX RTL in Update ResponseMessage.svelte
2025-08-24 23:04:57 +02:00
_00_ 3a360e441e FIX-RTL in UserMessage.svelte
FIX-RTL in UserMessage.svelte
2025-08-24 23:03:24 +02:00
_00_ a463e58de6 Update translation.json
correctión lost "
2025-08-23 20:59:49 +02:00
_00_ 7a59cc9186 Update translation.json
Gramatical correction
2025-08-23 20:58:12 +02:00
_00_ ff6946c230 UPD: i18n es-ES Translation v.0.6.25
UPD: i18n es-ES Translation v.0.6.25

Added new strings in es-ES Translation
2025-08-23 20:53:32 +02:00
Tim Jaeryang Baek 3ff773c199 Merge pull request #16838 from silentoplayz/shift-to-delete-prompts
feat: Add Shift-to-delete functionality for prompts on Workspace `Prompts` page
2025-08-23 21:57:09 +04:00
Tim Jaeryang Baek 8048d4ac39 Merge pull request #16850 from rgaricano/dev-FIX_playwright_timeout
FIX: Playwright Timeout (ms) interpreted as seconds
2025-08-23 21:55:48 +04:00
Tim Jaeryang Baek 8d7319441e Merge pull request #16848 from Kylapaallikko/dev
i18n: Update fi-FI translation
2025-08-23 21:51:16 +04:00
洪宇亮 d98a60fbbb Add files via upload 2025-08-23 21:42:35 +08:00
_00_ 093af754e7 FIX: Playwright Timeout (ms) interpreted as seconds
Fix for Playwright Timeout (ms) interpreted as seconds.

To address https://github.com/open-webui/open-webui/issues/16801

In Frontend Playwright Timeout is setted as (ms), but in backend is interpreted as (s) doing a time conversion for playwright_timeout var (that have to be in ms).

& as  _Originally posted by @rawbby in [#16801](https://github.com/open-webui/open-webui/issues/16801#issuecomment-3216782565)_

> I personally think milliseconds are a reasonable choice for the timeout. Maybe the conversion should be fixed, not the label.
> This would further not break existing configurations from users that rely on their current config.
>
2025-08-23 14:15:00 +02:00
Kylapaallikko fa1590df57 Update fi-FI translation.json
Added missing translations and fixed typos.
2025-08-23 09:16:49 +03:00
silentoplayz 9e7ec0eb1e feat: add shift-to-delete to prompts workspace
This commit adds the Shift key shortcut to the Prompts workspace page to reveal a trash can icon for quick deletion of prompts. This feature is already present for the Models, Tools, and Functions pages.
2025-08-22 16:09:21 -04:00
Selene Blok 5051bfe7ab feat(document retrieval): Authenticate Azure Document Intelligence using AzureDefaultCredential if API key is not provided 2025-08-22 16:15:43 +02:00
Tim Jaeryang Baek 1db8dec4f5 Merge pull request #16821 from open-webui/dev
0.6.25
2025-08-22 17:22:31 +04:00
Timothy Jaeryang Baek dafedf5675 chore: bump 2025-08-22 17:20:05 +04:00
Timothy Jaeryang Baek 37a3de0703 fix 2025-08-22 17:19:57 +04:00
Timothy Jaeryang Baek 72b25ab78b fix: image generation 2025-08-22 16:58:25 +04:00
Timothy Jaeryang Baek fbff4e19de fix: reranking 2025-08-22 16:47:05 +04:00
Tim Jaeryang Baek 2777bab148 Merge pull request #16810 from open-webui/dev
0.6.24
2025-08-22 14:06:05 +04:00
Timothy Jaeryang Baek 4ec70e3d46 refac 2025-08-22 14:05:33 +04:00
Timothy Jaeryang Baek 43b2eca418 refac 2025-08-22 14:01:57 +04:00
Timothy Jaeryang Baek 99f7d1b62b doc: changelog 2025-08-22 13:57:37 +04:00
Timothy Jaeryang Baek 62a5db7719 chore: bump 2025-08-22 13:46:10 +04:00
Timothy Jaeryang Baek 278a4edd0b fix: tool server not loading 2025-08-22 13:44:03 +04:00
Timothy Jaeryang Baek 279e3e970f fix: redis session issue 2025-08-22 13:30:44 +04:00
Timothy Jaeryang Baek 88a5b5ff4c fix: image gen 2025-08-22 13:25:23 +04:00
Tim Jaeryang Baek c159a1a120 Merge pull request #16783 from itk-dev/feature/high-contrast-chat-messages
FEAT: highContrastMode implemented in chat messages
2025-08-22 13:22:09 +04:00
Tim Jaeryang Baek 1decefbe59 Merge pull request #16808 from aleixdorca/dev
i18n: Update Catalan translation.json
2025-08-22 13:21:32 +04:00
Tim Jaeryang Baek 28157b4f8c Merge pull request #16802 from joaoback/patch-1
Update translation.json (pt-BR)
2025-08-22 13:21:22 +04:00
Aleix Dorca e013df19f9 Update catalan translation.json 2025-08-22 10:23:17 +02:00
joaoback 486f953ba2 Update translation.json (pt-BR)
new translations for the new items that were included in the latest versions.
2025-08-21 22:49:53 -03:00
Sine Jespersen 0952772705 highContrastMode implemented in chat messages 2025-08-21 12:52:32 +02:00
190 changed files with 8101 additions and 3463 deletions
+8 -5
View File
@@ -11,7 +11,7 @@ body:
## Important Notes
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project.
- **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.**
- **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
@@ -25,7 +25,9 @@ body:
label: Check Existing Issues
description: Confirm that you’ve checked for existing reports before submitting a new one.
options:
- label: I have searched the existing issues and discussions.
- label: I have searched for any existing and/or related issues.
required: true
- label: I have searched for any existing and/or related discussions.
required: true
- label: I am using the latest version of Open WebUI.
required: true
@@ -47,7 +49,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.3.11)
description: Specify the version (e.g., v0.6.26)
validations:
required: true
@@ -63,7 +65,7 @@ body:
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
validations:
required: true
@@ -126,6 +128,7 @@ body:
description: |
Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.
**If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
**If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
placeholder: |
Example (include every detail):
@@ -163,5 +166,5 @@ body:
attributes:
value: |
## Note
If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
**If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.
Thank you for contributing to Open WebUI!
-6
View File
@@ -12,12 +12,6 @@ updates:
interval: monthly
target-branch: 'dev'
- package-ecosystem: npm
directory: '/'
schedule:
interval: monthly
target-branch: 'dev'
- package-ecosystem: 'github-actions'
directory: '/'
schedule:
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check for changes in package.json
run: |
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
lfs: true
+166 -8
View File
@@ -43,7 +43,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -142,7 +142,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -244,7 +244,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -347,7 +347,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -419,6 +419,108 @@ jobs:
if-no-files-found: error
retention-days: 1
build-slim-image:
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-slim-${{ matrix.platform }}-
latest=false
- name: Build Docker image (slim)
uses: docker/build-push-action@v5
id: build
with:
context: .
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
build-args: |
BUILD_HASH=${{ github.sha }}
USE_SLIM=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-slim-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-main-images:
runs-on: ubuntu-latest
needs: [build-main-image]
@@ -433,7 +535,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-main-*
path: /tmp/digests
@@ -487,7 +589,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-cuda-*
path: /tmp/digests
@@ -543,7 +645,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-cuda126-*
path: /tmp/digests
@@ -599,7 +701,7 @@ jobs:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: digests-ollama-*
path: /tmp/digests
@@ -640,3 +742,59 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
merge-slim-images:
runs-on: ubuntu-latest
needs: [build-slim-image]
steps:
# GitHub Packages requires the entire repository name to be in lowercase
# although the repository owner has a lowercase username, this prevents some people from running actions after forking
- name: Set repository and image name to lowercase
run: |
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
env:
IMAGE_NAME: '${{ github.repository }}'
- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-slim-*
path: /tmp/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker images (default slim tag)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,enable=${{ github.ref == 'refs/heads/main' }},prefix=,suffix=,value=slim
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}
suffix=-slim,onlatest=true
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
- 3.12.x
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Git
+116
View File
@@ -5,6 +5,122 @@ 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.27] - 2025-09-09
### Added
- 📁 Emoji folder icons were added, allowing users to personalize workspace organization with visual cues, including improved chevron display. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1588f42fe777ad5d807e3f2fc8dbbc47a8db87c0), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b70c0f36c0f5bbfc2a767429984d6fba1a7bb26c), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/11dea8795bfce42aa5d8d58ef316ded05173bd87), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c0a47169fa059154d5f5a9ea6b94f9a66d82f255)
- 📁 The 'Search Collection' input field now dynamically displays the total number of files within the knowledge base. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/fbbe1117ae4c9c8fec6499d790eee275818eccc5)
- ☁️ A provider toggle in connection settings now allows users to manually specify Azure OpenAI deployments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/5bdd334b74fbd154085f2d590f4afdba32469c8a)
- ⚡ Model list caching performance was optimized by fixing cache key generation to reduce redundant API calls. [#17158](https://github.com/open-webui/open-webui/pull/17158)
- 🎨 Azure OpenAI image generation is now supported, with configurations for IMAGES_OPENAI_API_VERSION via environment variable and admin UI. [#17147](https://github.com/open-webui/open-webui/pull/17147), [#16274](https://github.com/open-webui/open-webui/discussions/16274), [Docs:#679](https://github.com/open-webui/docs/pull/679)
- ⚡ Comprehensive N+1 query performance is optimized by reducing database queries from 1+N to 1+1 patterns across major listing endpoints. [#17165](https://github.com/open-webui/open-webui/pull/17165), [#17160](https://github.com/open-webui/open-webui/pull/17160), [#17161](https://github.com/open-webui/open-webui/pull/17161), [#17162](https://github.com/open-webui/open-webui/pull/17162), [#17159](https://github.com/open-webui/open-webui/pull/17159), [#17166](https://github.com/open-webui/open-webui/pull/17166)
- ⚡ The PDF.js library is now dynamically loaded, significantly reducing initial page load size and improving responsiveness. [#17222](https://github.com/open-webui/open-webui/pull/17222)
- ⚡ The heic2any library is now dynamically loaded across various message input components, including channels, for faster page loads. [#17225](https://github.com/open-webui/open-webui/pull/17225), [#17229](https://github.com/open-webui/open-webui/pull/17229)
- 📚 The knowledge API now supports a "delete_file" query parameter, allowing configurable file deletion behavior. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/22c4ef4fb096498066b73befe993ae3a82f7a8e7)
- 📊 Llama.cpp timing statistics are now integrated into the usage field for comprehensive model performance metrics. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/e830b4959ecd4b2795e29e53026984a58a7696a9)
- 🗄️ The PGVECTOR_CREATE_EXTENSION environment variable now allows control over automatic pgvector extension creation. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/c2b4976c82d335ed524bd80dc914b5e2f5bfbd9e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/b45219c8b15b48d5ee3d42983e1107bbcefbab01), [Docs:#672](https://github.com/open-webui/docs/pull/672)
- 🔒 Comprehensive server-side OAuth token management was implemented, securely storing encrypted tokens in a new database table and introducing an automatic refresh mechanism, enabling seamless and secure forwarding of valid user-specific OAuth tokens to downstream services, including OpenAI-compatible endpoints and external tool servers via the new "system_oauth" authentication type, resolving long-standing issues such as large token size limitations, stale/expired tokens, and reliable token propagation, and enhancing overall security by minimizing client-side token exposure, configurable via "ENABLE_OAUTH_ID_TOKEN_COOKIE" and "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY" environment variables. [Docs:#683](https://github.com/open-webui/docs/pull/683), [#17210](https://github.com/open-webui/open-webui/pull/17210), [#8957](https://github.com/open-webui/open-webui/discussions/8957), [#11029](https://github.com/open-webui/open-webui/discussions/11029), [#17178](https://github.com/open-webui/open-webui/issues/17178), [#17183](https://github.com/open-webui/open-webui/issues/17183), [Commit](https://github.com/open-webui/open-webui/commit/217f4daef09b36d3d4cc4681e11d3ebd9984a1a5), [Commit](https://github.com/open-webui/open-webui/commit/fc11e4384fe98fac659e10596f67c23483578867), [Commit](https://github.com/open-webui/open-webui/commit/f11bdc6ab5dd5682bb3e27166e77581f5b8af3e0), [Commit](https://github.com/open-webui/open-webui/commit/f71834720e623761d972d4d740e9bbd90a3a86c6), [Commit](https://github.com/open-webui/open-webui/commit/b5bb6ae177dcdc4e8274d7e5ffa50bc8099fd466), [Commit](https://github.com/open-webui/open-webui/commit/b786d1e3f3308ef4f0f95d7130ddbcaaca4fc927), [Commit](https://github.com/open-webui/open-webui/commit/8a9f8627017bd0a74cbd647891552b26e56aabb7), [Commit](https://github.com/open-webui/open-webui/commit/30d1dc2c60e303756120fe1c5538968c4e6139f4), [Commit](https://github.com/open-webui/open-webui/commit/2b2d123531eb3f42c0e940593832a64e2806240d), [Commit](https://github.com/open-webui/open-webui/commit/6f6412dd16c63c2bb4df79a96b814bf69cb3f880)
- 🔒 Conditional Permission Hardening for OpenShift Deployments: Added a build argument to enable optional permission hardening for OpenShift and container environments. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
- 👥 Regex pattern support is added for OAuth blocked groups, allowing more flexible group filtering rules. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/df66e21472646648d008ebb22b0e8d5424d491df)
- 💬 Web search result display was enhanced to include titles and favicons, providing a clearer overview of search sources. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/33f04a771455e3fabf8f0e8ebb994ae7f41b8ed4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0a85dd4bca23022729eafdbc82c8c139fa365af2), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/16090bc2721fde492afa2c4af5927e2b668527e1), [#17197](https://github.com/open-webui/open-webui/pull/17197), [#14179](https://github.com/open-webui/open-webui/issues/14179), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1cdb7aed1ee9bf81f2fd0404be52dcfa64f8ed4f), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f2525ebc447c008cf7269ef20ce04fa456f302c4), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/7f523de408ede4075349d8de71ae0214b7e1a62e), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/3d37e4a42d344051ae715ab59bd7b5718e46c343), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/cd5e2be27b613314aadda6107089331783987985), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/6dc0df247347aede2762fe2065cf30275fd137ae)
- 💬 A new setting was added to control whether clicking a suggested prompt automatically sends the message or only inserts the text. [#17192](https://github.com/open-webui/open-webui/issues/17192), [Commit](https://github.com/open-webui/open-webui/commit/e023a98f11fc52feb21e4065ec707cc98e50c7d3)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, Catalan, and Spanish were enhanced and expanded.
### Fixed
- 🔍 Hybrid search functionality now correctly handles lexical-semantic weight labels and avoids errors when BM25 weight is zero. [#17049](https://github.com/open-webui/open-webui/pull/17049), [#17046](https://github.com/open-webui/open-webui/issues/17046)
- 🛑 Task stopping errors are prevented by gracefully handling multiple stop requests for the same task. [#17195](https://github.com/open-webui/open-webui/pull/17195)
- 🐍 Code execution package detection precision is improved in Pyodide to prevent unnecessary package inclusions. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/bbe116795860a81a647d9567e0d9cb1950650095)
- 🛠️ Tool message format API compliance is fixed by ensuring content fields in tool call responses contain valid string values instead of null. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/37bf0087e5b8a324009c9d06b304027df351ea6b)
- 📱 Mobile app config API authentication now supports Authorization header token verification with cookie fallback for iOS and Android requests. [#17175](https://github.com/open-webui/open-webui/pull/17175)
- 💾 Knowledge file save race conditions are prevented by serializing API calls and adding an "isSaving" guard. [#17137](https://github.com/open-webui/open-webui/pull/17137), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4ca936f0bf9813bee11ec8aea41d7e34fb6b16a9)
- 🔐 The SSO login button visibility is restored for OIDC PKCE authentication without a client secret. [#17012](https://github.com/open-webui/open-webui/pull/17012)
- 🔊 Text-to-Speech (TTS) API requests now use proper URL joining methods, ensuring reliable functionality regardless of trailing slashes in the base URL. [#17061](https://github.com/open-webui/open-webui/pull/17061)
- 🛡️ Admin account creation on Hugging Face Spaces now correctly detects the configured port, resolving issues with custom port deployments. [#17064](https://github.com/open-webui/open-webui/pull/17064)
- 📁 Unicode filename support is improved for external document loaders by properly URL-encoding filenames in HTTP headers. [#17013](https://github.com/open-webui/open-webui/pull/17013), [#17000](https://github.com/open-webui/open-webui/issues/17000)
- 🔗 Web page and YouTube attachments are now correctly processed by setting their type as "text" and using collection names for accurate content retrieval. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/487979859a6ffcfd60468f523822cdf838fbef5b)
- ✍️ Message input composition event handling is fixed to properly manage text input for multilingual users using Input Method Editors (IME). [#17085](https://github.com/open-webui/open-webui/pull/17085)
- 💬 Follow-up tooltip duplication is removed, streamlining the user interface and preventing visual clutter. [#17186](https://github.com/open-webui/open-webui/pull/17186)
- 🎨 Chat button text display is corrected by preventing clipping of descending characters and removing unnecessary capitalization. [#17191](https://github.com/open-webui/open-webui/pull/17191)
- 🧠 RAG Loop/Error with Gemma 3.1 2B Instruct is fixed by correctly unwrapping unexpected single-item list responses from models. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/1bc9711afd2b72cd07c4e539a83783868733767c), [#17213](https://github.com/open-webui/open-webui/issues/17213)
- 🖼️ HEIC conversion failures are resolved, improving robustness of image handling. [#17225](https://github.com/open-webui/open-webui/pull/17225)
- 📦 The slim Docker image size regression has been fixed by refining the build process to correctly exclude components when USE_SLIM=true. [#16997](https://github.com/open-webui/open-webui/issues/16997), [Commit](https://github.com/open-webui/open-webui/commit/be373e9fd42ac73b0302bdb487e16dbeae178b4e), [Commit](https://github.com/open-webui/open-webui/commit/0ebe4f8f8490451ac8e85a4846f010854d9b54e5)
- 📁 Knowledge base update validation errors are resolved, ensuring seamless management via UI or API. [#17244](https://github.com/open-webui/open-webui/issues/17244), [Commit](https://github.com/open-webui/open-webui/commit/9aac1489080a5c9441e89b1a56de0d3a672bc5fb)
- 🔐 Resolved a security issue where a global web search setting overrode model-specific restrictions, ensuring model-level settings are now correctly prioritized. [#17151](https://github.com/open-webui/open-webui/issues/17151), [Commit](https://github.com/open-webui/open-webui/commit/9368d0ac751ec3072d5a96712b80a9b20a642ce6)
- 🔐 OAuth redirect reliability is improved by robustly preserving the intended redirect path using session storage. [#17235](https://github.com/open-webui/open-webui/issues/17235), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/4f2b821088367da18374027919594365c7a3f459), [#15575](https://github.com/open-webui/open-webui/pull/15575), [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/d9f97c832c556fae4b116759da0177bf4fe619de)
- 🔐 Fixed a security vulnerability where knowledge base access within chat folders persisted after permissions were revoked. [#17182](https://github.com/open-webui/open-webui/issues/17182), [Commit](https://github.com/open-webui/open-webui/commit/40e40d1dddf9ca937e99af41c8ca038dbc93a7e6)
- 🔒 OIDC access denied errors are now displayed as user-friendly toast notifications instead of raw JSON. [#17208](https://github.com/open-webui/open-webui/issues/17208), [Commit](https://github.com/open-webui/open-webui/commit/3d6d050ad82d360adc42d6e9f42e8faf8d13c9f4)
- 💬 Chat exception handling is enhanced to prevent system instability during message generation and ensure graceful error recovery. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/f56889c5c7f0cf1a501c05d35dfa614e4f8b6958)
- 🔒 Static asset authentication is improved by adding crossorigin="use-credentials" attributes to all link elements, enabling proper cookie forwarding for proxy environments and authenticated requests to favicon, manifest, and stylesheet resources. [#17280](https://github.com/open-webui/open-webui/pull/17280), [Commit](https://github.com/open-webui/open-webui/commit/f17d8b5d19e1a05df7d63f53e939c99772a59c1e)
### Changed
- 🛠️ Renamed "Tools" to "External Tools" across the UI for clearer distinction between built-in and external functionalities. [Commit](https://github.com/open-webui/open-webui/pull/17070/commits/0bca4e230ef276bec468889e3be036242ad11086f)
- 🛡️ Default permission validation for message regeneration and deletion actions is enhanced to provide more restrictive access controls, improving chat security and user data protection. [#17285](https://github.com/open-webui/open-webui/pull/17285)
## [0.6.26] - 2025-08-28
### Added
- 🛂 **Granular Chat Interaction Permissions**: Added fine-grained permission controls for individual chat actions including "Continue Response", "Regenerate Response", "Rate Response", and "Delete Messages". Administrators can now configure these permissions per user group or set system defaults via environment variables, providing enhanced security and governance by preventing potential system prompt leakage through response continuation and enabling precise control over user interactions with AI responses.
- 🧠 **Custom Reasoning Tags Configuration**: Added configurable reasoning tag detection for AI model responses, allowing administrators and users to customize how the system identifies and processes reasoning content. Users can now define custom reasoning tag pairs, use default tags like "think" and "reasoning", or disable reasoning detection entirely through the Advanced Parameters interface, providing enhanced control over AI thought process visibility.
- 📱 **Pull-to-Refresh Support**: Added pull-to-refresh functionality allowing user to easily refresh the interface by pulling down on the navbar area. This resolves timeout issues that occurred when temporarily switching away from the app during long AI response generations, eliminating the need to close and relaunch the PWA.
- 📁 **Configurable File Upload Processing Mode**: Added "process_in_background" query parameter to the file upload API endpoint, allowing clients to choose between asynchronous (default) and synchronous file processing. Setting "process_in_background=false" forces the upload request to wait until extraction and embedding complete, returning immediately usable files and simplifying integration for backend API consumers that prefer blocking calls over polling workflows.
- 🔐 **Azure Document Intelligence DefaultAzureCredential Support**: Added support for authenticating with Azure Document Intelligence using DefaultAzureCredential in addition to API key authentication, enabling seamless integration with Azure Entra ID and managed identity authentication for enterprise Azure environments.
- 🔐 **Authentication Bootstrapping Enhancements**: Added "ENABLE_INITIAL_ADMIN_SIGNUP" environment variable and "?form=true" URL parameter to enable initial admin user creation and forced login form display in SSO-only deployments. This resolves bootstrap issues where administrators couldn't create the first user when login forms were disabled, allowing proper initialization of SSO-configured deployments without requiring temporary configuration changes.
- ⚡ **Query Generation Caching**: Added "ENABLE_QUERIES_CACHE" environment variable to enable request-scoped caching of generated search queries. When both web search and file retrieval are active, queries generated for web search are automatically reused for file retrieval, eliminating duplicate LLM API calls and reducing token usage and costs while maintaining search quality.
- 🔧 **Configurable Tool Call Retry Limit**: Added "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES" environment variable to control the maximum number of sequential tool calls allowed before safety stopping a chat session. This replaces the previous hardcoded limit of 10, enabling administrators to configure higher limits for complex workflows requiring extensive tool interactions.
- 📦 **Slim Docker Image Variant**: Added new slim Docker image option via "USE_SLIM" build argument that excludes embedded AI models and Ollama installation, reducing image size by approximately 1GB. This variant enables faster image pulls and deployments for environments where AI models are managed externally, particularly beneficial for auto-scaling clusters and distributed deployments.
- 🗂️ **Shift-to-Delete Functionality for Workspace Prompts**: Added keyboard shortcut support for quick prompt deletion on the Workspace Prompts page. Hold Shift and hover over any prompt to reveal a trash icon for instant deletion, bringing consistent interaction patterns across all workspace sections (Models, Tools, Functions, and now Prompts) and streamlining prompt management workflows.
- ♿ **Accessibility Enhancements**: Enhanced user interface accessibility with improved keyboard navigation, ARIA labels, and screen reader compatibility across key platform components.
- 📄 **Optimized PDF Export for Smaller File Size**: PDF exports are now significantly optimized, producing much smaller files for faster downloads and easier sharing or archiving of your chats and documents.
- 📦 **Slimmed Default Install with Optional Full Dependencies**: Installing Open WebUI via pip now defaults to a slimmer package; PostgreSQL support is no longer included by default—simply use 'pip install open-webui[all]' to include all optional dependencies for full feature compatibility.
- 🔄 **General Backend Refactoring**: Implemented various backend improvements to enhance performance, stability, and security, ensuring a more resilient and reliable platform for all users.
- 🌐 **Localization & Internationalization Improvements**: Enhanced and expanded translations for Finnish, Spanish, Japanese, Polish, Portuguese (Brazil), and Chinese, including missing translations and typo corrections, providing a more natural and professional user experience for speakers of these languages across the entire interface.
### Fixed
- ⚠️ **Chat Error Feedback Restored**: Fixed an issue where various backend errors (tool server failures, API connection issues, malformed responses) would cause chats to load indefinitely without providing user feedback. The system now properly displays error messages when failures occur during chat generation, allowing users to understand issues and retry as needed instead of waiting indefinitely.
- 🖼️ **Image Generation Steps Setting Visibility Fixed**: Fixed a UI issue where the "Set Steps" configuration option was incorrectly displayed for OpenAI and Gemini image generation engines that don't support this parameter. The setting is now only visible for compatible engines like ComfyUI and Automatic1111, eliminating user confusion about non-functional configuration options.
- 📄 **Datalab Marker API Document Loader Fixed**: Fixed broken Datalab Marker API document loader functionality by correcting URL path handling for both hosted Datalab services and self-hosted Marker servers. Removed hardcoded "/marker" paths from the loader code and restored proper default URL structure, ensuring PDF and document processing works correctly with both deployment types.
- 📋 **Citation Error Handling Improved**: Fixed an issue where malformed citation or source objects from external tools, pipes, or filters would cause JavaScript errors and make the chat interface completely unresponsive. The system now gracefully handles missing or undefined citation properties, allowing conversations to load properly even when tools generate defective citation events.
- 👥 **Group User Add API Endpoint Fixed**: Fixed an issue where the "/api/v1/groups/id/{group_id}/users/add" API endpoint would accept requests without errors but fail to actually add users to groups. The system now properly initializes and deduplicates user ID lists, ensuring users are correctly added to and removed from groups via API calls.
- 🛠️ **External Tool Server Error Handling Improved**: Fixed an issue where unreachable or misconfigured external tool servers would cause JavaScript errors and prevent the interface from loading properly. The system now gracefully handles connection failures, displays appropriate error messages, and filters out inaccessible servers while maintaining full functionality for working connections.
- 📋 **Code Block Copy Button Content Fixed**: Fixed an issue where the copy button in code blocks would copy the original AI-generated code instead of any user-edited content, ensuring the copy function always captures the currently displayed code as modified by users.
- 📄 **PDF Export Content Mismatch Fixed**: Resolved an issue where exporting a PDF of one chat while viewing another chat would incorrectly generate the PDF using the currently viewed chat's content instead of the intended chat's content. Additionally optimized the PDF generation algorithm with improved canvas slicing, better memory management, and enhanced image quality, while removing the problematic PDF export option from individual chat menus to prevent further confusion.
- 🖱️ **Windows Sidebar Cursor Icon Corrected**: Fixed confusing cursor icons on Windows systems where sidebar toggle buttons displayed resize cursors (ew-resize) instead of appropriate pointer cursors. The sidebar buttons now show standard pointer cursors on Windows, eliminating user confusion about whether the buttons expand/collapse the sidebar or resize it.
- 📝 **Safari IME Composition Bug Fixed**: Resolved an issue where pressing Enter while composing Chinese text using Input Method Editors (IMEs) on Safari would prematurely send messages instead of completing text composition. The system now properly detects composition states and ignores keydown events that occur immediately after composition ends, ensuring smooth multilingual text input across all browsers.
- 🔍 **Hybrid Search Parameter Handling Fixed**: Fixed an issue where the "hybrid" parameter in collection query requests was not being properly evaluated, causing the system to ignore user-specified hybrid search preferences and only check global configuration. Additionally resolved a division by zero error that occurred in hybrid search when BM25Retriever was called with empty document lists, ensuring robust search functionality across all collection states.
- 💬 **RTL Text Orientation in Messages Fixed**: Fixed text alignment issues in user messages and AI responses for Right-to-Left languages, ensuring proper text direction based on user language settings. Code blocks now consistently use Left-to-Right orientation regardless of the user's language preference, maintaining code readability across all supported languages.
- 📁 **File Content Preview in Modal Restored**: Fixed an issue where clicking on uploaded files would display an empty preview modal, even when the files were successfully processed and available for AI context. File content now displays correctly in the preview modal, ensuring users can verify and review their uploaded documents as intended.
- 🌐 **Playwright Timeout Configuration Corrected**: Fixed an issue where Playwright timeout values were incorrectly converted from milliseconds to seconds with an additional 1000x multiplier, causing excessively long web loading timeouts. The timeout parameter now correctly uses the configured millisecond values as intended, ensuring responsive web search and document loading operations.
### Changed
- 🔄 **Follow-Up Question Language Constraint Removed**: Follow-up question suggestions no longer strictly adhere to the chat's primary language setting, allowing for more flexible and diverse suggestion generation that may include questions in different languages based on conversation context and relevance rather than enforced language matching.
## [0.6.25] - 2025-08-22
### Fixed
- 🖼️ **Image Generation Reliability Restored**: Fixed a key issue causing image generation failures.
- 🏆 **Reranking Functionality Restored**: Resolved errors with rerank feature.
## [0.6.24] - 2025-08-21
### Added
- ♿ **High Contrast Mode in Chat Messages**: Implemented enhanced High Contrast Mode support for chat messages, making text and important details easier to read and improving accessibility for users with visual preferences or requirements.
- 🌎 **Localization & Internationalization Improvements**: Enhanced and expanded translations for a more natural and professional user experience for speakers of these languages across the entire interface.
### Fixed
- 🖼️ **ComfyUI Image Generation Restored**: Fixed a critical bug where ComfyUI-based image generation was not functioning, ensuring users can once again effortlessly create and interact with AI-generated visuals in their workflows.
- 🛠️ **Tool Server Loading and Visibility Restored**: Resolved an issue where connected tool servers were not loading or visible, restoring seamless integration and uninterrupted access to all external and custom tools directly within the platform.
- 🛡️ **Redis User Session Reliability**: Fixed a problem affecting the saving of user sessions in Redis, ensuring reliable login sessions, stable authentication, and secure multi-user environments.
## [0.6.23] - 2025-08-21
### Added
+16 -3
View File
@@ -3,6 +3,8 @@
# use build args in the docker build command with --build-arg="BUILDARG=true"
ARG USE_CUDA=false
ARG USE_OLLAMA=false
ARG USE_SLIM=false
ARG USE_PERMISSION_HARDENING=false
# Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
ARG USE_CUDA_VER=cu128
# any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
@@ -24,6 +26,9 @@ ARG GID=0
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
ARG BUILD_HASH
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
# ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /app
# to store git revision in build
@@ -43,6 +48,8 @@ FROM python:3.11-slim-bookworm AS base
ARG USE_CUDA
ARG USE_OLLAMA
ARG USE_CUDA_VER
ARG USE_SLIM
ARG USE_PERMISSION_HARDENING
ARG USE_EMBEDDING_MODEL
ARG USE_RERANKING_MODEL
ARG UID
@@ -54,6 +61,7 @@ ENV ENV=prod \
# pass build args to the build
USE_OLLAMA_DOCKER=${USE_OLLAMA} \
USE_CUDA_DOCKER=${USE_CUDA} \
USE_SLIM_DOCKER=${USE_SLIM} \
USE_CUDA_DOCKER_VER=${USE_CUDA_VER} \
USE_EMBEDDING_MODEL_DOCKER=${USE_EMBEDDING_MODEL} \
USE_RERANKING_MODEL_DOCKER=${USE_RERANKING_MODEL}
@@ -130,11 +138,14 @@ RUN pip3 install --no-cache-dir uv && \
else \
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \
uv pip install --system -r requirements.txt --no-cache-dir && \
if [ "$USE_SLIM" != "true" ]; then \
python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \
python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \
python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \
fi; \
chown -R $UID:$GID /app/backend/data/
fi; \
mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \
rm -rf /var/lib/apt/lists/*;
# Install Ollama if requested
RUN if [ "$USE_OLLAMA" = "true" ]; then \
@@ -163,11 +174,13 @@ HEALTHCHECK CMD curl --silent --fail http://localhost:${PORT:-8080}/health | jq
# Minimal, atomic permission hardening for OpenShift (arbitrary UID):
# - Group 0 owns /app and /root
# - Directories are group-writable and have SGID so new files inherit GID 0
RUN set -eux; \
RUN if [ "$USE_PERMISSION_HARDENING" = "true" ]; then \
set -eux; \
chgrp -R 0 /app /root || true; \
chmod -R g+rwX /app /root || true; \
find /app -type d -exec chmod g+s {} + || true; \
find /root -type d -exec chmod g+s {} + || true
find /root -type d -exec chmod g+s {} + || true; \
fi
USER $UID:$GID
+63 -3
View File
@@ -313,7 +313,7 @@ JWT_EXPIRES_IN = PersistentConfig(
####################################
ENABLE_OAUTH_PERSISTENT_CONFIG = (
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "True").lower() == "true"
os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true"
)
ENABLE_OAUTH_SIGNUP = PersistentConfig(
@@ -660,7 +660,7 @@ def load_oauth_providers():
if (
OAUTH_CLIENT_ID.value
and OAUTH_CLIENT_SECRET.value
and (OAUTH_CLIENT_SECRET.value or OAUTH_CODE_CHALLENGE_METHOD.value)
and OPENID_PROVIDER_URL.value
):
@@ -1208,6 +1208,23 @@ USER_PERMISSIONS_CHAT_DELETE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_DELETE_MESSAGE = (
os.environ.get("USER_PERMISSIONS_CHAT_DELETE_MESSAGE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE = (
os.environ.get("USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE", "True").lower()
== "true"
)
USER_PERMISSIONS_CHAT_RATE_RESPONSE = (
os.environ.get("USER_PERMISSIONS_CHAT_RATE_RESPONSE", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_EDIT = (
os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
)
@@ -1290,6 +1307,10 @@ DEFAULT_USER_PERMISSIONS = {
"params": USER_PERMISSIONS_CHAT_PARAMS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
"regenerate_response": USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE,
"rate_response": USER_PERMISSIONS_CHAT_RATE_RESPONSE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"share": USER_PERMISSIONS_CHAT_SHARE,
"export": USER_PERMISSIONS_CHAT_EXPORT,
@@ -1576,7 +1597,7 @@ FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
)
DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = """### Task:
Suggest 3-5 relevant follow-up questions or prompts in the chat's primary language that the user might naturally ask next in this conversation as a **user**, based on the chat history, to help continue or deepen the discussion.
Suggest 3-5 relevant follow-up questions or prompts that the user might naturally ask next in this conversation as a **user**, based on the chat history, to help continue or deepen the discussion.
### Guidelines:
- Write all follow-up questions from the user’s point of view, directed to the assistant.
- Make questions concise, clear, and directly related to the discussed topic(s).
@@ -1977,6 +1998,9 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
PGVECTOR_CREATE_EXTENSION = (
os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true"
)
PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true"
PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None)
if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
@@ -2208,6 +2232,18 @@ DOCLING_SERVER_URL = PersistentConfig(
os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
)
DOCLING_DO_OCR = PersistentConfig(
"DOCLING_DO_OCR",
"rag.docling_do_ocr",
os.getenv("DOCLING_DO_OCR", "True").lower() == "true",
)
DOCLING_FORCE_OCR = PersistentConfig(
"DOCLING_FORCE_OCR",
"rag.docling_force_ocr",
os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true",
)
DOCLING_OCR_ENGINE = PersistentConfig(
"DOCLING_OCR_ENGINE",
"rag.docling_ocr_engine",
@@ -2220,6 +2256,24 @@ DOCLING_OCR_LANG = PersistentConfig(
os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
)
DOCLING_PDF_BACKEND = PersistentConfig(
"DOCLING_PDF_BACKEND",
"rag.docling_pdf_backend",
os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"),
)
DOCLING_TABLE_MODE = PersistentConfig(
"DOCLING_TABLE_MODE",
"rag.docling_table_mode",
os.getenv("DOCLING_TABLE_MODE", "accurate"),
)
DOCLING_PIPELINE = PersistentConfig(
"DOCLING_PIPELINE",
"rag.docling_pipeline",
os.getenv("DOCLING_PIPELINE", "standard"),
)
DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
"DOCLING_DO_PICTURE_DESCRIPTION",
"rag.docling_do_picture_description",
@@ -3076,6 +3130,12 @@ IMAGES_OPENAI_API_BASE_URL = PersistentConfig(
"image_generation.openai.api_base_url",
os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL),
)
IMAGES_OPENAI_API_VERSION = PersistentConfig(
"IMAGES_OPENAI_API_VERSION",
"image_generation.openai.api_version",
os.getenv("IMAGES_OPENAI_API_VERSION", ""),
)
IMAGES_OPENAI_API_KEY = PersistentConfig(
"IMAGES_OPENAI_API_KEY",
"image_generation.openai.api_key",
+32
View File
@@ -362,6 +362,8 @@ ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true"
####################################
# REDIS
####################################
@@ -402,6 +404,10 @@ except ValueError:
####################################
WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true"
ENABLE_INITIAL_ADMIN_SIGNUP = (
os.environ.get("ENABLE_INITIAL_ADMIN_SIGNUP", "False").lower() == "true"
)
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
)
@@ -459,6 +465,19 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
)
####################################
# OAUTH Configuration
####################################
ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
)
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
####################################
# SCIM Configuration
@@ -527,6 +546,19 @@ else:
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
)
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
else:
try:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
except Exception:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
####################################
# WEBSOCKET SUPPORT
####################################
+11 -1
View File
@@ -219,6 +219,15 @@ async def generate_function_chat_completion(
__task__ = metadata.get("task", None)
__task_body__ = metadata.get("task_body", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
@@ -230,9 +239,10 @@ async def generate_function_chat_completion(
"__files__": files,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request,
}
extra_params["__tools__"] = get_tools(
extra_params["__tools__"] = await get_tools(
request,
tool_ids,
user,
+50 -7
View File
@@ -157,6 +157,7 @@ from open_webui.config import (
IMAGE_SIZE,
IMAGE_STEPS,
IMAGES_OPENAI_API_BASE_URL,
IMAGES_OPENAI_API_VERSION,
IMAGES_OPENAI_API_KEY,
IMAGES_GEMINI_API_BASE_URL,
IMAGES_GEMINI_API_KEY,
@@ -243,8 +244,13 @@ from open_webui.config import (
EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL,
DOCLING_SERVER_URL,
DOCLING_DO_OCR,
DOCLING_FORCE_OCR,
DOCLING_OCR_ENGINE,
DOCLING_OCR_LANG,
DOCLING_PDF_BACKEND,
DOCLING_TABLE_MODE,
DOCLING_PIPELINE,
DOCLING_DO_PICTURE_DESCRIPTION,
DOCLING_PICTURE_DESCRIPTION_MODE,
DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -591,6 +597,7 @@ app = FastAPI(
)
oauth_manager = OAuthManager(app)
app.state.oauth_manager = oauth_manager
app.state.instance_id = None
app.state.config = AppConfig(
@@ -810,8 +817,13 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND
app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE
app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE
app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE
app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
@@ -1019,6 +1031,7 @@ app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
app.state.config.IMAGES_OPENAI_API_VERSION = IMAGES_OPENAI_API_VERSION
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
app.state.config.IMAGES_GEMINI_API_BASE_URL = IMAGES_GEMINI_API_BASE_URL
@@ -1405,6 +1418,14 @@ async def chat_completion(
model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id, request.cookies.get("oauth_session_id", None)
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
metadata = {}
try:
if not model_item.get("direct", False):
@@ -1437,11 +1458,15 @@ async def chat_completion(
stream_delta_chunk_size = form_data.get("params", {}).get(
"stream_delta_chunk_size"
)
reasoning_tags = form_data.get("params", {}).get("reasoning_tags")
# Model Params
if model_info_params.get("stream_delta_chunk_size"):
stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size")
if model_info_params.get("reasoning_tags") is not None:
reasoning_tags = model_info_params.get("reasoning_tags")
metadata = {
"user_id": user.id,
"chat_id": form_data.pop("chat_id", None),
@@ -1457,6 +1482,7 @@ async def chat_completion(
"direct": model_item.get("direct", False),
"params": {
"stream_delta_chunk_size": stream_delta_chunk_size,
"reasoning_tags": reasoning_tags,
"function_calling": (
"native"
if (
@@ -1514,7 +1540,7 @@ async def chat_completion(
try:
event_emitter = get_event_emitter(metadata)
await event_emitter(
{"type": "task-cancelled"},
{"type": "chat:tasks:cancel"},
)
except Exception as e:
pass
@@ -1530,14 +1556,21 @@ async def chat_completion(
"error": {"content": str(e)},
},
)
event_emitter = get_event_emitter(metadata)
await event_emitter(
{
"type": "chat:message:error",
"data": {"error": {"content": str(e)}},
}
)
await event_emitter(
{"type": "chat:tasks:cancel"},
)
except:
pass
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
if (
metadata.get("session_id")
and metadata.get("chat_id")
@@ -1637,8 +1670,18 @@ async def list_tasks_by_chat_id_endpoint(
@app.get("/api/config")
async def get_app_config(request: Request):
user = None
if "token" in request.cookies:
token = None
auth_header = request.headers.get("Authorization")
if auth_header:
cred = get_http_authorization_cred(auth_header)
if cred:
token = cred.credentials
if not token and "token" in request.cookies:
token = request.cookies.get("token")
if token:
try:
data = decode_token(token)
except Exception as e:
@@ -0,0 +1,52 @@
"""Add oauth_session table
Revision ID: 38d63c18f30f
Revises: 3af16a1c9fb6
Create Date: 2025-09-08 14:19:59.583921
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "38d63c18f30f"
down_revision: Union[str, None] = "3af16a1c9fb6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create oauth_session table
op.create_table(
"oauth_session",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("provider", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("expires_at", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
)
# Create indexes for better performance
op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
op.create_index(
"idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
)
def downgrade() -> None:
# Drop indexes first
op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
# Drop the table
op.drop_table("oauth_session")
+9
View File
@@ -147,6 +147,15 @@ class FilesTable:
with get_db() as db:
return [FileModel.model_validate(file) for file in db.query(File).all()]
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
file = self.get_file_by_id(id)
if not file:
return False
if file.user_id == user_id:
return True
# Implement additional access control logic here as needed
return False
def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
with get_db() as db:
return [
+15 -2
View File
@@ -58,6 +58,14 @@ class FolderModel(BaseModel):
class FolderForm(BaseModel):
name: str
data: Optional[dict] = None
meta: Optional[dict] = None
model_config = ConfigDict(extra="allow")
class FolderUpdateForm(BaseModel):
name: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
model_config = ConfigDict(extra="allow")
@@ -191,7 +199,7 @@ class FolderTable:
return
def update_folder_by_id_and_user_id(
self, id: str, user_id: str, form_data: FolderForm
self, id: str, user_id: str, form_data: FolderUpdateForm
) -> Optional[FolderModel]:
try:
with get_db() as db:
@@ -222,8 +230,13 @@ class FolderTable:
**form_data["data"],
}
folder.updated_at = int(time.time())
if "meta" in form_data:
folder.meta = {
**(folder.meta or {}),
**form_data["meta"],
}
folder.updated_at = int(time.time())
db.commit()
return FolderModel.model_validate(folder)
+30 -7
View File
@@ -54,6 +54,22 @@ class FunctionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class FunctionWithValvesModel(BaseModel):
id: str
user_id: str
name: str
type: str
content: str
meta: FunctionMeta
valves: Optional[dict] = None
is_active: bool = False
is_global: bool = False
updated_at: int # timestamp in epoch
created_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
@@ -111,8 +127,8 @@ class FunctionsTable:
return None
def sync_functions(
self, user_id: str, functions: list[FunctionModel]
) -> list[FunctionModel]:
self, user_id: str, functions: list[FunctionWithValvesModel]
) -> list[FunctionWithValvesModel]:
# Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
try:
with get_db() as db:
@@ -166,17 +182,24 @@ class FunctionsTable:
except Exception:
return None
def get_functions(self, active_only=False) -> list[FunctionModel]:
def get_functions(
self, active_only=False, include_valves=False
) -> list[FunctionModel | FunctionWithValvesModel]:
with get_db() as db:
if active_only:
functions = db.query(Function).filter_by(is_active=True).all()
else:
functions = db.query(Function).all()
if include_valves:
return [
FunctionModel.model_validate(function)
for function in db.query(Function).filter_by(is_active=True).all()
FunctionWithValvesModel.model_validate(function)
for function in functions
]
else:
return [
FunctionModel.model_validate(function)
for function in db.query(Function).all()
FunctionModel.model_validate(function) for function in functions
]
def get_functions_by_type(
+18 -8
View File
@@ -288,13 +288,17 @@ class GroupTable:
if not group:
return None
if not group.user_ids:
group.user_ids = []
group_user_ids = group.user_ids
if not group_user_ids or not isinstance(group_user_ids, list):
group_user_ids = []
group_user_ids = list(set(group_user_ids)) # Deduplicate
for user_id in user_ids:
if user_id not in group.user_ids:
group.user_ids.append(user_id)
if user_id not in group_user_ids:
group_user_ids.append(user_id)
group.user_ids = group_user_ids
group.updated_at = int(time.time())
db.commit()
db.refresh(group)
@@ -312,14 +316,20 @@ class GroupTable:
if not group:
return None
if not group.user_ids:
group_user_ids = group.user_ids
if not group_user_ids or not isinstance(group_user_ids, list):
return GroupModel.model_validate(group)
for user_id in user_ids:
if user_id in group.user_ids:
group.user_ids.remove(user_id)
group_user_ids = list(set(group_user_ids)) # Deduplicate
for user_id in user_ids:
if user_id in group_user_ids:
group_user_ids.remove(user_id)
group.user_ids = group_user_ids
group.updated_at = int(time.time())
db.commit()
db.refresh(group)
return GroupModel.model_validate(group)
+25 -5
View File
@@ -8,6 +8,7 @@ from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import FileMetadataResponse
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@@ -128,11 +129,18 @@ class KnowledgeTable:
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
with get_db() as db:
knowledge_bases = []
for knowledge in (
all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
):
user = Users.get_user_by_id(knowledge.user_id)
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
knowledge_bases = []
for knowledge in all_knowledge:
user = users_dict.get(knowledge.user_id)
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
@@ -143,15 +151,27 @@ class KnowledgeTable:
)
return knowledge_bases
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id)
if not knowledge:
return False
if knowledge.user_id == user_id:
return True
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return has_access(user_id, permission, knowledge.access_control, user_group_ids)
def get_knowledge_bases_by_user_id(
self, user_id: str, permission: str = "write"
) -> list[KnowledgeUserModel]:
knowledge_bases = self.get_knowledge_bases()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
knowledge_base
for knowledge_base in knowledge_bases
if knowledge_base.user_id == user_id
or has_access(user_id, permission, knowledge_base.access_control)
or has_access(
user_id, permission, knowledge_base.access_control, user_group_ids
)
]
def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:
+12 -3
View File
@@ -5,6 +5,7 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
@@ -175,9 +176,16 @@ class ModelsTable:
def get_models(self) -> list[ModelUserResponse]:
with get_db() as db:
all_models = db.query(Model).filter(Model.base_model_id != None).all()
user_ids = list(set(model.user_id for model in all_models))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
models = []
for model in db.query(Model).filter(Model.base_model_id != None).all():
user = Users.get_user_by_id(model.user_id)
for model in all_models:
user = users_dict.get(model.user_id)
models.append(
ModelUserResponse.model_validate(
{
@@ -199,11 +207,12 @@ class ModelsTable:
self, user_id: str, permission: str = "write"
) -> list[ModelUserResponse]:
models = self.get_models()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
model
for model in models
if model.user_id == user_id
or has_access(user_id, permission, model.access_control)
or has_access(user_id, permission, model.access_control, user_group_ids)
]
def get_model_by_id(self, id: str) -> Optional[ModelModel]:
+3 -1
View File
@@ -4,6 +4,7 @@ import uuid
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse
@@ -105,11 +106,12 @@ class NoteTable:
self, user_id: str, permission: str = "write"
) -> list[NoteModel]:
notes = self.get_notes()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
note
for note in notes
if note.user_id == user_id
or has_access(user_id, permission, note.access_control)
or has_access(user_id, permission, note.access_control, user_group_ids)
]
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
+246
View File
@@ -0,0 +1,246 @@
import time
import logging
import uuid
from typing import Optional, List
import base64
import hashlib
import json
from cryptography.fernet import Fernet
from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, Index
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
####################
# DB MODEL
####################
class OAuthSession(Base):
__tablename__ = "oauth_session"
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
provider = Column(Text, nullable=False)
token = Column(
Text, nullable=False
) # JSON with access_token, id_token, refresh_token
expires_at = Column(BigInteger, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
# Add indexes for better performance
__table_args__ = (
Index("idx_oauth_session_user_id", "user_id"),
Index("idx_oauth_session_expires_at", "expires_at"),
Index("idx_oauth_session_user_provider", "user_id", "provider"),
)
class OAuthSessionModel(BaseModel):
id: str
user_id: str
provider: str
token: dict
expires_at: int # timestamp in epoch
created_at: int # timestamp in epoch
updated_at: int # timestamp in epoch
model_config = ConfigDict(from_attributes=True)
####################
# Forms
####################
class OAuthSessionResponse(BaseModel):
id: str
user_id: str
provider: str
expires_at: int
class OAuthSessionTable:
def __init__(self):
self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
if not self.encryption_key:
raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
# check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
if len(self.encryption_key) != 44:
key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
self.encryption_key = base64.urlsafe_b64encode(key_bytes)
else:
self.encryption_key = self.encryption_key.encode()
try:
self.fernet = Fernet(self.encryption_key)
except Exception as e:
log.error(f"Error initializing Fernet with provided key: {e}")
raise
def _encrypt_token(self, token) -> str:
"""Encrypt OAuth tokens for storage"""
try:
token_json = json.dumps(token)
encrypted = self.fernet.encrypt(token_json.encode()).decode()
return encrypted
except Exception as e:
log.error(f"Error encrypting tokens: {e}")
raise
def _decrypt_token(self, token: str):
"""Decrypt OAuth tokens from storage"""
try:
decrypted = self.fernet.decrypt(token.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting tokens: {e}")
raise
def create_session(
self,
user_id: str,
provider: str,
token: dict,
) -> Optional[OAuthSessionModel]:
"""Create a new OAuth session"""
try:
with get_db() as db:
current_time = int(time.time())
id = str(uuid.uuid4())
result = OAuthSession(
**{
"id": id,
"user_id": user_id,
"provider": provider,
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"created_at": current_time,
"updated_at": current_time,
}
)
db.add(result)
db.commit()
db.refresh(result)
if result:
result.token = token # Return decrypted token
return OAuthSessionModel.model_validate(result)
else:
return None
except Exception as e:
log.error(f"Error creating OAuth session: {e}")
return None
def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID"""
try:
with get_db() as db:
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_session_by_id_and_user_id(
self, session_id: str, user_id: str
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by ID and user ID"""
try:
with get_db() as db:
session = (
db.query(OAuthSession)
.filter_by(id=session_id, user_id=user_id)
.first()
)
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by ID: {e}")
return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user"""
try:
with get_db() as db:
sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
results = []
for session in sessions:
session.token = self._decrypt_token(session.token)
results.append(OAuthSessionModel.model_validate(session))
return results
except Exception as e:
log.error(f"Error getting OAuth sessions by user ID: {e}")
return []
def update_session_by_id(
self, session_id: str, token: dict
) -> Optional[OAuthSessionModel]:
"""Update OAuth session tokens"""
try:
with get_db() as db:
current_time = int(time.time())
db.query(OAuthSession).filter_by(id=session_id).update(
{
"token": self._encrypt_token(token),
"expires_at": token.get("expires_at"),
"updated_at": current_time,
}
)
db.commit()
session = db.query(OAuthSession).filter_by(id=session_id).first()
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error updating OAuth session tokens: {e}")
return None
def delete_session_by_id(self, session_id: str) -> bool:
"""Delete an OAuth session"""
try:
with get_db() as db:
result = db.query(OAuthSession).filter_by(id=session_id).delete()
db.commit()
return result > 0
except Exception as e:
log.error(f"Error deleting OAuth session: {e}")
return False
def delete_sessions_by_user_id(self, user_id: str) -> bool:
"""Delete all OAuth sessions for a user"""
try:
with get_db() as db:
result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
db.commit()
return True
except Exception as e:
log.error(f"Error deleting OAuth sessions by user ID: {e}")
return False
OAuthSessions = OAuthSessionTable()
+12 -4
View File
@@ -2,6 +2,7 @@ import time
from typing import Optional
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse
from pydantic import BaseModel, ConfigDict
@@ -103,10 +104,16 @@ class PromptsTable:
def get_prompts(self) -> list[PromptUserResponse]:
with get_db() as db:
prompts = []
all_prompts = db.query(Prompt).order_by(Prompt.timestamp.desc()).all()
for prompt in db.query(Prompt).order_by(Prompt.timestamp.desc()).all():
user = Users.get_user_by_id(prompt.user_id)
user_ids = list(set(prompt.user_id for prompt in all_prompts))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
prompts = []
for prompt in all_prompts:
user = users_dict.get(prompt.user_id)
prompts.append(
PromptUserResponse.model_validate(
{
@@ -122,12 +129,13 @@ class PromptsTable:
self, user_id: str, permission: str = "write"
) -> list[PromptUserResponse]:
prompts = self.get_prompts()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
prompt
for prompt in prompts
if prompt.user_id == user_id
or has_access(user_id, permission, prompt.access_control)
or has_access(user_id, permission, prompt.access_control, user_group_ids)
]
def update_prompt_by_command(
+13 -3
View File
@@ -4,6 +4,8 @@ from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.users import Users, UserResponse
from open_webui.models.groups import Groups
from open_webui.env import SRC_LOG_LEVELS
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text, JSON
@@ -144,9 +146,16 @@ class ToolsTable:
def get_tools(self) -> list[ToolUserModel]:
with get_db() as db:
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
user_ids = list(set(tool.user_id for tool in all_tools))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
users_dict = {user.id: user for user in users}
tools = []
for tool in db.query(Tool).order_by(Tool.updated_at.desc()).all():
user = Users.get_user_by_id(tool.user_id)
for tool in all_tools:
user = users_dict.get(tool.user_id)
tools.append(
ToolUserModel.model_validate(
{
@@ -161,12 +170,13 @@ class ToolsTable:
self, user_id: str, permission: str = "write"
) -> list[ToolUserModel]:
tools = self.get_tools()
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
return [
tool
for tool in tools
if tool.user_id == user_id
or has_access(user_id, permission, tool.access_control)
or has_access(user_id, permission, tool.access_control, user_group_ids)
]
def get_tool_valves_by_id(self, id: str) -> Optional[dict]:
@@ -64,7 +64,7 @@ class DatalabMarkerLoader:
return mime_map.get(ext, "application/octet-stream")
def check_marker_request_status(self, request_id: str) -> dict:
url = f"{self.api_base_url}/marker/{request_id}"
url = f"{self.api_base_url}/{request_id}"
headers = {"X-Api-Key": self.api_key}
try:
response = requests.get(url, headers=headers)
@@ -111,7 +111,7 @@ class DatalabMarkerLoader:
with open(self.file_path, "rb") as f:
files = {"file": (filename, f, mime_type)}
response = requests.post(
f"{self.api_base_url}/marker",
f"{self.api_base_url}",
data=form_data,
files=files,
headers=headers,
@@ -1,6 +1,7 @@
import requests
import logging, os
from typing import Iterator, List, Union
from urllib.parse import quote
from langchain_core.document_loaders import BaseLoader
from langchain_core.documents import Document
@@ -37,7 +38,7 @@ class ExternalDocumentLoader(BaseLoader):
headers["Authorization"] = f"Bearer {self.api_key}"
try:
headers["X-Filename"] = os.path.basename(self.file_path)
headers["X-Filename"] = quote(os.path.basename(self.file_path))
except:
pass
+33 -9
View File
@@ -4,6 +4,7 @@ import ftfy
import sys
import json
from azure.identity import DefaultAzureCredential
from langchain_community.document_loaders import (
AzureAIDocumentIntelligenceLoader,
BSHTMLLoader,
@@ -147,7 +148,7 @@ class DoclingLoader:
)
}
params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
params = {"image_export_mode": "placeholder"}
if self.params:
if self.params.get("do_picture_description"):
@@ -173,7 +174,15 @@ class DoclingLoader:
self.params.get("picture_description_api", {})
)
if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
params["do_ocr"] = self.params.get("do_ocr")
params["force_ocr"] = self.params.get("force_ocr")
if (
self.params.get("do_ocr")
and self.params.get("ocr_engine")
and self.params.get("ocr_lang")
):
params["ocr_engine"] = self.params.get("ocr_engine")
params["ocr_lang"] = [
lang.strip()
@@ -181,6 +190,15 @@ class DoclingLoader:
if lang.strip()
]
if self.params.get("pdf_backend"):
params["pdf_backend"] = self.params.get("pdf_backend")
if self.params.get("table_mode"):
params["table_mode"] = self.params.get("table_mode")
if self.params.get("pipeline"):
params["pipeline"] = self.params.get("pipeline")
endpoint = f"{self.url}/v1/convert/file"
r = requests.post(endpoint, files=files, data=params)
@@ -283,7 +301,7 @@ class Loader:
):
api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "")
if not api_base_url or api_base_url.strip() == "":
api_base_url = "https://www.datalab.to/api/v1"
api_base_url = "https://www.datalab.to/api/v1/marker" # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349
loader = DatalabMarkerLoader(
file_path=file_path,
@@ -327,7 +345,6 @@ class Loader:
elif (
self.engine == "document_intelligence"
and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != ""
and self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != ""
and (
file_ext in ["pdf", "xls", "xlsx", "docx", "ppt", "pptx"]
or file_content_type
@@ -340,11 +357,18 @@ class Loader:
]
)
):
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
if self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "":
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"),
)
else:
loader = AzureAIDocumentIntelligenceLoader(
file_path=file_path,
api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"),
azure_credential=DefaultAzureCredential(),
)
elif (
self.engine == "mistral_ocr"
and self.kwargs.get("MISTRAL_OCR_API_KEY") != ""
@@ -98,10 +98,9 @@ class YoutubeLoader:
else:
youtube_proxies = None
transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
try:
transcript_list = YouTubeTranscriptApi.list_transcripts(
self.video_id, proxies=youtube_proxies
)
transcript_list = transcript_api.list(self.video_id)
except Exception as e:
log.exception("Loading YouTube transcript failed")
return []
+30 -30
View File
@@ -124,14 +124,17 @@ def query_doc_with_hybrid_search(
hybrid_bm25_weight: float,
) -> dict:
try:
# BM_25 required only if weight is greater than 0
if hybrid_bm25_weight > 0:
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
bm25_retriever = BM25Retriever.from_texts(
texts=collection_result.documents[0],
metadatas=collection_result.metadatas[0],
)
bm25_retriever.k = k
if not collection_result.documents[0]:
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []}
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
bm25_retriever = BM25Retriever.from_texts(
texts=collection_result.documents[0],
metadatas=collection_result.metadatas[0],
)
bm25_retriever.k = k
vector_search_retriever = VectorSearchRetriever(
collection_name=collection_name,
@@ -339,22 +342,18 @@ def query_collection_with_hybrid_search(
# Fetch collection data once per collection sequentially
# Avoid fetching the same data multiple times later
collection_results = {}
# Only retrieve entire collection if bm_25 calculation is required
if hybrid_bm25_weight > 0:
for collection_name in collection_names:
try:
log.debug(
f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}"
)
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
collection_name=collection_name
)
except Exception as e:
log.exception(f"Failed to fetch collection {collection_name}: {e}")
collection_results[collection_name] = None
else:
for collection_name in collection_names:
collection_results[collection_name] = []
for collection_name in collection_names:
try:
log.debug(
f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}"
)
collection_results[collection_name] = VECTOR_DB_CLIENT.get(
collection_name=collection_name
)
except Exception as e:
log.exception(f"Failed to fetch collection {collection_name}: {e}")
collection_results[collection_name] = None
log.info(
f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..."
)
@@ -489,17 +488,18 @@ def get_sources_from_items(
if item.get("type") == "text":
# Raw Text
# Used during temporary chat file uploads
# Used during temporary chat file uploads or web page & youtube attachements
if item.get("file"):
if item.get("collection_name"):
# If item has a collection name, use it
collection_names.append(item.get("collection_name"))
elif item.get("file"):
# if item has file data, use it
query_result = {
"documents": [
[item.get("file", {}).get("data", {}).get("content")]
],
"metadatas": [
[item.get("file", {}).get("data", {}).get("meta", {})]
],
"metadatas": [[item.get("file", {}).get("meta", {})]],
}
else:
# Fallback to item content
@@ -966,7 +966,7 @@ class RerankCompressor(BaseDocumentCompressor):
)
scores = util.cos_sim(query_embedding, document_embedding)[0]
if scores:
if scores is not None:
docs_with_scores = list(
zip(
documents,
@@ -37,6 +37,7 @@ from open_webui.retrieval.vector.main import (
from open_webui.config import (
PGVECTOR_DB_URL,
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
PGVECTOR_CREATE_EXTENSION,
PGVECTOR_PGCRYPTO,
PGVECTOR_PGCRYPTO_KEY,
PGVECTOR_POOL_SIZE,
@@ -112,18 +113,19 @@ class PgvectorClient(VectorDBBase):
try:
# Ensure the pgvector extension is available
# Use a conditional check to avoid permission issues on Azure PostgreSQL
self.session.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
CREATE EXTENSION IF NOT EXISTS vector;
END IF;
END $$;
"""
if PGVECTOR_CREATE_EXTENSION:
self.session.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN
CREATE EXTENSION IF NOT EXISTS vector;
END IF;
END $$;
"""
)
)
)
if PGVECTOR_PGCRYPTO:
# Ensure the pgcrypto extension is available for encryption
+2 -1
View File
@@ -517,6 +517,7 @@ class SafeWebBaseLoader(WebBaseLoader):
async with session.get(
url,
**(self.requests_kwargs | kwargs),
allow_redirects=False,
) as response:
if self.raise_for_status:
response.raise_for_status()
@@ -614,7 +615,7 @@ def get_web_loader(
WebLoaderClass = SafeWebBaseLoader
if WEB_LOADER_ENGINE.value == "playwright":
WebLoaderClass = SafePlaywrightURLLoader
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value * 1000
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value
if PLAYWRIGHT_WS_URL.value:
web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value
+9 -5
View File
@@ -4,7 +4,6 @@ import logging
import os
import uuid
from functools import lru_cache
from pathlib import Path
from pydub import AudioSegment
from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor
@@ -15,7 +14,7 @@ import aiohttp
import aiofiles
import requests
import mimetypes
from urllib.parse import quote
from urllib.parse import urljoin, quote
from fastapi import (
Depends,
@@ -338,7 +337,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
timeout=timeout, trust_env=True
) as session:
r = await session.post(
url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
url=urljoin(
request.app.state.config.TTS_OPENAI_API_BASE_URL,
"/audio/speech",
),
json=payload,
headers={
"Content-Type": "application/json",
@@ -466,8 +468,10 @@ async def speech(request: Request, user=Depends(get_verified_user)):
timeout=timeout, trust_env=True
) as session:
async with session.post(
(base_url or f"https://{region}.tts.speech.microsoft.com")
+ "/cognitiveservices/v1",
urljoin(
base_url or f"https://{region}.tts.speech.microsoft.com",
"/cognitiveservices/v1",
),
headers={
"Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY,
"Content-Type": "application/ssml+xml",
+27 -15
View File
@@ -19,6 +19,7 @@ from open_webui.models.auths import (
)
from open_webui.models.users import Users, UpdateProfileForm
from open_webui.models.groups import Groups
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
@@ -29,6 +30,7 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
ENABLE_INITIAL_ADMIN_SIGNUP,
SRC_LOG_LEVELS,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -569,9 +571,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
not request.app.state.config.ENABLE_SIGNUP
or not request.app.state.config.ENABLE_LOGIN_FORM
):
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
else:
if has_users:
raise HTTPException(
@@ -674,19 +677,29 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
async def signout(request: Request, response: Response):
response.delete_cookie("token")
response.delete_cookie("oui-session")
response.delete_cookie("oauth_id_token")
if ENABLE_OAUTH_SIGNUP.value:
oauth_id_token = request.cookies.get("oauth_id_token")
if oauth_id_token and OPENID_PROVIDER_URL.value:
oauth_session_id = request.cookies.get("oauth_session_id")
if oauth_session_id:
response.delete_cookie("oauth_session_id")
session = OAuthSessions.get_session_by_id(oauth_session_id)
oauth_server_metadata_url = (
request.app.state.oauth_manager.get_server_metadata_url(session.provider)
if session
else None
) or OPENID_PROVIDER_URL.value
if session and oauth_server_metadata_url:
oauth_id_token = session.token.get("id_token")
try:
async with ClientSession(trust_env=True) as session:
async with session.get(OPENID_PROVIDER_URL.value) as resp:
if resp.status == 200:
openid_data = await resp.json()
async with session.get(oauth_server_metadata_url) as r:
if r.status == 200:
openid_data = await r.json()
logout_url = openid_data.get("end_session_endpoint")
if logout_url:
response.delete_cookie("oauth_id_token")
if logout_url:
return JSONResponse(
status_code=200,
content={
@@ -701,15 +714,14 @@ async def signout(request: Request, response: Response):
headers=response.headers,
)
else:
raise HTTPException(
status_code=resp.status,
detail="Failed to fetch OpenID configuration",
)
raise Exception("Failed to fetch OpenID configuration")
except Exception as e:
log.error(f"OpenID signout error: {str(e)}")
raise HTTPException(
status_code=500,
detail="Failed to sign out from the OpenID provider.",
headers=response.headers,
)
if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
+60 -25
View File
@@ -143,7 +143,28 @@ def upload_file(
file: UploadFile = File(...),
metadata: Optional[dict | str] = Form(None),
process: bool = Query(True),
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
):
return upload_file_handler(
request,
file=file,
metadata=metadata,
process=process,
process_in_background=process_in_background,
user=user,
background_tasks=background_tasks,
)
def upload_file_handler(
request: Request,
file: UploadFile = File(...),
metadata: Optional[dict | str] = Form(None),
process: bool = Query(True),
process_in_background: bool = Query(True),
user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None,
):
log.info(f"file.content_type: {file.content_type}")
@@ -214,16 +235,27 @@ def upload_file(
)
if process:
background_tasks.add_task(
process_uploaded_file,
request,
file,
file_path,
file_item,
file_metadata,
user,
)
return {"status": True, **file_item.model_dump()}
if background_tasks and process_in_background:
background_tasks.add_task(
process_uploaded_file,
request,
file,
file_path,
file_item,
file_metadata,
user,
)
return {"status": True, **file_item.model_dump()}
else:
process_uploaded_file(
request,
file,
file_path,
file_item,
file_metadata,
user,
)
return {"status": True, **file_item.model_dump()}
else:
if file_item:
return file_item
@@ -379,25 +411,28 @@ async def get_file_process_status(
MAX_FILE_PROCESSING_DURATION = 3600 * 2
async def event_stream(file_item):
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_item.id)
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
if file_item:
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = Files.get_file_by_id(file_item.id)
if file_item:
data = file_item.model_dump().get("data", {})
status = data.get("status")
if status:
event = {"status": status}
if status == "failed":
event["error"] = data.get("error")
if status:
event = {"status": status}
if status == "failed":
event["error"] = data.get("error")
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
yield f"data: {json.dumps(event)}\n\n"
if status in ("completed", "failed"):
break
else:
# Legacy
break
else:
# Legacy
break
await asyncio.sleep(0.5)
await asyncio.sleep(0.5)
else:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
return StreamingResponse(
event_stream(file),
+40 -9
View File
@@ -10,10 +10,14 @@ import mimetypes
from open_webui.models.folders import (
FolderForm,
FolderUpdateForm,
FolderModel,
Folders,
)
from open_webui.models.chats import Chats
from open_webui.models.files import Files
from open_webui.models.knowledge import Knowledges
from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
@@ -44,6 +48,31 @@ router = APIRouter()
async def get_folders(user=Depends(get_verified_user)):
folders = Folders.get_folders_by_user_id(user.id)
# Verify folder data integrity
for folder in folders:
if folder.data:
if "files" in folder.data:
valid_files = []
for file in folder.data["files"]:
if file.get("type") == "file":
if Files.check_access_by_user_id(
file.get("id"), user.id, "read"
):
valid_files.append(file)
elif file.get("type") == "collection":
if Knowledges.check_access_by_user_id(
file.get("id"), user.id, "read"
):
valid_files.append(file)
else:
valid_files.append(file)
folder.data["files"] = valid_files
Folders.update_folder_by_id_and_user_id(
folder.id, user.id, FolderUpdateForm(data=folder.data)
)
return [
{
**folder.model_dump(),
@@ -113,22 +142,24 @@ async def get_folder_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update")
async def update_folder_name_by_id(
id: str, form_data: FolderForm, user=Depends(get_verified_user)
id: str, form_data: FolderUpdateForm, user=Depends(get_verified_user)
):
folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder:
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
if form_data.name is not None:
# Check if folder with same name exists
existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
folder.parent_id, user.id, form_data.name
)
if existing_folder and existing_folder.id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),
)
try:
folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data)
return folder
except Exception as e:
log.exception(e)
+6 -5
View File
@@ -10,6 +10,7 @@ from open_webui.models.functions import (
FunctionForm,
FunctionModel,
FunctionResponse,
FunctionWithValvesModel,
Functions,
)
from open_webui.utils.plugin import (
@@ -46,9 +47,9 @@ async def get_functions(user=Depends(get_verified_user)):
############################
@router.get("/export", response_model=list[FunctionModel])
async def get_functions(user=Depends(get_admin_user)):
return Functions.get_functions()
@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
return Functions.get_functions(include_valves=include_valves)
############################
@@ -132,10 +133,10 @@ async def load_function_from_url(
class SyncFunctionsForm(BaseModel):
functions: list[FunctionModel] = []
functions: list[FunctionWithValvesModel] = []
@router.post("/sync", response_model=list[FunctionModel])
@router.post("/sync", response_model=list[FunctionWithValvesModel])
async def sync_functions(
request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
):
+33 -7
View File
@@ -10,11 +10,18 @@ from typing import Optional
from urllib.parse import quote
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
UploadFile,
)
from open_webui.config import CACHE_DIR
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
from open_webui.routers.files import upload_file
from open_webui.routers.files import upload_file_handler
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.images.comfyui import (
ComfyUIGenerateImageForm,
@@ -41,6 +48,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
},
"automatic1111": {
@@ -65,6 +73,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
class OpenAIConfigForm(BaseModel):
OPENAI_API_BASE_URL: str
OPENAI_API_VERSION: str
OPENAI_API_KEY: str
@@ -112,6 +121,9 @@ async def update_config(
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
form_data.openai.OPENAI_API_BASE_URL
)
request.app.state.config.IMAGES_OPENAI_API_VERSION = (
form_data.openai.OPENAI_API_VERSION
)
request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.openai.OPENAI_API_KEY
request.app.state.config.IMAGES_GEMINI_API_BASE_URL = (
@@ -158,6 +170,7 @@ async def update_config(
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
"openai": {
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
"OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION,
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
},
"automatic1111": {
@@ -469,8 +482,12 @@ def upload_image(request, image_data, content_type, metadata, user):
"content-type": content_type,
},
)
file_item = upload_file(
request, file=file, metadata=metadata, process=False, user=user
file_item = upload_file_handler(
request,
file=file,
metadata=metadata,
process=False,
user=user,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
@@ -487,10 +504,13 @@ async def image_generations(
# image model other than gpt-image-1, which is warned about on settings save
size = "512x512"
if "x" in request.app.state.config.IMAGE_SIZE:
if (
request.app.state.config.IMAGE_SIZE
and "x" in request.app.state.config.IMAGE_SIZE
):
size = request.app.state.config.IMAGE_SIZE
if "x" in form_data.size:
if form_data.size and "x" in form_data.size:
size = form_data.size
width, height = tuple(map(int, size.split("x")))
@@ -530,10 +550,16 @@ async def image_generations(
),
}
api_version_query_param = ""
if request.app.state.config.IMAGES_OPENAI_API_VERSION:
api_version_query_param = (
f"?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}"
)
# Use asyncio.to_thread for the requests.post call
r = await asyncio.to_thread(
requests.post,
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations",
url=f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations{api_version_query_param}",
json=data,
headers=headers,
)
+40 -13
View File
@@ -1,6 +1,6 @@
from typing import List, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
import logging
from open_webui.models.knowledge import (
@@ -151,6 +151,18 @@ async def create_new_knowledge(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
if knowledge:
@@ -285,6 +297,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
async def update_knowledge_by_id(
request: Request,
id: str,
form_data: KnowledgeForm,
user=Depends(get_verified_user),
@@ -306,10 +319,22 @@ async def update_knowledge_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
# Check if user can share publicly
if (
user.role != "admin"
and form_data.access_control == None
and not has_permission(
user.id,
"sharing.public_knowledge",
request.app.state.config.USER_PERMISSIONS,
)
):
form_data.access_control = {}
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
if knowledge:
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(),
@@ -492,6 +517,7 @@ def update_file_from_knowledge_by_id(
def remove_file_from_knowledge_by_id(
id: str,
form_data: KnowledgeFileIdForm,
delete_file: bool = Query(True),
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
@@ -528,18 +554,19 @@ def remove_file_from_knowledge_by_id(
log.debug(e)
pass
try:
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
if delete_file:
try:
# Remove the file's collection from vector database
file_collection = f"file-{form_data.file_id}"
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
except Exception as e:
log.debug("This was most likely caused by bypassing embedding processing")
log.debug(e)
pass
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
if knowledge:
data = knowledge.data or {}
+11 -7
View File
@@ -329,17 +329,21 @@ def merge_ollama_models_lists(model_lists):
for idx, model_list in enumerate(model_lists):
if model_list is not None:
for model in model_list:
id = model["model"]
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
id = model.get("model")
if id is not None:
if id not in merged_models:
model["urls"] = [idx]
merged_models[id] = model
else:
merged_models[id]["urls"].append(idx)
return list(merged_models.values())
@cached(ttl=MODELS_CACHE_TTL)
@cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models",
)
async def get_all_models(request: Request, user: UserModel = None):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
+113 -109
View File
@@ -119,6 +119,74 @@ def openai_reasoning_model_handler(payload):
return payload
def get_headers_and_cookies(
request: Request,
url,
key=None,
config=None,
metadata: Optional[dict] = None,
user: UserModel = None,
):
cookies = {}
headers = {
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
**(
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
if metadata and metadata.get("chat_id")
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
token = None
auth_type = config.get("auth_type")
if auth_type == "bearer" or auth_type is None:
# Default to bearer if not specified
token = f"{key}"
elif auth_type == "none":
token = None
elif auth_type == "session":
cookies = request.cookies
token = request.state.token.credentials
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
if oauth_token:
token = f"{oauth_token.get('access_token', '')}"
if token:
headers["Authorization"] = f"Bearer {token}"
return headers, cookies
##########################################
#
# API routes
@@ -210,34 +278,23 @@ async def speech(request: Request, user=Depends(get_verified_user)):
return FileResponse(file_path)
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
r = None
try:
r = requests.post(
url=f"{url}/audio/speech",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
},
headers=headers,
cookies=cookies,
stream=True,
)
@@ -401,7 +458,10 @@ async def get_filtered_models(models, user):
return filtered_models
@cached(ttl=MODELS_CACHE_TTL)
@cached(
ttl=MODELS_CACHE_TTL,
key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models",
)
async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
log.info("get_all_models()")
@@ -489,19 +549,9 @@ async def get_models(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
models = {
@@ -509,11 +559,10 @@ async def get_models(
"object": "list",
}
else:
headers["Authorization"] = f"Bearer {key}"
async with session.get(
f"{url}/models",
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status != 200:
@@ -572,7 +621,9 @@ class ConnectionVerificationForm(BaseModel):
@router.post("/verify")
async def verify_connection(
form_data: ConnectionVerificationForm, user=Depends(get_admin_user)
request: Request,
form_data: ConnectionVerificationForm,
user=Depends(get_admin_user),
):
url = form_data.url
key = form_data.key
@@ -584,19 +635,9 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session:
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
headers["api-key"] = key
@@ -605,6 +646,7 @@ async def verify_connection(
async with session.get(
url=f"{url}/openai/models?api-version={api_version}",
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
@@ -624,11 +666,10 @@ async def verify_connection(
return response_data
else:
headers["Authorization"] = f"Bearer {key}"
async with session.get(
f"{url}/models",
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
try:
@@ -836,32 +877,9 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"])
)
headers = {
"Content-Type": "application/json",
**(
{
"HTTP-Referer": "https://openwebui.com/",
"X-Title": "Open WebUI",
}
if "openrouter.ai" in url
else {}
),
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
**(
{"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
if metadata and metadata.get("chat_id")
else {}
),
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, metadata, user=user
)
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
@@ -871,7 +889,6 @@ async def generate_chat_completion(
request_url = f"{request_url}/chat/completions?api-version={api_version}"
else:
request_url = f"{url}/chat/completions"
headers["Authorization"] = f"Bearer {key}"
payload = json.dumps(payload)
@@ -890,6 +907,7 @@ async def generate_chat_completion(
url=request_url,
data=payload,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
@@ -951,31 +969,27 @@ async def embeddings(request: Request, form_data: dict, user):
models = request.app.state.OPENAI_MODELS
if model_id in models:
idx = models[model_id]["urlIdx"]
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
)
r = None
session = None
streaming = False
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user)
try:
session = aiohttp.ClientSession(trust_env=True)
r = await session.request(
method="POST",
url=f"{url}/embeddings",
data=body,
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS and user
else {}
),
},
headers=headers,
cookies=cookies,
)
if "text/event-stream" in r.headers.get("Content-Type", ""):
@@ -1037,19 +1051,9 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
streaming = False
try:
headers = {
"Content-Type": "application/json",
**(
{
"X-OpenWebUI-User-Name": quote(user.name, safe=" "),
"X-OpenWebUI-User-Id": user.id,
"X-OpenWebUI-User-Email": user.email,
"X-OpenWebUI-User-Role": user.role,
}
if ENABLE_FORWARD_USER_INFO_HEADERS
else {}
),
}
headers, cookies = get_headers_and_cookies(
request, url, key, api_config, user=user
)
if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview")
@@ -1062,7 +1066,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
request_url = f"{url}/{path}?api-version={api_version}"
else:
headers["Authorization"] = f"Bearer {key}"
request_url = f"{url}/{path}"
session = aiohttp.ClientSession(trust_env=True)
@@ -1071,6 +1074,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
url=request_url,
data=body,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
+59 -3
View File
@@ -426,8 +426,13 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -596,8 +601,13 @@ class ConfigForm(BaseModel):
TIKA_SERVER_URL: Optional[str] = None
DOCLING_SERVER_URL: Optional[str] = None
DOCLING_DO_OCR: Optional[bool] = None
DOCLING_FORCE_OCR: Optional[bool] = None
DOCLING_OCR_ENGINE: Optional[str] = None
DOCLING_OCR_LANG: Optional[str] = None
DOCLING_PDF_BACKEND: Optional[str] = None
DOCLING_TABLE_MODE: Optional[str] = None
DOCLING_PIPELINE: Optional[str] = None
DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
@@ -767,6 +777,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_DO_OCR = (
form_data.DOCLING_DO_OCR
if form_data.DOCLING_DO_OCR is not None
else request.app.state.config.DOCLING_DO_OCR
)
request.app.state.config.DOCLING_FORCE_OCR = (
form_data.DOCLING_FORCE_OCR
if form_data.DOCLING_FORCE_OCR is not None
else request.app.state.config.DOCLING_FORCE_OCR
)
request.app.state.config.DOCLING_OCR_ENGINE = (
form_data.DOCLING_OCR_ENGINE
if form_data.DOCLING_OCR_ENGINE is not None
@@ -777,7 +797,21 @@ async def update_rag_config(
if form_data.DOCLING_OCR_LANG is not None
else request.app.state.config.DOCLING_OCR_LANG
)
request.app.state.config.DOCLING_PDF_BACKEND = (
form_data.DOCLING_PDF_BACKEND
if form_data.DOCLING_PDF_BACKEND is not None
else request.app.state.config.DOCLING_PDF_BACKEND
)
request.app.state.config.DOCLING_TABLE_MODE = (
form_data.DOCLING_TABLE_MODE
if form_data.DOCLING_TABLE_MODE is not None
else request.app.state.config.DOCLING_TABLE_MODE
)
request.app.state.config.DOCLING_PIPELINE = (
form_data.DOCLING_PIPELINE
if form_data.DOCLING_PIPELINE is not None
else request.app.state.config.DOCLING_PIPELINE
)
request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
form_data.DOCLING_DO_PICTURE_DESCRIPTION
if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
@@ -1062,8 +1096,13 @@ async def update_rag_config(
"EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
"TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
"DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
"DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
"DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
"DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
"DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
"DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
"DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
"DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
"DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -1453,8 +1492,13 @@ def process_file(
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -1945,6 +1989,8 @@ async def process_web_search(
):
urls = []
result_items = []
try:
logging.info(
f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
@@ -1966,6 +2012,7 @@ async def process_web_search(
if result:
for item in result:
if item and item.link:
result_items.append(item)
urls.append(item.link)
urls = list(dict.fromkeys(urls))
@@ -2010,12 +2057,16 @@ async def process_web_search(
urls = [
doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
] # only keep the urls returned by the loader
result_items = [
dict(item) for item in result_items if item.link in urls
] # only keep the search results that have been loaded
if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
return {
"status": True,
"collection_name": None,
"filenames": urls,
"items": result_items,
"docs": [
{
"content": doc.page_content,
@@ -2048,6 +2099,7 @@ async def process_web_search(
return {
"status": True,
"collection_names": [collection_name],
"items": result_items,
"filenames": urls,
"loaded_count": len(docs),
}
@@ -2075,7 +2127,9 @@ def query_doc_handler(
user=Depends(get_verified_user),
):
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
form_data.hybrid is None or form_data.hybrid
):
collection_results = {}
collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get(
collection_name=form_data.collection_name
@@ -2145,7 +2199,9 @@ def query_collection_handler(
user=Depends(get_verified_user),
):
try:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH:
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
form_data.hybrid is None or form_data.hybrid
):
return query_collection_with_hybrid_search(
collection_names=form_data.collection_names,
queries=[form_data.query],
+4
View File
@@ -470,6 +470,10 @@ async def generate_queries(
detail=f"Query generation is disabled",
)
if getattr(request.state, "cached_queries", None):
log.info(f"Reusing cached queries: {request.state.cached_queries}")
return request.state.cached_queries
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
+3 -1
View File
@@ -4,6 +4,7 @@ from typing import Optional
import time
import re
import aiohttp
from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -71,11 +72,12 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
# Admin can see all tools
return tools
else:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
tools = [
tool
for tool in tools
if tool.user_id == user.id
or has_access(user.id, "read", tool.access_control)
or has_access(user.id, "read", tool.access_control, user_group_ids)
]
return tools
+18
View File
@@ -10,6 +10,8 @@ from pydantic import BaseModel
from open_webui.models.auths import Auths
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
@@ -146,6 +148,10 @@ class ChatPermissions(BaseModel):
params: bool = True
file_upload: bool = True
delete: bool = True
delete_message: bool = True
continue_response: bool = True
regenerate_response: bool = True
rate_response: bool = True
edit: bool = True
share: bool = True
export: bool = True
@@ -336,6 +342,18 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
)
@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict])
async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
if sessions and len(sessions) > 0:
return sessions
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
# GetUserProfileImageById
############################
+41 -3
View File
@@ -115,7 +115,7 @@ if WEBSOCKET_MANAGER == "redis":
clean_up_lock = RedisLock(
redis_url=WEBSOCKET_REDIS_URL,
lock_name="usage_cleanup_lock",
lock_name=f"{REDIS_KEY_PREFIX}:usage_cleanup_lock",
timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT,
redis_sentinels=redis_sentinels,
redis_cluster=WEBSOCKET_REDIS_CLUSTER,
@@ -266,7 +266,9 @@ async def connect(sid, environ, auth):
user = Users.get_user_by_id(data["id"])
if user:
SESSION_POOL[sid] = user.model_dump()
SESSION_POOL[sid] = user.model_dump(
exclude=["date_of_birth", "bio", "gender"]
)
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@@ -288,7 +290,7 @@ async def user_join(sid, data):
if not user:
return
SESSION_POOL[sid] = user.model_dump()
SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"])
if user.id in USER_POOL:
USER_POOL[user.id] = USER_POOL[user.id] + [sid]
else:
@@ -703,6 +705,42 @@ def get_event_emitter(request_info, update_db=True):
},
)
if "type" in event_data and event_data["type"] == "files":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
files = event_data.get("data", {}).get("files", [])
files.extend(message.get("files", []))
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"files": files,
},
)
if event_data.get("type") in ["source", "citation"]:
data = event_data.get("data", {})
if data.get("type") == None:
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
sources = message.get("sources", [])
sources.append(data)
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"sources": sources,
},
)
return __event_emitter__
+2 -2
View File
@@ -153,9 +153,9 @@ async def stop_task(redis, task_id: str):
# Optionally check if task_id still in Redis a few moments later for feedback?
return {"status": True, "message": f"Stop signal sent for {task_id}"}
task = tasks.pop(task_id)
task = tasks.pop(task_id, None)
if not task:
raise ValueError(f"Task with ID {task_id} not found.")
return {"status": False, "message": f"Task with ID {task_id} not found."}
task.cancel() # Request task cancellation
try:
+6 -3
View File
@@ -1,4 +1,4 @@
from typing import Optional, Union, List, Dict, Any
from typing import Optional, Set, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
@@ -109,12 +109,15 @@ def has_access(
user_id: str,
type: str = "write",
access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None,
) -> bool:
if access_control is None:
return type == "read"
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = [group.id for group in user_groups]
if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = {group.id for group in user_groups}
permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", [])
permitted_user_ids = permission_access.get("user_ids", [])
+54 -42
View File
@@ -261,55 +261,67 @@ def get_current_user(
return user
# auth by jwt token
try:
data = decode_token(token)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
)
if data is not None and "id" in data:
user = Users.get_user_by_id(data["id"])
if user is None:
try:
try:
data = decode_token(token)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
detail="Invalid token",
)
else:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
trusted_email = request.headers.get(
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
).lower()
if trusted_email and user.email != trusted_email:
# Delete the token cookie
response.delete_cookie("token")
# Delete OAuth token if present
if request.cookies.get("oauth_id_token"):
response.delete_cookie("oauth_id_token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User mismatch. Please sign in again.",
if data is not None and "id" in data:
user = Users.get_user_by_id(data["id"])
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
)
else:
if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
trusted_email = request.headers.get(
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
).lower()
if trusted_email and user.email != trusted_email:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User mismatch. Please sign in again.",
)
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "jwt")
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
background_tasks.add_task(
Users.update_user_last_active_by_id, user.id
)
return user
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
except Exception as e:
# Delete the token cookie
if request.cookies.get("token"):
response.delete_cookie("token")
# Add user info to current span
current_span = trace.get_current_span()
if current_span:
current_span.set_attribute("client.user.id", user.id)
current_span.set_attribute("client.user.email", user.email)
current_span.set_attribute("client.user.role", user.role)
current_span.set_attribute("client.auth.type", "jwt")
if request.cookies.get("oauth_id_token"):
response.delete_cookie("oauth_id_token")
# Refresh the user's last active timestamp asynchronously
# to prevent blocking the request
if background_tasks:
background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
return user
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
# Delete OAuth session if present
if request.cookies.get("oauth_session_id"):
response.delete_cookie("oauth_session_id")
raise e
def get_current_user_by_api_key(api_key: str):
+247 -133
View File
@@ -98,8 +98,10 @@ from open_webui.env import (
SRC_LOG_LEVELS,
GLOBAL_LOG_LEVEL,
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES,
BYPASS_MODEL_ACCESS_CONTROL,
ENABLE_REALTIME_CHAT_SAVE,
ENABLE_QUERIES_CACHE,
)
from open_webui.constants import TASKS
@@ -109,6 +111,20 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
DEFAULT_REASONING_TAGS = [
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<reason>", "</reason>"),
("<reasoning>", "</reasoning>"),
("<thought>", "</thought>"),
("<Thought>", "</Thought>"),
("<|begin_of_thought|>", "<|end_of_thought|>"),
("◁think▷", "◁/think▷"),
]
DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")]
DEFAULT_CODE_INTERPRETER_TAGS = [("<code_interpreter>", "</code_interpreter>")]
async def chat_completion_tools_handler(
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
) -> tuple[dict, dict]:
@@ -353,7 +369,7 @@ async def chat_web_search_handler(
"type": "status",
"data": {
"action": "web_search",
"description": "Generating search query",
"description": "Searching the web",
"done": False,
},
}
@@ -390,6 +406,9 @@ async def chat_web_search_handler(
except Exception as e:
queries = [response]
if ENABLE_QUERIES_CACHE:
request.state.cached_queries = queries
except Exception as e:
log.exception(e)
queries = [user_message]
@@ -416,8 +435,8 @@ async def chat_web_search_handler(
{
"type": "status",
"data": {
"action": "web_search",
"description": "Searching the web",
"action": "web_search_queries_generated",
"queries": queries,
"done": False,
},
}
@@ -468,6 +487,7 @@ async def chat_web_search_handler(
"action": "web_search",
"description": "Searched {{count}} sites",
"urls": results["filenames"],
"items": results.get("items", []),
"done": True,
},
}
@@ -510,7 +530,7 @@ async def chat_image_generation_handler(
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generating an image", "done": False},
"data": {"description": "Creating image", "done": False},
}
)
@@ -562,7 +582,7 @@ async def chat_image_generation_handler(
await __event_emitter__(
{
"type": "status",
"data": {"description": "Generated an image", "done": True},
"data": {"description": "Image created", "done": True},
}
)
@@ -605,8 +625,9 @@ async def chat_image_generation_handler(
async def chat_completion_files_handler(
request: Request, body: dict, user: UserModel
request: Request, body: dict, extra_params: dict, user: UserModel
) -> tuple[dict, dict[str, list]]:
__event_emitter__ = extra_params["__event_emitter__"]
sources = []
if files := body.get("metadata", {}).get("files", None):
@@ -642,6 +663,17 @@ async def chat_completion_files_handler(
if len(queries) == 0:
queries = [get_last_user_message(body["messages"])]
await __event_emitter__(
{
"type": "status",
"data": {
"action": "queries_generated",
"queries": queries,
"done": False,
},
}
)
try:
# Offload get_sources_from_items to a separate thread
loop = asyncio.get_running_loop()
@@ -678,6 +710,38 @@ async def chat_completion_files_handler(
log.debug(f"rag_contexts:sources: {sources}")
unique_ids = set()
for source in sources or []:
if not source or len(source.keys()) == 0:
continue
documents = source.get("document") or []
metadatas = source.get("metadata") or []
src_info = source.get("source") or {}
for index, _ in enumerate(documents):
metadata = metadatas[index] if index < len(metadatas) else None
_id = (
(metadata or {}).get("source")
or (src_info or {}).get("id")
or "N/A"
)
unique_ids.add(_id)
sources_count = len(unique_ids)
await __event_emitter__(
{
"type": "status",
"data": {
"action": "sources_retrieved",
"count": sources_count,
"done": True,
},
}
)
return body, {"sources": sources}
@@ -689,6 +753,7 @@ def apply_params_to_form_data(form_data, model):
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"reasoning_tags": list,
"system": str,
}
@@ -740,13 +805,25 @@ async def process_chat_payload(request, form_data, user, metadata, model):
system_message = get_system_message(form_data.get("messages", []))
if system_message:
form_data = apply_system_prompt_to_body(
system_message.get("content"), form_data, metadata, user
)
try:
form_data = apply_system_prompt_to_body(
system_message.get("content"), form_data, metadata, user
)
except:
pass
event_emitter = get_event_emitter(metadata)
event_call = get_event_call(metadata)
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_call,
@@ -754,6 +831,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__metadata__": metadata,
"__request__": request,
"__model__": model,
"__oauth_token__": oauth_token,
}
# Initialize events to store additional event to be sent to the client
@@ -862,7 +940,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
extra_params=extra_params,
)
except Exception as e:
raise Exception(f"Error: {e}")
raise Exception(f"{e}")
features = form_data.pop("features", None)
if features:
@@ -958,7 +1036,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
log.exception(e)
try:
form_data, flags = await chat_completion_files_handler(request, form_data, user)
form_data, flags = await chat_completion_files_handler(
request, form_data, extra_params, user
)
sources.extend(flags.get("sources", []))
except Exception as e:
log.exception(e)
@@ -1261,111 +1341,134 @@ async def process_chat_response(
# Non-streaming response
if not isinstance(response, StreamingResponse):
if event_emitter:
if isinstance(response, dict) or isinstance(response, JSONResponse):
try:
if isinstance(response, dict) or isinstance(response, JSONResponse):
if isinstance(response, list) and len(response) == 1:
# If the response is a single-item list, unwrap it #17213
response = response[0]
if isinstance(response, JSONResponse) and isinstance(
response.body, bytes
):
try:
response_data = json.loads(response.body.decode("utf-8"))
except json.JSONDecodeError:
response_data = {"error": {"detail": "Invalid JSON response"}}
else:
response_data = response
if "error" in response_data:
error = response_data["error"].get("detail", response_data["error"])
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"error": {"content": error},
},
)
if "selected_model_id" in response_data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response_data["selected_model_id"],
},
)
choices = response_data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response_data["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response_data,
if isinstance(response, JSONResponse) and isinstance(
response.body, bytes
):
try:
response_data = json.loads(response.body.decode("utf-8"))
except json.JSONDecodeError:
response_data = {
"error": {"detail": "Invalid JSON response"}
}
)
else:
response_data = response
title = Chats.get_chat_title_by_id(metadata["chat_id"])
if "error" in response_data:
error = response_data.get("error")
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
},
}
)
if isinstance(error, dict):
error = error.get("detail", error)
else:
error = str(error)
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
"error": {"content": error},
},
)
if isinstance(error, str) or isinstance(error, dict):
await event_emitter(
{
"type": "chat:message:error",
"data": {"error": {"content": error}},
}
)
if "selected_model_id" in response_data:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"selectedModelId": response_data["selected_model_id"],
},
)
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
choices = response_data.get("choices", [])
if choices and choices[0].get("message", {}).get("content"):
content = response_data["choices"][0]["message"]["content"]
if content:
await event_emitter(
{
"type": "chat:completion",
"data": response_data,
}
)
title = Chats.get_chat_title_by_id(metadata["chat_id"])
await event_emitter(
{
"type": "chat:completion",
"data": {
"done": True,
"content": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
}
)
await background_tasks_handler()
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata["chat_id"],
metadata["message_id"],
{
"role": "assistant",
"content": content,
},
)
if events and isinstance(events, list):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
# Send a webhook notification if the user is not active
if not get_active_status_by_user_id(user.id):
webhook_url = Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
await post_webhook(
request.app.state.WEBUI_NAME,
webhook_url,
f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
{
"action": "chat",
"message": content,
"title": title,
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
},
)
response_data = {
**extra_response,
**response_data,
}
await background_tasks_handler()
if isinstance(response, dict):
response = response_data
if isinstance(response, JSONResponse):
response = JSONResponse(
content=response_data,
headers=response.headers,
status_code=response.status_code,
)
if events and isinstance(events, list):
extra_response = {}
for event in events:
if isinstance(event, dict):
extra_response.update(event)
else:
extra_response[event] = True
response_data = {
**extra_response,
**response_data,
}
if isinstance(response, dict):
response = response_data
if isinstance(response, JSONResponse):
response = JSONResponse(
content=response_data,
headers=response.headers,
status_code=response.status_code,
)
except Exception as e:
log.debug(f"Error occurred while processing request: {e}")
pass
return response
else:
@@ -1391,11 +1494,21 @@ async def process_chat_response(
):
return response
oauth_token = None
try:
oauth_token = request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
extra_params = {
"__event_emitter__": event_emitter,
"__event_call__": event_caller,
"__user__": user.model_dump() if isinstance(user, UserModel) else {},
"__metadata__": metadata,
"__oauth_token__": oauth_token,
"__request__": request,
"__model__": model,
}
@@ -1465,7 +1578,7 @@ async def process_chat_response(
tool_result_files = result.get("files", None)
break
if tool_result:
if tool_result is not None:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
else:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
@@ -1580,7 +1693,7 @@ async def process_chat_response(
{
"role": "tool",
"tool_call_id": result["tool_call_id"],
"content": result["content"],
"content": result.get("content", "") or "",
}
)
temp_blocks = []
@@ -1803,27 +1916,23 @@ async def process_chat_response(
}
]
# We might want to disable this by default
DETECT_REASONING = True
DETECT_SOLUTION = True
reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags")
DETECT_REASONING_TAGS = reasoning_tags_param is not False
DETECT_CODE_INTERPRETER = metadata.get("features", {}).get(
"code_interpreter", False
)
reasoning_tags = [
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<reason>", "</reason>"),
("<reasoning>", "</reasoning>"),
("<thought>", "</thought>"),
("<Thought>", "</Thought>"),
("<|begin_of_thought|>", "<|end_of_thought|>"),
("◁think▷", "◁/think▷"),
]
code_interpreter_tags = [("<code_interpreter>", "</code_interpreter>")]
solution_tags = [("<|begin_of_solution|>", "<|end_of_solution|>")]
reasoning_tags = []
if DETECT_REASONING_TAGS:
if (
isinstance(reasoning_tags_param, list)
and len(reasoning_tags_param) == 2
):
reasoning_tags = [
(reasoning_tags_param[0], reasoning_tags_param[1])
]
else:
reasoning_tags = DEFAULT_REASONING_TAGS
try:
for event in events:
@@ -1932,6 +2041,10 @@ async def process_chat_response(
}
)
usage = data.get("usage", {})
usage.update(
data.get("timing", {})
) # llama.cpp
if usage:
await event_emitter(
{
@@ -2075,7 +2188,7 @@ async def process_chat_response(
content_blocks[-1]["content"] + value
)
if DETECT_REASONING:
if DETECT_REASONING_TAGS:
content, content_blocks, _ = (
tag_content_handler(
"reasoning",
@@ -2085,11 +2198,20 @@ async def process_chat_response(
)
)
content, content_blocks, _ = (
tag_content_handler(
"solution",
DEFAULT_SOLUTION_TAGS,
content,
content_blocks,
)
)
if DETECT_CODE_INTERPRETER:
content, content_blocks, end = (
tag_content_handler(
"code_interpreter",
code_interpreter_tags,
DEFAULT_CODE_INTERPRETER_TAGS,
content,
content_blocks,
)
@@ -2098,16 +2220,6 @@ async def process_chat_response(
if end:
break
if DETECT_SOLUTION:
content, content_blocks, _ = (
tag_content_handler(
"solution",
solution_tags,
content,
content_blocks,
)
)
if ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
Chats.upsert_message_to_chat_by_id_and_message_id(
@@ -2182,10 +2294,12 @@ async def process_chat_response(
await stream_body_handler(response, form_data)
MAX_TOOL_CALL_RETRIES = 10
tool_call_retries = 0
while len(tool_calls) > 0 and tool_call_retries < MAX_TOOL_CALL_RETRIES:
while (
len(tool_calls) > 0
and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES
):
tool_call_retries += 1
@@ -2304,7 +2418,7 @@ async def process_chat_response(
results.append(
{
"tool_call_id": tool_call_id,
"content": tool_result,
"content": tool_result or "",
**(
{"files": tool_result_files}
if tool_result_files
@@ -2591,7 +2705,7 @@ async def process_chat_response(
await background_tasks_handler()
except asyncio.CancelledError:
log.warning("Task was cancelled!")
await event_emitter({"type": "task-cancelled"})
await event_emitter({"type": "chat:tasks:cancel"})
if not ENABLE_REALTIME_CHAT_SAVE:
# Save message in the database
+458 -169
View File
@@ -4,6 +4,11 @@ import mimetypes
import sys
import uuid
import json
from datetime import datetime, timedelta
import re
import fnmatch
import time
import aiohttp
from authlib.integrations.starlette_client import OAuth
@@ -14,8 +19,12 @@ from fastapi import (
)
from starlette.responses import RedirectResponse
from open_webui.models.auths import Auths
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
from open_webui.config import (
DEFAULT_USER_ROLE,
@@ -46,6 +55,7 @@ from open_webui.env import (
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
ENABLE_OAUTH_ID_TOKEN_COOKIE,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
@@ -79,15 +89,235 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
def is_in_blocked_groups(group_name: str, groups: list) -> bool:
"""
Check if a group name matches any blocked pattern.
Supports exact matches, shell-style wildcards (*, ?), and regex patterns.
Args:
group_name: The group name to check
groups: List of patterns to match against
Returns:
True if the group is blocked, False otherwise
"""
if not groups:
return False
for group_pattern in groups:
if not group_pattern: # Skip empty patterns
continue
# Exact match
if group_name == group_pattern:
return True
# Try as regex pattern first if it contains regex-specific characters
if any(
char in group_pattern
for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"]
):
try:
# Use the original pattern as-is for regex matching
if re.search(group_pattern, group_name):
return True
except re.error:
# If regex is invalid, fall through to wildcard check
pass
# Shell-style wildcard match (supports * and ?)
if "*" in group_pattern or "?" in group_pattern:
if fnmatch.fnmatch(group_name, group_pattern):
return True
return False
class OAuthManager:
def __init__(self, app):
self.oauth = OAuth()
self.app = app
self._clients = {}
for _, provider_config in OAUTH_PROVIDERS.items():
provider_config["register"](self.oauth)
def get_client(self, provider_name):
return self.oauth.create_client(provider_name)
if provider_name not in self._clients:
self._clients[provider_name] = self.oauth.create_client(provider_name)
return self._clients[provider_name]
def get_server_metadata_url(self, provider_name):
if provider_name in self._clients:
client = self._clients[provider_name]
return (
client.server_metadata_url
if hasattr(client, "server_metadata_url")
else None
)
return None
def get_oauth_token(
self, user_id: str, session_id: str, force_refresh: bool = False
):
"""
Get a valid OAuth token for the user, automatically refreshing if needed.
Args:
user_id: The user ID
provider: Optional provider name. If None, gets the most recent session.
force_refresh: Force token refresh even if current token appears valid
Returns:
dict: OAuth token data with access_token, or None if no valid token available
"""
try:
# Get the OAuth session
session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id)
if not session:
log.warning(
f"No OAuth session found for user {user_id}, session {session_id}"
)
return None
if force_refresh or datetime.now() + timedelta(
minutes=5
) >= datetime.fromtimestamp(session.expires_at):
log.debug(
f"Token refresh needed for user {user_id}, provider {session.provider}"
)
refreshed_token = self._refresh_token(session)
if refreshed_token:
return refreshed_token
else:
log.warning(
f"Token refresh failed for user {user_id}, provider {session.provider}"
)
return None
return session.token
except Exception as e:
log.error(f"Error getting OAuth token for user {user_id}: {e}")
return None
async def _refresh_token(self, session) -> dict:
"""
Refresh an OAuth token if needed, with concurrency protection.
Args:
session: The OAuth session object
Returns:
dict: Refreshed token data, or None if refresh failed
"""
try:
# Perform the actual refresh
refreshed_token = await self._perform_token_refresh(session)
if refreshed_token:
# Update the session with new token data
session = OAuthSessions.update_session_by_id(
session.id, refreshed_token
)
log.info(f"Successfully refreshed token for session {session.id}")
return session.token
else:
log.error(f"Failed to refresh token for session {session.id}")
return None
except Exception as e:
log.error(f"Error refreshing token for session {session.id}: {e}")
return None
async def _perform_token_refresh(self, session) -> dict:
"""
Perform the actual OAuth token refresh.
Args:
session: The OAuth session object
Returns:
dict: New token data, or None if refresh failed
"""
provider = session.provider
token_data = session.token
if not token_data.get("refresh_token"):
log.warning(f"No refresh token available for session {session.id}")
return None
try:
client = self.get_client(provider)
if not client:
log.error(f"No OAuth client found for provider {provider}")
return None
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(client.gserver_metadata_url) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint")
else:
log.error(
f"Failed to fetch OpenID configuration for provider {provider}"
)
if not token_endpoint:
log.error(f"No token endpoint found for provider {provider}")
return None
# Prepare refresh request
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": token_data["refresh_token"],
"client_id": client.client_id,
}
# Add client_secret if available (some providers require it)
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
token_endpoint,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status == 200:
new_token_data = await r.json()
# Merge with existing token data (preserve refresh_token if not provided)
if "refresh_token" not in new_token_data:
new_token_data["refresh_token"] = token_data[
"refresh_token"
]
# Add timestamp for tracking
new_token_data["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if (
"expires_in" in new_token_data
and "expires_at" not in new_token_data
):
new_token_data["expires_at"] = (
datetime.now().timestamp()
+ new_token_data["expires_in"]
)
log.debug(f"Token refresh successful for provider {provider}")
return new_token_data
else:
error_text = await r.text()
log.error(
f"Token refresh failed for provider {provider}: {r.status} - {error_text}"
)
return None
except Exception as e:
log.error(f"Exception during token refresh for provider {provider}: {e}")
return None
def get_user_role(self, user, user_data):
user_count = Users.get_num_users()
@@ -238,7 +468,7 @@ class OAuthManager:
if (
user_oauth_groups
and group_model.name not in user_oauth_groups
and group_model.name not in blocked_groups
and not is_in_blocked_groups(group_model.name, blocked_groups)
):
# Remove group from user
log.debug(
@@ -269,7 +499,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
and not is_in_blocked_groups(group_model.name, blocked_groups)
):
# Add user to group
log.debug(
@@ -354,185 +584,205 @@ class OAuthManager:
async def handle_callback(self, request, provider, response):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
client = self.get_client(provider)
error_message = None
try:
token = await client.authorize_access_token(request)
except Exception as e:
log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
user_data: UserInfo = token.get("userinfo")
if (
(not user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
client = self.get_client(provider)
try:
token = await client.authorize_access_token(request)
except Exception as e:
log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if auth_manager_config.OAUTH_SUB_CLAIM:
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
else:
# Fallback to the default sub claim if not configured
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
# Try to get userinfo from the token first, some providers include it there
user_data: UserInfo = token.get("userinfo")
if (
(not user_data)
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
):
user_data: UserInfo = await client.userinfo(token=token)
if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Extract the "sub" claim, using custom claim if configured
if auth_manager_config.OAUTH_SUB_CLAIM:
sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
else:
# Fallback to the default sub claim if not configured
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
if not sub:
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
provider_sub = f"{provider}@{sub}"
provider_sub = f"{provider}@{sub}"
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, "")
# We currently mandate that email addresses are provided
if not email:
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
if provider == "github":
try:
access_token = token.get("access_token")
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.github.com/user/emails",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
emails = await resp.json()
# use the primary email as the user's email
primary_email = next(
(e["email"] for e in emails if e.get("primary")),
None,
)
if primary_email:
email = primary_email
else:
log.warning(
"No primary email found in GitHub response"
# Email extraction
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
email = user_data.get(email_claim, "")
# We currently mandate that email addresses are provided
if not email:
# If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
if provider == "github":
try:
access_token = token.get("access_token")
headers = {"Authorization": f"Bearer {access_token}"}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://api.github.com/user/emails",
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.ok:
emails = await resp.json()
# use the primary email as the user's email
primary_email = next(
(
e["email"]
for e in emails
if e.get("primary")
),
None,
)
if primary_email:
email = primary_email
else:
log.warning(
"No primary email found in GitHub response"
)
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
else:
log.warning("Failed to fetch GitHub email")
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
else:
log.warning("Failed to fetch GitHub email")
raise HTTPException(
400, detail=ERROR_MESSAGES.INVALID_CRED
)
except Exception as e:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider_sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = Users.get_user_by_email(email)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
if not user:
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
except Exception as e:
log.warning(f"Error fetching GitHub email: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
else:
picture_url = "/user.png"
log.warning(f"OAuth callback failed, email is missing: {user_data}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
email = email.lower()
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
if not name:
log.warning("Username claim is missing, using email as name")
name = email
role = self.get_user_role(None, user_data)
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=role,
oauth_sub=provider_sub,
# If allowed domains are configured, check if the email domain is in the list
if (
"*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
and email.split("@")[-1]
not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
):
log.warning(
f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
)
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
# Check if the user exists
user = Users.get_user_by_oauth_sub(provider_sub)
if not user:
# If the user does not exist, check if merging is enabled
if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
# Check if the user exists by email
user = Users.get_user_by_email(email)
if user:
# Update the user with the new oauth sub
Users.update_user_oauth_sub_by_id(user.id, provider_sub)
if user:
determined_role = self.get_user_role(user, user_data)
if user.role != determined_role:
Users.update_user_role_by_id(user.id, determined_role)
# Update profile picture if enabled and different from current
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
new_picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
)
processed_picture_url = await self._process_picture_url(
new_picture_url, token.get("access_token")
)
if processed_picture_url != user.profile_image_url:
Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url
)
log.debug(f"Updated profile picture for user {user.email}")
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
# If the user does not exist, check if signups are enabled
if auth_manager_config.ENABLE_OAUTH_SIGNUP:
# Check if an existing user with the same email already exists
existing_user = Users.get_user_by_email(email)
if existing_user:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
if picture_claim:
picture_url = user_data.get(
picture_claim,
OAUTH_PROVIDERS[provider].get("picture_url", ""),
)
picture_url = await self._process_picture_url(
picture_url, token.get("access_token")
)
else:
picture_url = "/user.png"
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
name = user_data.get(username_claim)
if not name:
log.warning("Username claim is missing, using email as name")
name = email
user = Auths.insert_new_auth(
email=email,
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=name,
profile_image_url=picture_url,
role=self.get_user_role(None, user_data),
oauth_sub=provider_sub,
)
if auth_manager_config.WEBHOOK_URL:
await post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
else:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
jwt_token = create_token(
data={"id": user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if (
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT
and user.role != "admin"
):
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
)
jwt_token = create_token(
data={"id": user.id},
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
self.update_user_groups(
user=user,
user_data=user_data,
default_permissions=request.app.state.config.USER_PERMISSIONS,
except Exception as e:
log.error(f"Error during OAuth process: {e}")
error_message = (
e.detail
if isinstance(e, HTTPException) and e.detail
else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
)
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
@@ -540,6 +790,10 @@ class OAuthManager:
redirect_base_url = redirect_base_url[:-1]
redirect_url = f"{redirect_base_url}/auth"
if error_message:
redirect_url = f"{redirect_url}?error={error_message}"
return RedirectResponse(url=redirect_url, headers=response.headers)
response = RedirectResponse(url=redirect_url, headers=response.headers)
# Set the cookie token
@@ -552,13 +806,48 @@ class OAuthManager:
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if ENABLE_OAUTH_SIGNUP.value:
oauth_id_token = token.get("id_token")
# Legacy cookies for compatibility with older frontend versions
if ENABLE_OAUTH_ID_TOKEN_COOKIE:
response.set_cookie(
key="oauth_id_token",
value=oauth_id_token,
value=token.get("id_token"),
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
try:
# Add timestamp for tracking
token["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
# Clean up any existing sessions for this user/provider first
sessions = OAuthSessions.get_sessions_by_user_id(user.id)
for session in sessions:
if session.provider == provider:
OAuthSessions.delete_session_by_id(session.id)
session = OAuthSessions.create_session(
user_id=user.id,
provider=provider,
token=token,
)
response.set_cookie(
key="oauth_session_id",
value=session.id,
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
log.info(
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
)
except Exception as e:
log.error(f"Failed to store OAuth session server-side: {e}")
return response
+1
View File
@@ -63,6 +63,7 @@ def remove_open_webui_params(params: dict) -> dict:
"stream_response": bool,
"stream_delta_chunk_size": int,
"function_calling": str,
"reasoning_tags": list,
"system": str,
}
+41 -20
View File
@@ -119,18 +119,38 @@ async def get_tools(
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
token = None
cookies = {}
headers = {}
if auth_type == "bearer":
token = tool_server_connection.get("key", "")
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
token = request.state.token.credentials
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
def make_tool_function(function_name, token, tool_server_data):
headers["Content-Type"] = "application/json"
def make_tool_function(function_name, tool_server_data, headers):
async def tool_function(**kwargs):
return await execute_tool_server(
token=token,
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
@@ -139,7 +159,7 @@ async def get_tools(
return tool_function
tool_function = make_tool_function(
function_name, token, tool_server_data
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
@@ -489,14 +509,14 @@ async def get_tool_servers(request: Request):
if request.app.state.redis is not None:
try:
tool_servers = json.loads(await request.app.state.redis.get("tool_servers"))
request.app.state.TOOL_SERVERS = tool_servers
except Exception as e:
log.error(f"Error fetching tool_servers from Redis: {e}")
if not tool_servers:
await set_tool_servers(request)
tool_servers = await set_tool_servers(request)
request.app.state.TOOL_SERVERS = tool_servers
return request.app.state.TOOL_SERVERS
return tool_servers
async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
@@ -542,9 +562,7 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
return data
async def get_tool_servers_data(
servers: List[Dict[str, Any]], session_token: Optional[str] = None
) -> List[Dict[str, Any]]:
async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# Prepare list of enabled servers along with their original index
server_entries = []
for idx, server in enumerate(servers):
@@ -560,8 +578,9 @@ async def get_tool_servers_data(
if auth_type == "bearer":
token = server.get("key", "")
elif auth_type == "session":
token = session_token
elif auth_type == "none":
# No authentication
pass
id = info.get("id")
if not id:
@@ -610,7 +629,12 @@ async def get_tool_servers_data(
async def execute_tool_server(
token: str, url: str, name: str, params: Dict[str, Any], server_data: Dict[str, Any]
url: str,
headers: Dict[str, str],
cookies: Dict[str, str],
name: str,
params: Dict[str, Any],
server_data: Dict[str, Any],
) -> Any:
error = None
try:
@@ -671,11 +695,6 @@ async def execute_tool_server(
f"Request body expected for operation '{name}' but none found."
)
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
async with aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
) as session:
@@ -686,6 +705,7 @@ async def execute_tool_server(
final_url,
json=body_params,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) as response:
if response.status >= 400:
@@ -702,6 +722,7 @@ async def execute_tool_server(
async with request_method(
final_url,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
) as response:
if response.status >= 400:
+9 -10
View File
@@ -29,7 +29,7 @@ pymongo
redis
boto3==1.40.5
argon2-cffi==23.1.0
argon2-cffi==25.1.0
APScheduler==3.10.4
pycrdt==0.12.25
@@ -42,12 +42,12 @@ asgiref==3.8.1
# AI libraries
openai
anthropic
google-genai==1.28.0
google-genai==1.32.0
google-generativeai==0.8.5
tiktoken
langchain==0.3.26
langchain-community==0.3.26
langchain-community==0.3.27
fake-useragent==2.2.0
chromadb==0.6.3
@@ -65,12 +65,12 @@ transformers
sentence-transformers==4.1.0
accelerate
colbert-ai==0.2.21
pyarrow==20.0.0
einops==0.8.1
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1
ftfy==6.2.3
pypdf==4.3.1
pypdf==6.0.0
fpdf2==2.8.2
pymdown-extensions==10.14.2
docx2txt==0.8
@@ -99,11 +99,10 @@ onnxruntime==1.20.1
faster-whisper==1.1.1
PyJWT[crypto]==2.10.1
authlib==1.6.1
authlib==1.6.3
black==25.1.0
langfuse==2.44.0
youtube-transcript-api==1.1.0
youtube-transcript-api==1.2.2
pytube==15.0.0
pydub
@@ -116,7 +115,7 @@ google-auth-oauthlib
## Tests
docker~=7.1.0
pytest~=8.3.5
pytest~=8.4.1
pytest-docker~=3.1.1
googleapis-common-protos==1.63.2
+2 -2
View File
@@ -53,12 +53,12 @@ if [ -n "$SPACE_ID" ]; then
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' &
webui_pid=$!
echo "Waiting for webui to start..."
while ! curl -s http://localhost:8080/health > /dev/null; do
while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do
sleep 1
done
echo "Creating admin user..."
curl \
-X POST "http://localhost:8080/api/v1/auths/signup" \
-X POST "http://localhost:${PORT}/api/v1/auths/signup" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }"
+99 -78
View File
@@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.6.23",
"version": "0.6.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.6.23",
"version": "0.6.27",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@@ -26,7 +26,7 @@
"@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.0.7",
"@tiptap/extension-highlight": "^3.3.0",
"@tiptap/extension-image": "^3.0.7",
"@tiptap/extension-link": "^3.0.7",
"@tiptap/extension-list": "^3.0.7",
@@ -46,7 +46,7 @@
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"dompurify": "^3.2.5",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
@@ -66,10 +66,10 @@
"leaflet": "^1.9.4",
"lowlight": "^3.3.0",
"marked": "^9.1.0",
"mermaid": "^11.6.0",
"mermaid": "^11.10.1",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
"pdfjs-dist": "^5.3.93",
"pdfjs-dist": "^5.4.149",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3",
@@ -82,10 +82,11 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3",
"pyodide": "^0.28.2",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
@@ -2224,9 +2225,9 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.4.0.tgz",
"integrity": "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz",
"integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
@@ -2238,9 +2239,9 @@
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.73.tgz",
"integrity": "sha512-9iwPZrNlCK4rG+vWyDvyvGeYjck9MoP0NVQP6N60gqJNFA1GsN0imG05pzNsqfCvFxUxgiTYlR8ff0HC1HXJiw==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.78.tgz",
"integrity": "sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==",
"license": "MIT",
"optional": true,
"workspaces": [
@@ -2250,22 +2251,22 @@
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.73",
"@napi-rs/canvas-darwin-arm64": "0.1.73",
"@napi-rs/canvas-darwin-x64": "0.1.73",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.73",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.73",
"@napi-rs/canvas-linux-arm64-musl": "0.1.73",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.73",
"@napi-rs/canvas-linux-x64-gnu": "0.1.73",
"@napi-rs/canvas-linux-x64-musl": "0.1.73",
"@napi-rs/canvas-win32-x64-msvc": "0.1.73"
"@napi-rs/canvas-android-arm64": "0.1.78",
"@napi-rs/canvas-darwin-arm64": "0.1.78",
"@napi-rs/canvas-darwin-x64": "0.1.78",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.78",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.78",
"@napi-rs/canvas-linux-arm64-musl": "0.1.78",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.78",
"@napi-rs/canvas-linux-x64-gnu": "0.1.78",
"@napi-rs/canvas-linux-x64-musl": "0.1.78",
"@napi-rs/canvas-win32-x64-msvc": "0.1.78"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.73.tgz",
"integrity": "sha512-s8dMhfYIHVv7gz8BXg3Nb6cFi950Y0xH5R/sotNZzUVvU9EVqHfkqiGJ4UIqu+15UhqguT6mI3Bv1mhpRkmMQw==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.78.tgz",
"integrity": "sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==",
"cpu": [
"arm64"
],
@@ -2279,9 +2280,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.73.tgz",
"integrity": "sha512-bLPCq8Yyq1vMdVdIpQAqmgf6VGUknk8e7NdSZXJJFOA9gxkJ1RGcHOwoXo7h0gzhHxSorg71hIxyxtwXpq10Rw==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.78.tgz",
"integrity": "sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==",
"cpu": [
"arm64"
],
@@ -2295,9 +2296,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.73.tgz",
"integrity": "sha512-GR1CcehDjdNYXN3bj8PIXcXfYLUUOQANjQpM+KNnmpRo7ojsuqPjT7ZVH+6zoG/aqRJWhiSo+ChQMRazZlRU9g==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.78.tgz",
"integrity": "sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==",
"cpu": [
"x64"
],
@@ -2311,9 +2312,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.73.tgz",
"integrity": "sha512-cM7F0kBJVFio0+U2iKSW4fWSfYQ8CPg4/DRZodSum/GcIyfB8+UPJSRM1BvvlcWinKLfX1zUYOwonZX9IFRRcw==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.78.tgz",
"integrity": "sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==",
"cpu": [
"arm"
],
@@ -2327,9 +2328,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.73.tgz",
"integrity": "sha512-PMWNrMON9uz9klz1B8ZY/RXepQSC5dxxHQTowfw93Tb3fLtWO5oNX2k9utw7OM4ypT9BUZUWJnDQ5bfuXc/EUQ==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.78.tgz",
"integrity": "sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==",
"cpu": [
"arm64"
],
@@ -2343,9 +2344,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.73.tgz",
"integrity": "sha512-lX0z2bNmnk1PGZ+0a9OZwI2lPPvWjRYzPqvEitXX7lspyLFrOzh2kcQiLL7bhyODN23QvfriqwYqp5GreSzVvA==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.78.tgz",
"integrity": "sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==",
"cpu": [
"arm64"
],
@@ -2359,9 +2360,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.73.tgz",
"integrity": "sha512-QDQgMElwxAoADsSR3UYvdTTQk5XOyD9J5kq15Z8XpGwpZOZsSE0zZ/X1JaOtS2x+HEZL6z1S6MF/1uhZFZb5ig==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.78.tgz",
"integrity": "sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==",
"cpu": [
"riscv64"
],
@@ -2375,9 +2376,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.73.tgz",
"integrity": "sha512-wbzLJrTalQrpyrU1YRrO6w6pdr5vcebbJa+Aut5QfTaW9eEmMb1WFG6l1V+cCa5LdHmRr8bsvl0nJDU/IYDsmw==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.78.tgz",
"integrity": "sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==",
"cpu": [
"x64"
],
@@ -2391,9 +2392,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.73.tgz",
"integrity": "sha512-xbfhYrUufoTAKvsEx2ZUN4jvACabIF0h1F5Ik1Rk4e/kQq6c+Dwa5QF0bGrfLhceLpzHT0pCMGMDeQKQrcUIyA==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.78.tgz",
"integrity": "sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==",
"cpu": [
"x64"
],
@@ -2407,9 +2408,9 @@
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.73",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.73.tgz",
"integrity": "sha512-YQmHXBufFBdWqhx+ympeTPkMfs3RNxaOgWm59vyjpsub7Us07BwCcmu1N5kildhO8Fm0syoI2kHnzGkJBLSvsg==",
"version": "0.1.78",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.78.tgz",
"integrity": "sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==",
"cpu": [
"x64"
],
@@ -3516,16 +3517,16 @@
}
},
"node_modules/@tiptap/extension-highlight": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.0.7.tgz",
"integrity": "sha512-3oIRuXAg7l9+VPIMwHycXcqtZ7XJcC5vnLhPAQXIesYun6L9EoXmQox0225z8jpPG70N8zfl+YSd4qjsTMPaAg==",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.3.0.tgz",
"integrity": "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.0.7"
"@tiptap/core": "^3.3.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
@@ -6767,9 +6768,9 @@
}
},
"node_modules/dompurify": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -9577,14 +9578,14 @@
}
},
"node_modules/mermaid": {
"version": "11.6.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.6.0.tgz",
"integrity": "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==",
"version": "11.10.1",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz",
"integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.0.4",
"@iconify/utils": "^2.1.33",
"@mermaid-js/parser": "^0.4.0",
"@mermaid-js/parser": "^0.6.2",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"cytoscape-cose-bilkent": "^4.1.0",
@@ -9593,11 +9594,11 @@
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.11",
"dayjs": "^1.11.13",
"dompurify": "^3.2.4",
"katex": "^0.16.9",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^15.0.7",
"marked": "^16.0.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
@@ -9605,15 +9606,15 @@
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "15.0.8",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.8.tgz",
"integrity": "sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==",
"version": "16.2.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz",
"integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
}
},
"node_modules/mermaid/node_modules/uuid": {
@@ -10252,15 +10253,15 @@
}
},
"node_modules/pdfjs-dist": {
"version": "5.3.93",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.3.93.tgz",
"integrity": "sha512-w3fQKVL1oGn8FRyx5JUG5tnbblggDqyx2XzA5brsJ5hSuS+I0NdnJANhmeWKLjotdbPQucLBug5t0MeWr0AAdg==",
"version": "5.4.149",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz",
"integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.71"
"@napi-rs/canvas": "^0.1.77"
}
},
"node_modules/pend": {
@@ -10905,9 +10906,9 @@
}
},
"node_modules/pyodide": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.27.7.tgz",
"integrity": "sha512-RUSVJlhQdfWfgO9hVHCiXoG+nVZQRS5D9FzgpLJ/VcgGBLSAKoPL8kTiOikxbHQm1kRISeWUBdulEgO26qpSRA==",
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.2.tgz",
"integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==",
"license": "MPL-2.0",
"dependencies": {
"ws": "^8.5.0"
@@ -12502,6 +12503,26 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
}
},
"node_modules/svelte-tiptap": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/svelte-tiptap/-/svelte-tiptap-3.0.0.tgz",
"integrity": "sha512-digFHOJe16RX0HIU+u8hOaCS9sIgktTpYHSF9yJ6dgxPv/JWJdYCdwoX65lcHitFhhCG7xnolJng6PJa9M9h3w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-bubble-menu": "^3.0.0",
"@tiptap/extension-floating-menu": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"svelte": "^5.0.0"
}
},
"node_modules/svelte/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.6.23",
"version": "0.6.27",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@@ -70,7 +70,7 @@
"@tiptap/extension-drag-handle": "^3.0.7",
"@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.0.7",
"@tiptap/extension-highlight": "^3.3.0",
"@tiptap/extension-image": "^3.0.7",
"@tiptap/extension-link": "^3.0.7",
"@tiptap/extension-list": "^3.0.7",
@@ -90,7 +90,7 @@
"codemirror-lang-hcl": "^0.1.0",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"dompurify": "^3.2.5",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
@@ -110,10 +110,10 @@
"leaflet": "^1.9.4",
"lowlight": "^3.3.0",
"marked": "^9.1.0",
"mermaid": "^11.6.0",
"mermaid": "^11.10.1",
"paneforge": "^0.0.6",
"panzoom": "^9.4.3",
"pdfjs-dist": "^5.3.93",
"pdfjs-dist": "^5.4.149",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.0",
"prosemirror-example-setup": "^1.2.3",
@@ -126,10 +126,11 @@
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.27.3",
"pyodide": "^0.28.2",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6",
"svelte-sonner": "^0.3.19",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
"turndown-plugin-gfm": "^1.0.2",
+31 -28
View File
@@ -15,6 +15,10 @@ dependencies = [
"python-jose==3.4.0",
"passlib[bcrypt]==1.7.4",
"cryptography",
"bcrypt==4.3.0",
"argon2-cffi==23.1.0",
"PyJWT[crypto]==2.10.1",
"authlib==1.6.3",
"requests==2.32.4",
"aiohttp==3.12.15",
@@ -28,34 +32,27 @@ dependencies = [
"alembic==1.14.0",
"peewee==3.18.1",
"peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9",
"pgvector==0.4.0",
"PyMySQL==1.1.1",
"bcrypt==4.3.0",
"pymongo",
"redis",
"boto3==1.40.5",
"argon2-cffi==23.1.0",
"APScheduler==3.10.4",
"pycrdt==0.12.25",
"redis",
"PyMySQL==1.1.1",
"boto3==1.40.5",
"APScheduler==3.10.4",
"RestrictedPython==8.0",
"loguru==0.7.3",
"asgiref==3.8.1",
"tiktoken",
"openai",
"anthropic",
"google-genai==1.28.0",
"google-genai==1.32.0",
"google-generativeai==0.8.5",
"tiktoken",
"langchain==0.3.26",
"langchain-community==0.3.26",
"langchain-community==0.3.27",
"fake-useragent==2.2.0",
"chromadb==0.6.3",
@@ -75,7 +72,7 @@ dependencies = [
"einops==0.8.1",
"ftfy==6.2.3",
"pypdf==4.3.1",
"pypdf==6.0.0",
"fpdf2==2.8.2",
"pymdown-extensions==10.14.2",
"docx2txt==0.8",
@@ -100,14 +97,9 @@ dependencies = [
"rank-bm25==0.2.2",
"onnxruntime==1.20.1",
"faster-whisper==1.1.1",
"PyJWT[crypto]==2.10.1",
"authlib==1.6.1",
"black==25.1.0",
"langfuse==2.44.0",
"youtube-transcript-api==1.1.0",
"pytube==15.0.0",
@@ -118,9 +110,7 @@ dependencies = [
"google-auth-httplib2",
"google-auth-oauthlib",
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.1.1",
"googleapis-common-protos==1.63.2",
"google-cloud-storage==2.19.0",
@@ -131,12 +121,8 @@ dependencies = [
"ldap3==2.9.1",
"firecrawl-py==1.12.0",
"tencentcloud-sdk-python==3.0.1336",
"gcp-storage-emulator>=2024.8.3",
"moto[s3]>=5.0.26",
"oracledb>=3.2.0",
"posthog==5.4.0",
@@ -154,6 +140,23 @@ classifiers = [
"Topic :: Multimedia",
]
[project.optional-dependencies]
postgres = [
"psycopg2-binary==2.9.9",
"pgvector==0.4.0",
]
all = [
"pymongo",
"psycopg2-binary==2.9.9",
"pgvector==0.4.0",
"moto[s3]>=5.0.26",
"gcp-storage-emulator>=2024.8.3",
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.1.1",
]
[project.scripts]
open-webui = "open_webui:app"
+8
View File
@@ -282,6 +282,14 @@ input[type='number'] {
outline: none;
}
.cm-gutters {
@apply !bg-white dark:!bg-black !border-none;
}
.cm-editor {
@apply bg-white dark:bg-black;
}
.tippy-box[data-theme~='dark'] {
@apply rounded-lg bg-gray-950 text-xs border border-gray-900 shadow-xl;
}
+30 -8
View File
@@ -2,14 +2,35 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="icon" type="image/png" href="/static/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="shortcut icon" href="/static/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/static/favicon.png" crossorigin="use-credentials" />
<link
rel="icon"
type="image/png"
href="/static/favicon-96x96.png"
sizes="96x96"
crossorigin="use-credentials"
/>
<link
rel="icon"
type="image/svg+xml"
href="/static/favicon.svg"
crossorigin="use-credentials"
/>
<link rel="shortcut icon" href="/static/favicon.ico" crossorigin="use-credentials" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/static/apple-touch-icon.png"
crossorigin="use-credentials"
/>
<meta name="apple-mobile-web-app-title" content="Open WebUI" />
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link
rel="manifest"
href="/manifest.json"
crossorigin="use-credentials"
crossorigin="use-credentials"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
@@ -22,9 +43,10 @@
type="application/opensearchdescription+xml"
title="Open WebUI"
href="/opensearch.xml"
crossorigin="use-credentials"
/>
<script src="/static/loader.js" defer></script>
<link rel="stylesheet" href="/static/custom.css" />
<script src="/static/loader.js" defer crossorigin="use-credentials"></script>
<link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />
<script>
function resizeIframe(obj) {
+2 -1
View File
@@ -1,8 +1,9 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
type FolderForm = {
name: string;
name?: string;
data?: Record<string, any>;
meta?: Record<string, any>;
};
export const createNewFolder = async (token: string, folderForm: FolderForm) => {
+22 -9
View File
@@ -347,25 +347,31 @@ export const getToolServerData = async (token: string, url: string) => {
return data;
};
export const getToolServersData = async (i18n, servers: object[]) => {
export const getToolServersData = async (servers: object[]) => {
return (
await Promise.all(
servers
.filter((server) => server?.config?.enable)
.map(async (server) => {
let error = null;
let toolServerToken = null;
const auth_type = server?.auth_type ?? 'bearer';
if (auth_type === 'bearer') {
toolServerToken = server?.key;
} else if (auth_type === 'none') {
// No authentication
} else if (auth_type === 'session') {
toolServerToken = localStorage.token;
}
const data = await getToolServerData(
(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
toolServerToken,
(server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
).catch((err) => {
toast.error(
$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
URL: (server?.path ?? '').includes('://')
? server?.path
: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`
})
);
error = err;
return null;
});
@@ -377,6 +383,13 @@ export const getToolServersData = async (i18n, servers: object[]) => {
info: info,
specs: specs
};
} else if (error) {
return {
error,
url: server?.url
};
} else {
return null;
}
})
)
+1
View File
@@ -372,6 +372,7 @@ export const generateOpenAIChatCompletion = async (
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(body)
})
.then(async (res) => {
+111 -42
View File
@@ -31,6 +31,7 @@
let url = '';
let key = '';
let auth_type = 'bearer';
let connectionType = 'external';
let azure = false;
@@ -74,6 +75,7 @@
url,
key,
config: {
auth_type,
azure: azure,
api_version: apiVersion
}
@@ -146,6 +148,7 @@
prefix_id: prefixId,
model_ids: modelIds,
connection_type: connectionType,
auth_type,
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
}
};
@@ -157,6 +160,7 @@
url = '';
key = '';
auth_type = 'bearer';
prefixId = '';
tags = [];
modelIds = [];
@@ -167,6 +171,8 @@
url = connection.url;
key = connection.key;
auth_type = connection.config.auth_type ?? 'bearer';
enable = connection.config?.enable ?? true;
tags = connection.config?.tags ?? [];
prefixId = connection.config?.prefix_id ?? '';
@@ -305,23 +311,63 @@
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
<label
for="select-bearer-or-session"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Auth')}</label
>
{$i18n.t('Key')}
</div>
<div class="flex-1">
<SensitiveInput
inputClassName={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
<div class="flex gap-2">
<div class="flex-shrink-0 self-start">
<select
id="select-bearer-or-session"
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
{#if !ollama}
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
{/if}
</select>
</div>
<div class="flex flex-1 items-center">
{#if auth_type === 'bearer'}
<SensitiveInput
bind:value={key}
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<label
for="prefix-id-input"
@@ -349,6 +395,29 @@
</div>
</div>
{#if !ollama && !direct}
<div class="flex flex-row justify-between items-center w-full mt-2">
<label
for="prefix-id-input"
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>{$i18n.t('Provider Type')}</label
>
<div>
<button
on:click={() => {
azure = !azure;
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{azure ? $i18n.t('Azure OpenAI') : $i18n.t('OpenAI')}
</button>
</div>
</div>
{/if}
{#if azure}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
@@ -374,36 +443,6 @@
</div>
{/if}
<div class="flex gap-2 mt-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="flex flex-col w-full">
<div class="mb-1 flex justify-between">
<div
@@ -489,6 +528,36 @@
</div>
</div>
<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" />
<div class="flex gap-2">
<div class="flex flex-col w-full">
<div
class={`mb-0.5 text-xs text-gray-500
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
>
{$i18n.t('Tags')}
</div>
<div class="flex-1 mt-0.5">
<Tags
bind:tags
on:add={(e) => {
tags = [
...tags,
{
name: e.detail
}
];
}}
on:delete={(e) => {
tags = tags.filter((tag) => tag.name !== e.detail);
}}
/>
</div>
</div>
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit}
<button
+18
View File
@@ -283,8 +283,14 @@
class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
bind:value={auth_type}
>
<option value="none">{$i18n.t('None')}</option>
<option value="bearer">{$i18n.t('Bearer')}</option>
<option value="session">{$i18n.t('Session')}</option>
{#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option>
{/if}
</select>
</div>
@@ -295,12 +301,24 @@
placeholder={$i18n.t('API Key')}
required={false}
/>
{:else if auth_type === 'none'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('No authentication')}
</div>
{:else if auth_type === 'session'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user session credentials to authenticate')}
</div>
{:else if auth_type === 'system_oauth'}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Forwards system user OAuth access token to authenticate')}
</div>
{/if}
</div>
</div>
+1 -1
View File
@@ -204,7 +204,7 @@
/>
</svg>
</div>
<div class=" self-center">{$i18n.t('Tools')}</div>
<div class=" self-center">{$i18n.t('External Tools')}</div>
</button>
<button
@@ -261,10 +261,10 @@
<div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection
pipeline={pipelineUrls[url] ? true : false}
bind:url
bind:key={OPENAI_API_KEYS[idx]}
{url}
key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]}
pipeline={pipelineUrls[url] ? true : false}
onSubmit={() => {
updateOpenAIHandler();
}}
@@ -326,7 +326,7 @@
<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection
bind:url
{url}
bind:config={OLLAMA_API_CONFIGS[idx]}
{idx}
onSubmit={() => {
@@ -71,6 +71,7 @@
class="w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')}
bind:value={url}
readonly={true}
/>
</Tooltip>
@@ -69,6 +69,7 @@
placeholder={$i18n.t('API Base URL')}
bind:value={url}
autocomplete="off"
readonly={true}
/>
{#if pipeline}
@@ -94,13 +95,6 @@
</div>
{/if}
</div>
<SensitiveInput
inputClassName=" outline-hidden bg-transparent w-full"
placeholder={$i18n.t('API Key')}
required={false}
bind:value={key}
/>
</div>
</Tooltip>
@@ -153,6 +153,7 @@
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
RAGConfig.DOCLING_DO_OCR &&
((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') ||
(RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === ''))
) {
@@ -161,6 +162,14 @@
);
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
RAGConfig.DOCLING_DO_OCR === false &&
RAGConfig.DOCLING_FORCE_OCR === true
) {
toast.error($i18n.t('In order to force OCR, performing OCR must be enabled.'));
return;
}
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' &&
@@ -185,10 +194,9 @@
if (
RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence' &&
(RAGConfig.DOCUMENT_INTELLIGENCE_ENDPOINT === '' ||
RAGConfig.DOCUMENT_INTELLIGENCE_KEY === '')
RAGConfig.DOCUMENT_INTELLIGENCE_ENDPOINT === ''
) {
toast.error($i18n.t('Document Intelligence endpoint and key required.'));
toast.error($i18n.t('Document Intelligence endpoint required.'));
return;
}
if (
@@ -546,19 +554,91 @@
bind:value={RAGConfig.DOCLING_SERVER_URL}
/>
</div>
<div class="flex w-full mt-2">
<input
class="flex-1 w-full 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 text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
bind:value={RAGConfig.DOCLING_OCR_LANG}
/>
</div>
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Perform OCR')}
</div>
<div class="flex items-center relative">
<Switch bind:state={RAGConfig.DOCLING_DO_OCR} />
</div>
</div>
</div>
{#if RAGConfig.DOCLING_DO_OCR}
<div class="flex w-full mt-2">
<input
class="flex-1 w-full 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 text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Enter Docling OCR Language(s)')}
bind:value={RAGConfig.DOCLING_OCR_LANG}
/>
</div>
{/if}
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Force OCR')}
</div>
<div class="flex items-center relative">
<Switch bind:state={RAGConfig.DOCLING_FORCE_OCR} />
</div>
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('PDF Backend')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DOCLING_PDF_BACKEND}
>
<option value="pypdfium2">{$i18n.t('pypdfium2')}</option>
<option value="dlparse_v1">{$i18n.t('dlparse_v1')}</option>
<option value="dlparse_v2">{$i18n.t('dlparse_v2')}</option>
<option value="dlparse_v4">{$i18n.t('dlparse_v4')}</option>
</select>
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('Table Mode')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DOCLING_TABLE_MODE}
>
<option value="fast">{$i18n.t('fast')}</option>
<option value="accurate">{$i18n.t('accurate')}</option>
</select>
</div>
</div>
<div class="flex justify-between w-full mt-2">
<div class="self-center text-xs font-medium">
<Tooltip content={''} placement="top-start">
{$i18n.t('Pipeline')}
</Tooltip>
</div>
<div class="">
<select
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
bind:value={RAGConfig.DOCLING_PIPELINE}
>
<option value="standard">{$i18n.t('standard')}</option>
<option value="vlm">{$i18n.t('vlm')}</option>
</select>
</div>
</div>
<div class="flex w-full mt-2">
<div class="flex-1 flex justify-between">
<div class=" self-center text-xs font-medium">
@@ -644,6 +724,7 @@
<SensitiveInput
placeholder={$i18n.t('Enter Document Intelligence Key')}
bind:value={RAGConfig.DOCUMENT_INTELLIGENCE_KEY}
required={false}
/>
</div>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'mistral_ocr'}
@@ -1104,10 +1185,10 @@
<div class="py-0.5">
<div class="flex w-full justify-between">
<div class=" text-left text-xs font-small">
{$i18n.t('lexical')}
{$i18n.t('semantic')}
</div>
<div class=" text-right text-xs font-small">
{$i18n.t('semantic')}
{$i18n.t('lexical')}
</div>
</div>
</div>
+49 -25
View File
@@ -599,20 +599,42 @@
{/if}
{:else if config?.engine === 'openai'}
<div>
<div class=" mb-1.5 text-sm font-medium">{$i18n.t('OpenAI API Config')}</div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('OpenAI API Config')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<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('API Base URL')}
bind:value={config.openai.OPENAI_API_BASE_URL}
required
/>
</div>
</div>
</div>
<div class="flex gap-2 mb-1">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('API Base URL')}
bind:value={config.openai.OPENAI_API_BASE_URL}
required
/>
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('API Key')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={config.openai.OPENAI_API_KEY}
required
/>
</div>
</div>
</div>
<SensitiveInput
placeholder={$i18n.t('API Key')}
bind:value={config.openai.OPENAI_API_KEY}
/>
<div>
<div class=" mb-2 text-sm font-medium">{$i18n.t('API Version')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<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('API Version')}
bind:value={config.openai.OPENAI_API_VERSION}
/>
</div>
</div>
</div>
{:else if config?.engine === 'gemini'}
@@ -682,21 +704,23 @@
</div>
</div>
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Steps')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<Tooltip content={$i18n.t('Enter Number of Steps (e.g. 50)')} placement="top-start">
<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 Number of Steps (e.g. 50)')}
bind:value={imageGenerationConfig.IMAGE_STEPS}
required
/>
</Tooltip>
{#if ['comfyui', 'automatic1111', ''].includes(config?.engine)}
<div>
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Steps')}</div>
<div class="flex w-full">
<div class="flex-1 mr-2">
<Tooltip content={$i18n.t('Enter Number of Steps (e.g. 50)')} placement="top-start">
<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 Number of Steps (e.g. 50)')}
bind:value={imageGenerationConfig.IMAGE_STEPS}
required
/>
</Tooltip>
</div>
</div>
</div>
</div>
{/if}
{/if}
{/if}
</div>
@@ -71,6 +71,10 @@
params: true,
file_upload: true,
delete: true,
delete_message: true,
continue_response: true,
regenerate_response: true,
rate_response: true,
edit: true,
share: true,
export: true,
@@ -53,6 +53,10 @@
params: true,
file_upload: true,
delete: true,
delete_message: true,
continue_response: true,
regenerate_response: true,
rate_response: true,
edit: true,
share: true,
export: true,
@@ -1,6 +1,7 @@
<script>
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { page } from '$app/stores';
const i18n = getContext('i18n');
@@ -10,7 +11,6 @@
import User from '$lib/components/icons/User.svelte';
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
import GroupModal from './EditGroupModal.svelte';
import { querystringValue } from '$lib/utils';
export let users = [];
export let group = {
@@ -47,7 +47,7 @@
};
onMount(() => {
const groupId = querystringValue('id');
const groupId = $page.url.searchParams.get('id');
if (groupId && groupId === group.id) {
showEdit = true;
}
@@ -26,6 +26,10 @@
params: true,
file_upload: true,
delete: true,
delete_message: true,
continue_response: true,
regenerate_response: true,
rate_response: true,
edit: true,
share: true,
export: true,
@@ -292,6 +296,14 @@
</div>
{/if}
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
</div>
<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 Delete')}
@@ -302,10 +314,34 @@
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Chat Edit')}
{$i18n.t('Allow Delete Messages')}
</div>
<Switch bind:state={permissions.chat.edit} />
<Switch bind:state={permissions.chat.delete_message} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Continue Response')}
</div>
<Switch bind:state={permissions.chat.continue_response} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Regenerate Response')}
</div>
<Switch bind:state={permissions.chat.regenerate_response} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Allow Rate Response')}
</div>
<Switch bind:state={permissions.chat.rate_response} />
</div>
<div class=" flex w-full justify-between my-2 pr-2">
@@ -1,7 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import heic2any from 'heic2any';
import { tick, getContext, onMount, onDestroy } from 'svelte';
@@ -9,7 +8,7 @@
import { config, mobile, settings, socket, user } from '$lib/stores';
import {
blobToFile,
convertHeicToJpeg,
compressImage,
extractInputVariables,
getAge,
@@ -377,11 +376,7 @@
];
};
reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
} else {
uploadFileHandler(file);
}
@@ -30,9 +30,10 @@
import ProfilePreview from './Message/ProfilePreview.svelte';
import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubble.svelte';
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import ReactionPicker from './Message/ReactionPicker.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils';
import Emoji from '$lib/components/common/Emoji.svelte';
export let message;
export let showUserProfile = true;
@@ -74,7 +75,7 @@
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-850"
>
<ReactionPicker
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
@@ -91,7 +92,7 @@
<FaceSmile />
</button>
</Tooltip>
</ReactionPicker>
</EmojiPicker>
{#if !thread}
<Tooltip content={$i18n.t('Reply in Thread')}>
@@ -275,20 +276,7 @@
onReaction(reaction.name);
}}
>
{#if $shortCodesToEmojis[reaction.name]}
<img
src="{WEBUI_BASE_URL}/assets/emojis/{$shortCodesToEmojis[
reaction.name
].toLowerCase()}.svg"
alt={reaction.name}
class=" size-4"
loading="lazy"
/>
{:else}
<div>
{reaction.name}
</div>
{/if}
<Emoji shortCode={reaction.name} />
{#if reaction.user_ids.length > 0}
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
@@ -299,7 +287,7 @@
</Tooltip>
{/each}
<ReactionPicker
<EmojiPicker
onSubmit={(name) => {
onReaction(name);
}}
@@ -311,7 +299,7 @@
<FaceSmile />
</div>
</Tooltip>
</ReactionPicker>
</EmojiPicker>
</div>
</div>
{/if}
+4 -1
View File
@@ -204,7 +204,10 @@
});
</script>
<div class=" w-full h-full relative flex flex-col bg-gray-50 dark:bg-gray-850">
<div
class=" w-full h-full relative flex flex-col bg-gray-50 dark:bg-gray-850"
id="artifacts-container"
>
<div class="w-full h-full flex flex-col flex-1 relative">
{#if contents.length > 0}
<div
+80 -37
View File
@@ -50,7 +50,6 @@
removeAllDetails
} from '$lib/utils';
import { generateChatCompletion } from '$lib/apis/ollama';
import {
createNewChat,
getAllTags,
@@ -63,8 +62,6 @@
} from '$lib/apis/chats';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
import { createOpenAITextStream } from '$lib/apis/streaming';
import { queryMemory } from '$lib/apis/memories';
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
import {
chatCompleted,
@@ -75,6 +72,10 @@
getTaskIdsByChatId
} from '$lib/apis';
import { getTools } from '$lib/apis/tools';
import { uploadFile } from '$lib/apis/files';
import { createOpenAITextStream } from '$lib/apis/streaming';
import { fade } from 'svelte/transition';
import Banner from '../common/Banner.svelte';
import MessageInput from '$lib/components/chat/MessageInput.svelte';
@@ -85,10 +86,8 @@
import Placeholder from './Placeholder.svelte';
import NotificationToast from '../NotificationToast.svelte';
import Spinner from '../common/Spinner.svelte';
import { fade } from 'svelte/transition';
import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte';
import { uploadFile } from '$lib/apis/files';
export let chatIdProp = '';
@@ -203,7 +202,12 @@
if (type === 'prompt') {
// Handle prompt selection
messageInput?.setText(data);
messageInput?.setText(data, async () => {
if (!($settings?.insertSuggestionPrompt ?? false)) {
await tick();
submitPrompt(prompt);
}
});
}
};
@@ -318,12 +322,21 @@
}
} else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:tasks:cancel') {
taskIds = null;
const responseMessage = history.messages[history.currentId];
// Set all response messages to done
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
history.messages[messageId].done = true;
}
} 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:message:error') {
message.error = data.error;
} else if (type === 'chat:message:follow_ups') {
message.followUps = data.follow_ups;
@@ -669,7 +682,7 @@
console.log(url);
const fileItem = {
type: 'doc',
type: 'text',
name: url,
collection_name: '',
status: 'uploading',
@@ -702,7 +715,7 @@
console.log(url);
const fileItem = {
type: 'doc',
type: 'text',
name: url,
collection_name: '',
status: 'uploading',
@@ -1394,10 +1407,10 @@
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
console.log('submitPrompt', userPrompt, $chatId);
const messages = createMessagesList(history, history.currentId);
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
selectedModels = _selectedModels;
}
@@ -1411,15 +1424,6 @@
return;
}
if (messages.length != 0 && messages.at(-1).done != true) {
// Response not done
return;
}
if (messages.length != 0 && messages.at(-1).error && !messages.at(-1).content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
}
if (
files.length > 0 &&
files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0
@@ -1429,6 +1433,7 @@
);
return;
}
if (
($config?.file?.max_count ?? null) !== null &&
files.length + chatFiles.length > $config?.file?.max_count
@@ -1441,9 +1446,25 @@
return;
}
if (history?.currentId) {
const lastMessage = history.messages[history.currentId];
if (lastMessage.done != true) {
// Response not done
return;
}
if (lastMessage.error && !lastMessage.content) {
// Error in response
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
return;
}
}
messageInput?.setText('');
prompt = '';
const messages = createMessagesList(history, history.currentId);
// Reset chat input textarea
if (!($settings?.richTextInput ?? true)) {
const chatInputElement = document.getElementById('chat-input');
@@ -1618,6 +1639,46 @@
chats.set(await getChatList(localStorage.token, $currentChatPage));
};
const getFeatures = () => {
let features = {};
if ($config?.features)
features = {
image_generation:
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search:
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled
: false
};
const currentModels = atSelectedModel?.id ? [atSelectedModel.id] : selectedModels;
if (
currentModels.filter(
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
).length === currentModels.length
) {
if (($settings?.webSearch ?? false) === 'always') {
features = { ...features, web_search: true };
}
}
if ($settings?.memory ?? false) {
features = { ...features, memory: true };
}
return features;
};
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
const responseMessage = _history.messages[responseMessageId];
const userMessage = _history.messages[responseMessage.parentId];
@@ -1730,25 +1791,7 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
tool_servers: $toolServers,
features: {
image_generation:
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
? imageGenerationEnabled
: false,
code_interpreter:
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
? codeInterpreterEnabled
: false,
web_search:
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
? webSearchEnabled || ($settings?.webSearch ?? false) === 'always'
: false,
memory: $settings?.memory ?? false
},
features: getFeatures(),
variables: {
...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined)
},
+20 -6
View File
@@ -39,7 +39,11 @@
export const openPane = () => {
if (parseInt(localStorage?.chatControlsSize)) {
pane.resize(parseInt(localStorage?.chatControlsSize));
const container = document.getElementById('chat-container');
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
pane.resize(size);
} else {
pane.resize(minSize);
}
@@ -91,7 +95,7 @@
const resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
const width = entry.contentRect.width;
// calculate the percentage of 200px
// calculate the percentage of 350px
const percentage = (350 / width) * 100;
// set the minSize to the percentage, must be an integer
minSize = Math.floor(percentage);
@@ -99,6 +103,13 @@
if ($showControls) {
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
pane.resize(minSize);
} else {
let size = Math.floor(
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
);
if (size < minSize) {
pane.resize(minSize);
}
}
}
}
@@ -193,7 +204,10 @@
<!-- if $showControls -->
{#if $showControls}
<PaneResizer class="relative flex w-2 items-center justify-center bg-background group">
<PaneResizer
class="relative flex w-2 items-center justify-center bg-background group"
id="controls-resizer"
>
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs">
<EllipsisVertical className="size-4 invisible group-hover:visible" />
</div>
@@ -204,8 +218,6 @@
bind:pane
defaultSize={0}
onResize={(size) => {
console.log('size', size, minSize);
if ($showControls && pane.isExpanded()) {
if (size < minSize) {
pane.resize(minSize);
@@ -214,7 +226,9 @@
if (size < minSize) {
localStorage.chatControlsSize = 0;
} else {
localStorage.chatControlsSize = size;
// save the size in pixels to localStorage
const container = document.getElementById('chat-container');
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
}
}
}}
+62 -51
View File
@@ -1,11 +1,6 @@
<script lang="ts">
import * as pdfjs from 'pdfjs-dist';
import * as pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs';
pdfjs.GlobalWorkerOptions.workerSrc = import.meta.url + 'pdfjs-dist/build/pdf.worker.mjs';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import heic2any from 'heic2any';
import { toast } from 'svelte-sonner';
@@ -32,7 +27,7 @@
} from '$lib/stores';
import {
blobToFile,
convertHeicToJpeg,
compressImage,
createMessagesList,
extractContentFromFile,
@@ -106,6 +101,7 @@
export let codeInterpreterEnabled = false;
let showInputVariablesModal = false;
let inputVariablesModalCallback = (variableValues) => {};
let inputVariables = {};
let inputVariableValues = {};
@@ -127,11 +123,24 @@
codeInterpreterEnabled
});
const inputVariableHandler = async (text: string) => {
const inputVariableHandler = async (text: string): Promise<string> => {
inputVariables = extractInputVariables(text);
if (Object.keys(inputVariables).length > 0) {
showInputVariablesModal = true;
// No variables? return the original text immediately.
if (Object.keys(inputVariables).length === 0) {
return text;
}
// Show modal and wait for the user's input.
showInputVariablesModal = true;
return await new Promise<string>((resolve) => {
inputVariablesModalCallback = (variableValues) => {
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
showInputVariablesModal = false;
resolve(text);
};
});
};
const textVariableHandler = async (text: string) => {
@@ -249,7 +258,6 @@
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
inputVariableHandler(text);
return text;
};
@@ -285,7 +293,7 @@
}
};
export const setText = async (text?: string) => {
export const setText = async (text?: string, cb?: (text: string) => void) => {
const chatInput = document.getElementById('chat-input');
if (chatInput) {
@@ -301,6 +309,10 @@
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
}
text = await inputVariableHandler(text);
await tick();
if (cb) await cb(text);
}
};
@@ -417,6 +429,30 @@
let recording = false;
let isComposing = false;
// Safari has a bug where compositionend is not triggered correctly #16615
// when using the virtual keyboard on iOS.
let compositionEndedAt = -2e8;
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
function inOrNearComposition(event: Event) {
if (isComposing) {
return true;
}
// See https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/.
// On Japanese input method editors (IMEs), the Enter key is used to confirm character
// selection. On Safari, when Enter is pressed, compositionend and keydown events are
// emitted. The keydown event triggers newline insertion, which we don't want.
// This method returns true if the keydown event should be ignored.
// We only ignore it once, as pressing Enter a second time *should* insert a newline.
// Furthermore, the keydown event timestamp must be close to the compositionEndedAt timestamp.
// This guards against the case where compositionend is triggered without the keyboard
// (e.g. character confirmation may be done with the mouse), and keydown is triggered
// afterwards- we wouldn't want to ignore the keydown event in this case.
if (isSafari && Math.abs(event.timeStamp - compositionEndedAt) < 500) {
compositionEndedAt = -2e8;
return true;
}
return false;
}
let chatInputContainerElement;
let chatInputElement;
@@ -616,7 +652,7 @@
} else {
// If temporary chat is enabled, we just add the file to the list without uploading it.
const content = await extractContentFromFile(file, pdfjsLib).catch((error) => {
const content = await extractContentFromFile(file).catch((error) => {
toast.error(
$i18n.t('Failed to extract content from the file: {{error}}', { error: error })
);
@@ -739,11 +775,7 @@
}
];
};
reader.readAsDataURL(
file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' })
: file
);
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
} else {
uploadFileHandler(file);
}
@@ -849,10 +881,7 @@
<InputVariablesModal
bind:show={showInputVariablesModal}
variables={inputVariables}
onSave={(variableValues) => {
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
}}
onSave={inputVariablesModalCallback}
/>
{#if loaded}
@@ -1169,19 +1198,9 @@
return res;
}}
oncompositionstart={() => (isComposing = true)}
oncompositionend={() => {
const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
);
if (isSafari) {
// Safari has a bug where compositionend is not triggered correctly #16615
// when using the virtual keyboard on iOS.
// We use a timeout to ensure that the composition is ended after a short delay.
setTimeout(() => (isComposing = false));
} else {
isComposing = false;
}
oncompositionend={(e) => {
compositionEndedAt = e.timeStamp;
isComposing = false;
}}
on:keydown={async (e) => {
e = e.detail.event;
@@ -1290,7 +1309,7 @@
navigator.msMaxTouchPoints > 0
)
) {
if (isComposing) {
if (inOrNearComposition(e)) {
return;
}
@@ -1393,17 +1412,9 @@
command = getCommand();
}}
on:compositionstart={() => (isComposing = true)}
on:compositionend={() => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Safari has a bug where compositionend is not triggered correctly #16615
// when using the virtual keyboard on iOS.
// We use a timeout to ensure that the composition is ended after a short delay.
setTimeout(() => (isComposing = false));
} else {
isComposing = false;
}
on:compositionend={(e) => {
compositionEndedAt = e.timeStamp;
isComposing = false;
}}
on:keydown={async (e) => {
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
@@ -1523,7 +1534,7 @@
navigator.msMaxTouchPoints > 0
)
) {
if (isComposing) {
if (inOrNearComposition(e)) {
return;
}
@@ -1772,7 +1783,7 @@
<Sparkles className="size-4" strokeWidth="1.75" />
{/if}
<span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{filter?.name}</span
>
</button>
@@ -1791,7 +1802,7 @@
>
<GlobeAlt className="size-4" strokeWidth="1.75" />
<span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{$i18n.t('Web Search')}</span
>
</button>
@@ -1810,7 +1821,7 @@
>
<Photo className="size-4" strokeWidth="1.75" />
<span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{$i18n.t('Image')}</span
>
</button>
@@ -1836,7 +1847,7 @@
>
<CommandLine className="size-4" strokeWidth="1.75" />
<span
class="hidden @xl:block whitespace-nowrap overflow-hidden text-ellipsis leading-none pr-0.5"
class="hidden @xl:block whitespace-nowrap text-ellipsis leading-none normal-case pr-0.5"
>{$i18n.t('Code Interpreter')}</span
>
</button>
+4 -2
View File
@@ -49,6 +49,7 @@
export let addMessages: Function = () => {};
export let readOnly = false;
export let editCodeBlock = true;
export let topPadding = false;
export let bottomPadding = false;
@@ -56,7 +57,7 @@
export let onSelect = (e) => {};
let messagesCount = 20;
export let messagesCount: number | null = 20;
let messagesLoading = false;
const loadMoreMessages = async () => {
@@ -76,7 +77,7 @@
let _messages = [];
let message = history.messages[history.currentId];
while (message && _messages.length <= messagesCount) {
while (message && (messagesCount !== null ? _messages.length <= messagesCount : true)) {
_messages.unshift({ ...message });
message = message.parentId !== null ? history.messages[message.parentId] : null;
}
@@ -447,6 +448,7 @@
{addMessages}
{triggerScroll}
{readOnly}
{editCodeBlock}
{topPadding}
/>
{/each}
+40 -121
View File
@@ -1,9 +1,6 @@
<script lang="ts">
import { getContext } from 'svelte';
import CitationsModal from './CitationsModal.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import CitationsModal from '$lib/components/chat/Messages/Citations/CitationsModal.svelte';
const i18n = getContext('i18n');
@@ -14,14 +11,16 @@
let showPercentage = false;
let showRelevance = true;
let citationModal = null;
let showCitationModal = false;
let selectedCitation: any = null;
let isCollapsibleOpen = false;
export const showSourceModal = (sourceIdx) => {
if (citations[sourceIdx]) {
selectedCitation = citations[sourceIdx];
showCitationModal = true;
console.log('Showing citation modal for:', citations[sourceIdx]);
citationModal?.showCitation(citations[sourceIdx]);
// showCitationModal = true;
}
};
@@ -55,9 +54,9 @@
return acc;
}
source.document.forEach((document, index) => {
const metadata = source.metadata?.[index];
const distance = source.distances?.[index];
source?.document?.forEach((document, index) => {
const metadata = source?.metadata?.[index];
const distance = source?.distances?.[index];
// Within the same citation there could be multiple documents
const id = metadata?.source ?? source?.source?.id ?? 'N/A';
@@ -87,6 +86,7 @@
});
}
});
return acc;
}, []);
console.log('citations', citations);
@@ -94,127 +94,46 @@
showRelevance = calculateShowRelevance(citations);
showPercentage = shouldShowPercentage(citations);
}
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
</script>
<CitationsModal
bind:this={citationModal}
bind:show={showCitationModal}
citation={selectedCitation}
{id}
{citations}
{showPercentage}
{showRelevance}
/>
{#if citations.length > 0}
<div class=" py-0.5 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
{#if citations.length <= 3}
<div class="flex text-xs font-medium flex-wrap">
{#each citations as citation, idx}
<button
id={`source-${id}-${idx + 1}`}
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-white dark:bg-gray-900 rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div
class="flex-1 mx-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition"
>
{decodeString(citation.source.name)}
</div>
</button>
{/each}
{@const urlCitations = citations.filter((c) => c?.source?.name?.startsWith('http'))}
<div class=" py-1 -mx-0.5 w-full flex gap-1 items-center flex-wrap">
<button
class="text-xs font-medium text-gray-600 dark:text-gray-300 px-3.5 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition flex items-center gap-1 border border-gray-50 dark:border-gray-850"
on:click={() => {
showCitationModal = true;
}}
>
{#if urlCitations.length > 0}
<div class="flex -space-x-1 items-center">
{#each urlCitations.slice(0, 3) as citation, idx}
<img
src="https://www.google.com/s2/favicons?sz=32&domain={citation.source.name}"
alt="favicon"
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-white dark:bg-gray-900"
/>
{/each}
</div>
{/if}
<div>
{#if citations.length === 1}
{$i18n.t('1 Source')}
{:else}
{$i18n.t('{{COUNT}} Sources', {
COUNT: citations.length
})}
{/if}
</div>
{:else}
<Collapsible
id={`collapsible-${id}`}
bind:open={isCollapsibleOpen}
className="w-full max-w-full "
buttonClassName="w-fit max-w-full"
>
<div
class="flex w-full overflow-auto items-center gap-2 text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition cursor-pointer"
>
<div
class="flex-1 flex items-center gap-1 overflow-auto scrollbar-none w-full max-w-full"
>
<span class="whitespace-nowrap hidden sm:inline shrink-0"
>{$i18n.t('References from')}</span
>
<div class="flex items-center overflow-auto scrollbar-none w-full max-w-full flex-1">
<div class="flex text-xs font-medium items-center">
{#each citations.slice(0, 2) as citation, idx}
<button
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 1}
</div>
{/if}
<div class="flex-1 mx-1 truncate">
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
<div class="flex items-center gap-1 whitespace-nowrap shrink-0">
<span class="hidden sm:inline">{$i18n.t('and')}</span>
{citations.length - 2}
<span>{$i18n.t('more')}</span>
</div>
</div>
<div class="shrink-0">
{#if isCollapsibleOpen}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
<div slot="content">
<div class="flex text-xs font-medium flex-wrap">
{#each citations.slice(2) as citation, idx}
<button
class="no-toggle outline-hidden flex dark:text-gray-300 p-1 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition rounded-xl max-w-96"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
{#if citations.every((c) => c.distances !== undefined)}
<div class="bg-gray-50 dark:bg-gray-800 rounded-full size-4">
{idx + 3}
</div>
{/if}
<div class="flex-1 mx-1 truncate">
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
</Collapsible>
{/if}
</button>
</div>
{/if}
@@ -61,8 +61,34 @@
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center capitalize">
{$i18n.t('Citation')}
<div class=" text-lg font-medium self-center">
{#if citation?.source?.name}
{@const document = mergedDocuments?.[0]}
{#if document?.metadata?.file_id || document.source?.url?.includes('http')}
<Tooltip
className="w-fit"
content={$i18n.t('Open file')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<a
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow"
href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
? document.source.url
: `#`}
target="_blank"
>
{decodeString(citation?.source?.name)}
</a>
</Tooltip>
{:else}
{decodeString(citation?.source?.name)}
{/if}
{:else}
{$i18n.t('Citation')}
{/if}
</div>
<button
class="self-center"
@@ -76,57 +102,31 @@
<div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4">
<div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden"
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden gap-1"
>
{#each mergedDocuments as document, documentIdx}
<div class="flex flex-col w-full">
<div class="text-sm font-medium dark:text-gray-300">
{$i18n.t('Source')}
</div>
{#if document.source?.name}
<Tooltip
className="w-fit"
content={$i18n.t('Open file')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
<div class="text-sm dark:text-gray-400 flex items-center gap-2 w-fit">
<a
class="hover:text-gray-500 dark:hover:text-gray-100 underline grow"
href={document?.metadata?.file_id
? `${WEBUI_API_BASE_URL}/files/${document?.metadata?.file_id}/content${document?.metadata?.page !== undefined ? `#page=${document.metadata.page + 1}` : ''}`
: document.source?.url?.includes('http')
? document.source.url
: `#`}
target="_blank"
>
{decodeString(document?.metadata?.name ?? document.source.name)}
</a>
{#if Number.isInteger(document?.metadata?.page)}
<span class="text-xs text-gray-500 dark:text-gray-400">
({$i18n.t('page')}
{document.metadata.page + 1})
</span>
{/if}
</div>
</Tooltip>
{#if document.metadata?.parameters}
<div class="text-sm font-medium dark:text-gray-300 mt-2 mb-0.5">
<div class="flex flex-col w-full gap-2">
{#if document.metadata?.parameters}
<div>
<div class="text-sm font-medium dark:text-gray-300 mb-1">
{$i18n.t('Parameters')}
</div>
<Textarea readonly value={JSON.stringify(document.metadata.parameters, null, 2)}
></Textarea>
{/if}
{#if showRelevance}
<div class="text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Relevance')}
</div>
{#if document.distance !== undefined}
</div>
{/if}
<div>
<div
class=" text-sm font-medium dark:text-gray-300 flex items-center gap-2 w-fit mb-1"
>
{$i18n.t('Content')}
{#if showRelevance && document.distance !== undefined}
<Tooltip
className="w-fit"
content={$i18n.t('Semantic distance to query')}
content={$i18n.t('Relevance')}
placement="top-start"
tippyOptions={{ duration: [500, 0] }}
>
@@ -141,12 +141,6 @@
{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)})
@@ -154,39 +148,30 @@
{/if}
</div>
</Tooltip>
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No distance available')}
</div>
{/if}
{/if}
{:else}
<div class="text-sm dark:text-gray-400">
{$i18n.t('No source available')}
{#if Number.isInteger(document?.metadata?.page)}
<span class="text-sm text-gray-500 dark:text-gray-400">
({$i18n.t('page')}
{document.metadata.page + 1})
</span>
{/if}
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class=" text-sm font-medium dark:text-gray-300 mt-2">
{$i18n.t('Content')}
</div>
{#if document.metadata?.html}
<iframe
class="w-full border-0 h-auto rounded-none"
sandbox="allow-scripts allow-forms allow-same-origin"
srcdoc={document.document}
title={$i18n.t('Content')}
></iframe>
{:else}
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
{#if document.metadata?.html}
<iframe
class="w-full border-0 h-auto rounded-none"
sandbox="allow-scripts allow-forms allow-same-origin"
srcdoc={document.document}
title={$i18n.t('Content')}
></iframe>
{:else}
<pre class="text-sm dark:text-gray-400 whitespace-pre-line">
{document.document}
</pre>
{/if}
{/if}
</div>
</div>
{#if documentIdx !== mergedDocuments.length - 1}
<hr class="border-gray-100 dark:border-gray-850 my-3" />
{/if}
{/each}
</div>
</div>
@@ -0,0 +1,82 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import CitationModal from './CitationModal.svelte';
export let id = '';
export let show = false;
export let citations = [];
export let showPercentage = false;
export let showRelevance = true;
let showCitationModal = false;
let selectedCitation: any = null;
export const showCitation = (citation) => {
selectedCitation = citation;
showCitationModal = true;
};
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
</script>
<CitationModal
bind:show={showCitationModal}
citation={selectedCitation}
{showPercentage}
{showRelevance}
/>
<Modal size="lg" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center capitalize">
{$i18n.t('Citations')}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-6 pb-5 md:space-x-4">
<div
class="flex flex-col w-full dark:text-gray-200 overflow-y-scroll max-h-[22rem] scrollbar-hidden text-left text-sm gap-2"
>
{#each citations as citation, idx}
<button
id={`source-${id}-${idx + 1}`}
class="no-toggle outline-hidden flex dark:text-gray-300 bg-white dark:bg-gray-900 rounded-xl gap-1.5 items-center"
on:click={() => {
showCitationModal = true;
selectedCitation = citation;
}}
>
<div class=" font-medium">
{idx + 1}.
</div>
<div
class="flex-1 truncate text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white transition text-left"
>
{decodeString(citation.source.name)}
</div>
</button>
{/each}
</div>
</div>
</div>
</Modal>
@@ -1,4 +1,6 @@
<script lang="ts">
import hljs from 'highlight.js';
import mermaid from 'mermaid';
import { v4 as uuidv4 } from 'uuid';
@@ -22,6 +24,7 @@
const i18n = getContext('i18n');
export let id = '';
export let edit = true;
export let onSave = (e) => {};
export let onUpdate = (e) => {};
@@ -84,7 +87,7 @@
const copyCode = async () => {
copied = true;
await copyToClipboard(code);
await copyToClipboard(_code);
setTimeout(() => {
copied = false;
@@ -213,19 +216,19 @@
const executePythonAsWorker = async (code) => {
let packages = [
code.includes('requests') ? 'requests' : null,
code.includes('bs4') ? 'beautifulsoup4' : null,
code.includes('numpy') ? 'numpy' : null,
code.includes('pandas') ? 'pandas' : null,
code.includes('sklearn') ? 'scikit-learn' : null,
code.includes('scipy') ? 'scipy' : null,
code.includes('re') ? 'regex' : null,
code.includes('seaborn') ? 'seaborn' : null,
code.includes('sympy') ? 'sympy' : null,
code.includes('tiktoken') ? 'tiktoken' : null,
code.includes('matplotlib') ? 'matplotlib' : null,
code.includes('pytz') ? 'pytz' : null,
code.includes('openai') ? 'openai' : null
/\bimport\s+requests\b|\bfrom\s+requests\b/.test(code) ? 'requests' : null,
/\bimport\s+bs4\b|\bfrom\s+bs4\b/.test(code) ? 'beautifulsoup4' : null,
/\bimport\s+numpy\b|\bfrom\s+numpy\b/.test(code) ? 'numpy' : null,
/\bimport\s+pandas\b|\bfrom\s+pandas\b/.test(code) ? 'pandas' : null,
/\bimport\s+matplotlib\b|\bfrom\s+matplotlib\b/.test(code) ? 'matplotlib' : null,
/\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null,
/\bimport\s+sklearn\b|\bfrom\s+sklearn\b/.test(code) ? 'scikit-learn' : null,
/\bimport\s+scipy\b|\bfrom\s+scipy\b/.test(code) ? 'scipy' : null,
/\bimport\s+re\b|\bfrom\s+re\b/.test(code) ? 'regex' : null,
/\bimport\s+seaborn\b|\bfrom\s+seaborn\b/.test(code) ? 'seaborn' : null,
/\bimport\s+sympy\b|\bfrom\s+sympy\b/.test(code) ? 'sympy' : null,
/\bimport\s+tiktoken\b|\bfrom\s+tiktoken\b/.test(code) ? 'tiktoken' : null,
/\bimport\s+pytz\b|\bfrom\s+pytz\b/.test(code) ? 'pytz' : null
].filter(Boolean);
console.log(packages);
@@ -413,11 +416,11 @@
</script>
<div>
<div class="relative {className} flex flex-col rounded-lg" dir="ltr">
<div class="relative {className} flex flex-col rounded-xl pt-2" dir="ltr">
{#if lang === 'mermaid'}
{#if mermaidHtml}
<SvgPanZoom
className=" border border-gray-100 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden"
className=" border border-gray-100 dark:border-gray-850 rounded-xl max-h-fit overflow-hidden"
svg={mermaidHtml}
content={_token.text}
/>
@@ -425,16 +428,16 @@
<pre class="mermaid">{code}</pre>
{/if}
{:else}
<div class="text-text-300 absolute pl-4 py-1.5 text-xs font-medium dark:text-white">
<div class="text-text-300 absolute pl-4 text-xs font-medium dark:text-white">
{lang}
</div>
<div
class="sticky {stickyButtonsClassName} mb-1 py-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
class="sticky {stickyButtonsClassName} mb-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
>
<div class="flex items-center gap-0.5 translate-y-[1px]">
<div class="flex items-center gap-0.5">
<button
class="flex gap-1 items-center bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
class="flex gap-1 items-center bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={collapseCodeBlock}
>
<div class=" -translate-y-[0.5px]">
@@ -448,7 +451,7 @@
{#if preview && ['html', 'svg'].includes(lang)}
<button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={previewCode}
>
<div class=" -translate-y-[0.5px]">
@@ -468,7 +471,7 @@
</div>
{:else if run}
<button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={async () => {
code = _code;
await tick();
@@ -488,7 +491,7 @@
{#if save}
<button
class="save-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
class="save-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={saveCode}
>
{saved ? $i18n.t('Saved') : $i18n.t('Save')}
@@ -496,36 +499,50 @@
{/if}
<button
class="copy-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
class="copy-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button
>
</div>
</div>
<div
class="language-{lang} rounded-t-lg -mt-8 {editorClassName
class="language-{lang} rounded-t-xl -mt-8 {editorClassName
? editorClassName
: executing || stdout || stderr || result
? ''
: 'rounded-b-lg'} overflow-hidden"
: 'rounded-b-xl'} overflow-hidden"
>
<div class=" pt-7 bg-gray-50 dark:bg-gray-850"></div>
<div class=" pt-8 bg-gray-50 dark:bg-black"></div>
{#if !collapsed}
<CodeEditor
value={code}
{id}
{lang}
onSave={() => {
saveCode();
}}
onChange={(value) => {
_code = value;
}}
/>
{#if edit}
<CodeEditor
value={code}
{id}
{lang}
onSave={() => {
saveCode();
}}
onChange={(value) => {
_code = value;
}}
/>
{:else}
<pre
class=" hljs p-4 px-5 overflow-x-auto"
style="border-top-left-radius: 0px; border-top-right-radius: 0px; {(executing ||
stdout ||
stderr ||
result) &&
'border-bottom-left-radius: 0px; border-bottom-right-radius: 0px;'}"><code
class="language-{lang} rounded-t-none whitespace-pre text-sm"
>{@html hljs.highlightAuto(code, hljs.getLanguage(lang)?.aliases).value ||
code}</code
></pre>
{/if}
{:else}
<div
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-lg! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs"
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-xl! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs"
>
<span class="text-gray-500 italic">
{$i18n.t('{{COUNT}} hidden lines', {
@@ -539,12 +556,12 @@
{#if !collapsed}
<div
id="plt-canvas-{id}"
class="bg-gray-50 dark:bg-[#202123] dark:text-white max-w-full overflow-x-auto scrollbar-hidden"
class="bg-gray-50 dark:bg-black dark:text-white max-w-full overflow-x-auto scrollbar-hidden"
/>
{#if executing || stdout || stderr || result || files}
<div
class="bg-gray-50 dark:bg-[#202123] dark:text-white rounded-b-lg! py-4 px-4 flex flex-col gap-2"
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-xl! py-4 px-4 flex flex-col gap-2"
>
{#if executing}
<div class=" ">
@@ -30,6 +30,8 @@
export let save = false;
export let preview = false;
export let floatingButtons = true;
export let editCodeBlock = true;
export let topPadding = false;
export let onSave = (e) => {};
@@ -138,6 +140,7 @@
{save}
{preview}
{done}
{editCodeBlock}
{topPadding}
sourceIds={(sources ?? []).reduce((acc, source) => {
let ids = [];
@@ -14,6 +14,8 @@
export let model = null;
export let save = false;
export let preview = false;
export let editCodeBlock = true;
export let topPadding = false;
export let sourceIds = [];
@@ -52,6 +54,7 @@
{done}
{save}
{preview}
{editCodeBlock}
{topPadding}
{onTaskClick}
{onSourceClick}
@@ -32,6 +32,8 @@
export let save = false;
export let preview = false;
export let editCodeBlock = true;
export let topPadding = false;
export let onSave: Function = () => {};
@@ -106,6 +108,7 @@
{attributes}
{save}
{preview}
edit={editCodeBlock}
stickyButtonsClassName={topPadding ? 'top-8' : 'top-0'}
onSave={(value) => {
onSave({
@@ -198,6 +201,7 @@
id={`${id}-${tokenIdx}`}
tokens={token.tokens}
{done}
{editCodeBlock}
{onTaskClick}
{onSourceClick}
/>
@@ -231,6 +235,7 @@
tokens={item.tokens}
top={token.loose}
{done}
{editCodeBlock}
{onTaskClick}
{onSourceClick}
/>
@@ -264,6 +269,7 @@
tokens={item.tokens}
top={token.loose}
{done}
{editCodeBlock}
{onTaskClick}
{onSourceClick}
/>
@@ -274,6 +280,7 @@
tokens={item.tokens}
top={token.loose}
{done}
{editCodeBlock}
{onTaskClick}
{onSourceClick}
/>
@@ -296,6 +303,7 @@
tokens={marked.lexer(token.text)}
attributes={token?.attributes}
{done}
{editCodeBlock}
{onTaskClick}
{onSourceClick}
/>
@@ -21,6 +21,10 @@
// Helper function to return only the domain from a URL
function getDomain(url: string): string {
const domain = url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0];
if (domain.startsWith('www.')) {
return domain.slice(4);
}
return domain;
}
@@ -33,6 +37,14 @@
return title;
}
const getDisplayTitle = (title: string) => {
if (!title) return 'N/A';
if (title.length > 30) {
return title.slice(0, 15) + '...' + title.slice(-10);
}
return title;
};
$: attributes = extractAttributes(token.text);
</script>
@@ -44,7 +56,11 @@
}}
>
<span class="line-clamp-1">
{attributes.title ? formattedTitle(attributes.title) : ''}
{getDisplayTitle(
decodeURIComponent(attributes.title)
? formattedTitle(decodeURIComponent(attributes.title))
: ''
)}
</span>
</button>
{/if}
@@ -41,10 +41,11 @@
export let addMessages;
export let triggerScroll;
export let readOnly = false;
export let editCodeBlock = true;
export let topPadding = false;
</script>
<li
<div
class="flex flex-col justify-between px-5 mb-3 w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-5xl'} mx-auto rounded-lg group"
@@ -68,6 +69,7 @@
{editMessage}
{deleteMessage}
{readOnly}
{editCodeBlock}
{topPadding}
/>
{:else if (history.messages[history.messages[messageId].parentId]?.models?.length ?? 1) === 1}
@@ -93,6 +95,7 @@
{regenerateResponse}
{addMessages}
{readOnly}
{editCodeBlock}
{topPadding}
/>
{:else}
@@ -116,8 +119,9 @@
{triggerScroll}
{addMessages}
{readOnly}
{editCodeBlock}
{topPadding}
/>
{/if}
{/if}
</li>
</div>
@@ -29,6 +29,7 @@
export let isLastMessage;
export let readOnly = false;
export let editCodeBlock = true;
export let setInputText: Function = () => {};
export let updateChat: Function;
@@ -379,6 +380,7 @@
}}
{addMessages}
{readOnly}
{editCodeBlock}
{topPadding}
/>
{/if}
@@ -52,6 +52,7 @@
import { fade } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions';
import RegenerateMenu from './ResponseMessage/RegenerateMenu.svelte';
import StatusHistory from './ResponseMessage/StatusHistory.svelte';
interface MessageType {
id: string;
@@ -138,6 +139,7 @@
export let isLastMessage = true;
export let readOnly = false;
export let editCodeBlock = true;
export let topPadding = false;
let citationsElement: HTMLDivElement;
@@ -626,7 +628,10 @@
{#if message.timestamp}
<div
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
class="self-center text-xs font-medium first-letter:capitalize ml-0.5 translate-y-[1px] {($settings?.highContrastMode ??
false)
? 'dark:text-gray-100 text-gray-900'
: 'invisible group-hover:visible transition text-gray-400'}"
>
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
@@ -638,76 +643,11 @@
<div>
<div class="chat-{message.role} w-full min-w-full markdown-prose">
<div>
{#if (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length > 0}
{@const status = (
message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]
).at(-1)}
{#if !status?.hidden}
<div class="status-description flex items-center gap-2 py-0.5">
{#if status?.action === 'web_search' && status?.urls}
<WebSearchResults {status}>
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t("Generating search query") -->
<!-- $i18n.t("No search query generated") -->
<!-- $i18n.t('Searched {{count}} sites') -->
{#if status?.description.includes('{{count}}')}
{$i18n.t(status?.description, {
count: status?.urls.length
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else}
{status?.description}
{/if}
</div>
</div>
</WebSearchResults>
{:else if status?.action === 'knowledge_search'}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, {
searchQuery: status.query
})}
</div>
</div>
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{status?.done === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
{#if status?.description.includes('{{searchQuery}}')}
{$i18n.t(status?.description, {
searchQuery: status?.query
})}
{:else if status?.description === 'No search query generated'}
{$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}
</div>
</div>
{/if}
</div>
{/if}
{#if model?.info?.meta?.capabilities?.status_updates ?? true}
<StatusHistory
statusHistory={message?.statusHistory}
expand={message?.content === ''}
/>
{/if}
{#if message?.files && message.files?.filter((f) => f.type === 'image').length > 0}
@@ -794,7 +734,7 @@
</div>
{:else}
<div class="w-full flex flex-col relative" id="response-content-container">
{#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0}
{#if message.content === '' && !message.error && ((model?.info?.meta?.capabilities?.status_updates ?? true) ? (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0 || (message?.statusHistory?.at(-1)?.hidden ?? false) : true)}
<Skeleton />
{:else if message.content && message.error !== true}
<!-- always show message contents even if there's an error -->
@@ -811,6 +751,7 @@
($settings?.showFloatingActionButtons ?? true)}
save={!readOnly}
preview={!readOnly}
{editCodeBlock}
{topPadding}
done={($settings?.chatFadeStreamingText ?? true)
? (message?.done ?? false)
@@ -968,7 +909,7 @@
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
aria-label={$i18n.t('Edit')}
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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={() => {
@@ -998,7 +939,7 @@
<Tooltip content={$i18n.t('Copy')} placement="bottom">
<button
aria-label={$i18n.t('Copy')}
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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 copy-response-button"
on:click={() => {
@@ -1028,7 +969,7 @@
<button
aria-label={$i18n.t('Read Aloud')}
id="speak-button-{message.id}"
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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={() => {
@@ -1111,7 +1052,7 @@
<Tooltip content={$i18n.t('Generate Image')} placement="bottom">
<button
aria-label={$i18n.t('Generate Image')}
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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={() => {
@@ -1191,7 +1132,7 @@
>
<button
aria-hidden="true"
class=" {isLastMessage
class=" {isLastMessage || ($settings?.highContrastMode ?? false)
? '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 whitespace-pre-wrap"
on:click={() => {
@@ -1219,11 +1160,11 @@
{/if}
{#if !readOnly}
{#if !$temporaryChatEnabled && ($config?.features.enable_message_rating ?? true)}
{#if !$temporaryChatEnabled && ($config?.features.enable_message_rating ?? true) && ($user?.role === 'admin' || ($user?.permissions?.chat?.rate_response ?? true))}
<Tooltip content={$i18n.t('Good Response')} placement="bottom">
<button
aria-label={$i18n.t('Good Response')}
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
message?.annotation?.rating ?? ''
@@ -1261,7 +1202,7 @@
<Tooltip content={$i18n.t('Bad Response')} placement="bottom">
<button
aria-label={$i18n.t('Bad Response')}
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg {(
message?.annotation?.rating ?? ''
@@ -1297,13 +1238,13 @@
</Tooltip>
{/if}
{#if isLastMessage}
{#if isLastMessage && ($user?.role === 'admin' || ($user?.permissions?.chat?.continue_response ?? true))}
<Tooltip content={$i18n.t('Continue Response')} placement="bottom">
<button
aria-label={$i18n.t('Continue Response')}
type="button"
id="continue-response-button"
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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={() => {
@@ -1334,79 +1275,11 @@
</Tooltip>
{/if}
{#if $settings?.regenerateMenu ?? true}
<button
type="button"
class="hidden regenerate-response-button"
on:click={() => {
showRateComment = false;
regenerateResponse(message);
(model?.actions ?? []).forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
}
});
});
}}
/>
<RegenerateMenu
onRegenerate={(prompt = null) => {
showRateComment = false;
regenerateResponse(message, prompt);
(model?.actions ?? []).forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
}
});
});
}}
>
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<div
aria-label={$i18n.t('Regenerate')}
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</div>
</Tooltip>
</RegenerateMenu>
{:else}
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.regenerate_response ?? true)}
{#if $settings?.regenerateMenu ?? true}
<button
type="button"
aria-label={$i18n.t('Regenerate')}
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 regenerate-response-button"
class="hidden regenerate-response-button"
on:click={() => {
showRateComment = false;
regenerateResponse(message);
@@ -1423,56 +1296,128 @@
});
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{/if}
/>
{#if siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
type="button"
aria-label={$i18n.t('Delete')}
id="delete-response-button"
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={() => {
showDeleteConfirm = true;
<RegenerateMenu
onRegenerate={(prompt = null) => {
showRateComment = false;
regenerateResponse(message, prompt);
(model?.actions ?? []).forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
}
});
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
class="w-4 h-4"
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<div
aria-label={$i18n.t('Regenerate')}
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"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</div>
</Tooltip>
</RegenerateMenu>
{:else}
<Tooltip content={$i18n.t('Regenerate')} placement="bottom">
<button
type="button"
aria-label={$i18n.t('Regenerate')}
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 regenerate-response-button"
on:click={() => {
showRateComment = false;
regenerateResponse(message);
(model?.actions ?? []).forEach((action) => {
dispatch('action', {
id: action.id,
event: {
id: 'regenerate-response',
data: {
messageId: message.id
}
}
});
});
}}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.3"
aria-hidden="true"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if}
{#if $user?.role === 'admin' || ($user?.permissions?.chat?.delete_message ?? true)}
{#if siblings.length > 1}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
type="button"
aria-label={$i18n.t('Delete')}
id="delete-response-button"
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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={() => {
showDeleteConfirm = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if}
{#if isLastMessage}
@@ -1481,7 +1426,7 @@
<button
type="button"
aria-label={action.name}
class="{isLastMessage
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? '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={() => {
@@ -1,6 +1,5 @@
<script lang="ts">
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowTurnDownRight from '$lib/components/icons/ArrowTurnDownRight.svelte';
import { onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n');
@@ -20,13 +19,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Tooltip content={followUp} placement="top-start" className="line-clamp-1">
<div
class=" mr-2 py-1.5 bg-transparent text-left text-sm flex items-center gap-2 px-1.5 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white transition cursor-pointer"
class=" py-1.5 bg-transparent text-left text-sm flex items-center gap-2 text-gray-500 dark:text-gray-400 hover:text-black dark:hover:text-white transition cursor-pointer"
on:click={() => onClick(followUp)}
title={followUp}
aria-label={followUp}
>
<ArrowTurnDownRight className="size-3.5" />
<div class="line-clamp-1">
{followUp}
</div>
@@ -34,7 +30,7 @@
</Tooltip>
{#if idx < followUps.length - 1}
<hr class="border-gray-100 dark:border-gray-850" />
<hr class="border-gray-50 dark:border-gray-850" />
{/if}
{/each}
</div>
@@ -0,0 +1,80 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import StatusItem from './StatusHistory/StatusItem.svelte';
export let statusHistory = [];
export let expand = false;
let showHistory = true;
$: if (expand) {
showHistory = true;
} else {
showHistory = false;
}
let history = [];
let status = null;
$: if (history && history.length > 0) {
status = history.at(-1);
}
$: if (JSON.stringify(statusHistory) !== JSON.stringify(history)) {
history = statusHistory;
}
</script>
{#if history && history.length > 0}
{#if status?.hidden !== true}
<div class="text-sm flex flex-col w-full">
{#if showHistory}
<div class="flex flex-row">
{#if history.length > 1}
<div class="w-1 border-r border-gray-50 dark:border-gray-800 mt-3 -mb-2.5" />
<div class="w-full -translate-x-[7.5px]">
{#each history as status, idx}
{#if idx !== history.length - 1}
<div class="flex items-start gap-2 mb-1">
<div class="pt-3 px-1">
<span class="relative flex size-2">
<span
class="relative inline-flex size-1.5 rounded-full bg-gray-200 dark:bg-gray-700"
></span>
</span>
</div>
<StatusItem {status} done={true} />
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
<button
class="w-full -translate-x-[3.5px]"
on:click={() => {
showHistory = !showHistory;
}}
>
<div class="flex items-start gap-2">
<div class="pt-3 px-1">
<span class="relative flex size-2">
{#if status?.done === false}
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 dark:bg-gray-700 opacity-75"
></span>
{/if}
<span class="relative inline-flex size-1.5 rounded-full bg-gray-200 dark:bg-gray-700"
></span>
</span>
</div>
<StatusItem {status} />
</div>
</button>
</div>
{/if}
{/if}
@@ -0,0 +1,150 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import WebSearchResults from '../WebSearchResults.svelte';
import Search from '$lib/components/icons/Search.svelte';
import { t } from 'i18next';
export let status = null;
export let done = false;
</script>
{#if !status?.hidden}
<div class="status-description flex items-center gap-2 py-0.5 w-full text-left">
{#if status?.action === 'web_search' && (status?.urls || status?.items)}
<WebSearchResults {status}>
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t("Generating search query") -->
<!-- $i18n.t("No search query generated") -->
<!-- $i18n.t('Searched {{count}} sites') -->
{#if status?.description.includes('{{count}}')}
{$i18n.t(status?.description, {
count: (status?.urls || status?.items).length
})}
{:else if status?.description === 'No search query generated'}
{$i18n.t('No search query generated')}
{:else if status?.description === 'Generating search query'}
{$i18n.t('Generating search query')}
{:else}
{status?.description}
{/if}
</div>
</div>
</WebSearchResults>
{:else if status?.action === 'knowledge_search'}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, {
searchQuery: status.query
})}
</div>
</div>
{:else if status?.action === 'web_search_queries_generated' && status?.queries}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Searching`)}
</div>
<div class=" flex gap-1 flex-wrap mt-2">
{#each status.queries as query, idx (query)}
<div
class="bg-gray-50 dark:bg-gray-850 flex rounded-lg py-1 px-2 items-center gap-1 text-xs"
>
<div>
<Search className="size-3" />
</div>
<span class="line-clamp-1">
{query}
</span>
</div>
{/each}
</div>
</div>
{:else if status?.action === 'queries_generated' && status?.queries}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{$i18n.t(`Querying`)}
</div>
<div class=" flex gap-1 flex-wrap mt-2">
{#each status.queries as query, idx (query)}
<div
class="bg-gray-50 dark:bg-gray-850 flex rounded-lg py-1 px-2 items-center gap-1 text-xs"
>
<div>
<Search className="size-3" />
</div>
<span class="line-clamp-1">
{query}
</span>
</div>
{/each}
</div>
</div>
{:else if status?.action === 'sources_retrieved' && status?.count !== undefined}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
{#if status.count === 0}
{$i18n.t('No sources found')}
{:else if status.count === 1}
{$i18n.t('Retrieved 1 source')}
{:else}
<!-- {$i18n.t('Source')} -->
<!-- {$i18n.t('No source available')} -->
<!-- {$i18n.t('No distance available')} -->
<!-- {$i18n.t('Retrieved {{count}} sources')} -->
{$i18n.t('Retrieved {{count}} sources', {
count: status.count
})}
{/if}
</div>
</div>
{:else}
<div class="flex flex-col justify-center -space-y-0.5">
<div
class="{(done || status?.done) === false
? 'shimmer'
: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
>
<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
{#if status?.description?.includes('{{searchQuery}}')}
{$i18n.t(status?.description, {
searchQuery: status?.query
})}
{:else if status?.description === 'No search query generated'}
{$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}
</div>
</div>
{/if}
</div>
{/if}
@@ -8,27 +8,25 @@
let state = false;
</script>
<Collapsible bind:open={state} className="w-full space-y-1">
<div
class="flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
>
<Collapsible grow={true} className="w-full" buttonClassName="w-full" bind:open={state}>
<div class="flex items-center gap-2 text-gray-500 transition">
<slot />
{#if state}
<ChevronUp strokeWidth="3.5" className="size-3.5 " />
<ChevronUp strokeWidth="2.5" className="size-3.5 " />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5 " />
<ChevronDown strokeWidth="2.5" className="size-3.5 " />
{/if}
</div>
<div
class="text-sm border border-gray-300/30 dark:border-gray-700/50 rounded-xl mb-1.5"
class="text-sm border border-gray-50 dark:border-gray-850 rounded-xl my-1.5 p-2 w-full"
slot="content"
>
{#if status?.query}
<a
href="https://www.google.com/search?q={status.query}"
target="_blank"
class="flex w-full items-center p-3 px-4 border-b border-gray-300/30 dark:border-gray-700/50 group/item justify-between font-normal text-gray-800 dark:text-gray-300 no-underline"
class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 font-normal! no-underline!"
>
<div class="flex gap-2 items-center">
<Search />
@@ -58,36 +56,86 @@
</a>
{/if}
{#each status.urls as url, urlIdx}
<a
href={url}
target="_blank"
class="flex w-full items-center p-3 px-4 {urlIdx === status.urls.length - 1
? ''
: 'border-b border-gray-300/30 dark:border-gray-700/50'} group/item justify-between font-normal text-gray-800 dark:text-gray-300"
>
<div class=" line-clamp-1">
{url}
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
{#if status?.items}
{#each status.items as item, itemIdx}
<a
href={item.link}
target="_blank"
class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 rounded-lg font-normal! no-underline! mb-1"
>
<!-- -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
<div class=" flex justify-center items-center gap-3">
<div class="w-fit">
<img
src="https://www.google.com/s2/favicons?sz=32&domain={item.link}"
alt="favicon"
class="size-3.5"
/>
</div>
<div class="w-full text-sm line-clamp-1">
{item?.title ?? item.link}
</div>
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<path
fill-rule="evenodd"
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
{/each}
<!-- -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
{/each}
{:else if status?.urls}
{#each status.urls as url, urlIdx}
<a
href={url}
target="_blank"
class="flex w-full items-center p-1 px-3 group/item justify-between text-gray-800 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 rounded-lg no-underline mb-1"
>
<div class=" flex justify-center items-center gap-3">
<div class="w-fit">
<img
src="https://www.google.com/s2/favicons?sz=32&domain={url}"
alt="favicon"
class="size-3.5"
/>
</div>
<div class="w-full text-sm line-clamp-1">
{url}
</div>
</div>
<div
class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
>
<!-- -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</div>
</a>
{/each}
{/if}
</div>
</Collapsible>
@@ -38,6 +38,7 @@
export let isFirstMessage: boolean;
export let readOnly: boolean;
export let editCodeBlock = true;
export let topPadding = false;
let showDeleteConfirm = false;
@@ -146,7 +147,10 @@
{#if message.timestamp}
<div
class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
class="self-center text-xs font-medium first-letter:capitalize ml-0.5 translate-y-[1px] {($settings?.highContrastMode ??
false)
? 'dark:text-gray-900 text-gray-100'
: 'invisible group-hover:visible transition'}"
>
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
@@ -158,7 +162,10 @@
{:else if message.timestamp}
<div class="flex justify-end pr-2 text-xs">
<div
class="text-[0.65rem] invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize mb-0.5"
class="text-[0.65rem] font-medium first-letter:capitalize mb-0.5 {($settings?.highContrastMode ??
false)
? 'dark:text-gray-100 text-gray-900'
: 'invisible group-hover:visible transition text-gray-400'}"
>
<Tooltip content={dayjs(message.timestamp * 1000).format('LLLL')}>
<span class="line-clamp-1">{formatDate(message.timestamp * 1000)}</span>
@@ -207,7 +214,10 @@
</div>
<div class=" absolute -top-1 -right-1">
<button
class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
class=" bg-white text-black border border-white rounded-full {($settings?.highContrastMode ??
false)
? ''
: 'group-hover:visible invisible transition'}"
type="button"
on:click={() => {
editedFiles.splice(fileIdx, 1);
@@ -317,13 +327,18 @@
<div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
<div
class="rounded-3xl {($settings?.chatBubble ?? true)
? `max-w-[90%] px-5 py-2 bg-gray-50 dark:bg-gray-850 ${
? `max-w-[90%] px-4 py-1.5 bg-gray-50 dark:bg-gray-850 ${
message.files ? 'rounded-tr-lg' : ''
}`
: ' w-full'}"
>
{#if message.content}
<Markdown id={`${chatId}-${message.id}`} content={message.content} {topPadding} />
<Markdown
id={`${chatId}-${message.id}`}
content={message.content}
{editCodeBlock}
{topPadding}
/>
{/if}
</div>
</div>
@@ -433,7 +448,9 @@
{#if !readOnly}
<Tooltip content={$i18n.t('Edit')} placement="bottom">
<button
class="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 edit-user-message-button"
class="{($settings?.highContrastMode ?? false)
? ''
: '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 edit-user-message-button"
on:click={() => {
editMessageHandler();
}}
@@ -459,7 +476,9 @@
{#if message?.content}
<Tooltip content={$i18n.t('Copy')} placement="bottom">
<button
class="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"
class="{($settings?.highContrastMode ?? false)
? ''
: '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={() => {
copyToClipboard(message.content);
}}
@@ -482,30 +501,34 @@
</Tooltip>
{/if}
{#if !readOnly && (!isFirstMessage || siblings.length > 1)}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
class="invisible group-hover:visible p-1 rounded-sm dark:hover:text-white hover:text-black transition"
on:click={() => {
showDeleteConfirm = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.delete_message ?? false)}
{#if !readOnly && (!isFirstMessage || siblings.length > 1)}
<Tooltip content={$i18n.t('Delete')} placement="bottom">
<button
class="{($settings?.highContrastMode ?? false)
? ''
: 'invisible group-hover:visible'} p-1 rounded-sm dark:hover:text-white hover:text-black transition"
on:click={() => {
showDeleteConfirm = true;
}}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</Tooltip>
{/if}
{/if}
{#if $settings?.chatBubble ?? true}
@@ -21,6 +21,8 @@
import FolderMenu from '$lib/components/layout/Sidebar/Folders/FolderMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
export let folder = null;
@@ -63,6 +65,25 @@
}
};
const updateIconHandler = async (iconName) => {
const res = await updateFolderById(localStorage.token, folder.id, {
meta: {
icon: iconName
}
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
folder.meta = { ...folder.meta, icon: iconName };
toast.success($i18n.t('Folder updated successfully'));
selectedFolder.set(folder);
onUpdate(folder);
}
};
const deleteHandler = async () => {
const res = await deleteFolderById(localStorage.token, folder.id).catch((error) => {
toast.error(`${error}`);
@@ -116,9 +137,23 @@
<div class="mb-3 px-6 @md:max-w-3xl justify-between w-full flex relative group items-center">
<div class="text-center flex gap-3.5 items-center">
<div class=" rounded-full bg-gray-50 dark:bg-gray-800 p-3 w-fit">
<Folder className="size-4.5" strokeWidth="2" />
</div>
<EmojiPicker
onClose={() => {}}
onSubmit={(name) => {
console.log(name);
updateIconHandler(name);
}}
>
<button
class=" rounded-full bg-gray-50 dark:bg-gray-800 size-11 flex justify-center items-center"
>
{#if folder?.meta?.icon}
<Emoji className="size-6" shortCode={folder.meta.icon} />
{:else}
<Folder className="size-4.5" strokeWidth="2" />
{/if}
</button>
</EmojiPicker>
<div class="text-3xl">
{folder.name}
@@ -17,6 +17,7 @@
stream_response: null, // Set stream responses for this model individually
stream_delta_chunk_size: null, // Set the chunk size for streaming responses
function_calling: null,
reasoning_tags: null,
seed: null,
stop: null,
temperature: null,
@@ -175,6 +176,71 @@
</Tooltip>
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(
'Enable, disable, or customize the reasoning tags used by the model. "Enabled" uses default tags, "Disabled" turns off reasoning tags, and "Custom" lets you specify your own start and end tags.'
)}
placement="top-start"
className="inline-tooltip"
>
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Reasoning Tags')}
</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition shrink-0 outline-hidden"
type="button"
on:click={() => {
if ((params?.reasoning_tags ?? null) === null) {
params.reasoning_tags = ['', ''];
} else if ((params?.reasoning_tags ?? []).length === 2) {
params.reasoning_tags = true;
} else if ((params?.reasoning_tags ?? null) !== false) {
params.reasoning_tags = false;
} else {
params.reasoning_tags = null;
}
}}
>
{#if (params?.reasoning_tags ?? null) === null}
<span class="ml-2 self-center"> {$i18n.t('Default')} </span>
{:else if (params?.reasoning_tags ?? null) === true}
<span class="ml-2 self-center"> {$i18n.t('Enabled')} </span>
{:else if (params?.reasoning_tags ?? null) === false}
<span class="ml-2 self-center"> {$i18n.t('Disabled')} </span>
{:else}
<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
{/if}
</button>
</div>
</Tooltip>
{#if ![true, false, null].includes(params?.reasoning_tags ?? null) && (params?.reasoning_tags ?? []).length === 2}
<div class="flex mt-0.5 space-x-2">
<div class=" flex-1">
<input
class="text-sm w-full bg-transparent outline-hidden outline-none"
type="text"
placeholder={$i18n.t('Start Tag')}
bind:value={params.reasoning_tags[0]}
autocomplete="off"
/>
</div>
<div class=" flex-1">
<input
class="text-sm w-full bg-transparent outline-hidden outline-none"
type="text"
placeholder={$i18n.t('End Tag')}
bind:value={params.reasoning_tags[1]}
autocomplete="off"
/>
</div>
</div>
{/if}
</div>
<div class=" py-0.5 w-full justify-between">
<Tooltip
content={$i18n.t(

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