Compare commits

...

868 Commits

Author SHA1 Message Date
Timothy Jaeryang Baek e4c9734fcb Merge pull request #10038 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.13
2025-02-17 20:36:50 -08:00
Timothy Jaeryang Baek 188b928548 doc: changelog 2025-02-17 20:28:13 -08:00
Timothy Jaeryang Baek e8b11a78d1 chore: format 2025-02-17 20:18:03 -08:00
Timothy Jaeryang Baek d5ab2c6891 refac: readme 2025-02-17 19:52:34 -08:00
Timothy Jaeryang Baek fdaa6a9ee5 doc: readme 2025-02-17 19:51:41 -08:00
Timothy Jaeryang Baek c657e2a1bb doc: readme 2025-02-17 19:51:10 -08:00
Timothy Jaeryang Baek 658944e756 refac 2025-02-17 19:29:28 -08:00
Timothy Jaeryang Baek ba6cde8a87 fix: include_domain does NOT exist 2025-02-17 19:20:49 -08:00
Timothy Jaeryang Baek 9bd26d6faa enh: avif support 2025-02-17 18:55:48 -08:00
Timothy Jaeryang Baek a79a6a5c34 chore: format 2025-02-17 18:51:40 -08:00
Timothy Jaeryang Baek 0c461023fe enh: svg diagram download button 2025-02-17 18:50:23 -08:00
Timothy Jaeryang Baek 93731e4ca9 fix: temp chat message continue 2025-02-17 18:40:40 -08:00
Timothy Jaeryang Baek f72e28a56c enh: message delete confirm dialog 2025-02-17 18:33:21 -08:00
Timothy Jaeryang Baek dbe5d1ca08 refac 2025-02-17 18:16:23 -08:00
Timothy Jaeryang Baek ca0b7217d2 enh: full context web search 2025-02-17 18:14:26 -08:00
Timothy Jaeryang Baek 16ce8ab16c refac 2025-02-17 17:41:58 -08:00
Timothy Jaeryang Baek 70a5be08c7 refac 2025-02-17 16:33:14 -08:00
Timothy Jaeryang Baek b850b8a5a4 refac: styling 2025-02-17 16:33:01 -08:00
Timothy Jaeryang Baek 05af00305d enh: warning 2025-02-17 16:30:13 -08:00
Timothy Jaeryang Baek 2f75eef499 enh: code execution settings 2025-02-17 16:25:50 -08:00
Timothy Jaeryang Baek 3df6fa7ccb Merge pull request #10206 from ttv20/patch-1
i18n: Improve RTL text handling in chat and sidebar with auto-direction
2025-02-17 15:24:59 -08:00
Timothy Jaeryang Baek a5fa1cd835 Merge pull request #10209 from i-infra/iinf/fix-for-openrouter
[fix] no unambiguous indexing on "owned_by" - fix OpenRouter.ai
2025-02-17 15:24:21 -08:00
Elkana Bardugo 8d149348a0 Update MarkdownTokens.svelte: Revert text-align for download button in rtl
prevent button overriding because the download button is in absolute position and it doesn't have start/end properties
2025-02-17 22:43:34 +02:00
Elkana Bardugo d26569f636 Update ChatItem.svelte: Revert text-align for download button in rtl
prevent button overriding because the menu button is in absolute position and it doesn't have start/end properties
2025-02-17 22:42:37 +02:00
Elkana Bardugo 93590d224b Merge branch 'dev' into patch-1 2025-02-17 22:35:36 +02:00
Elkana Bardugo 0888ddde7c Fix card title direction and alignment (for RTL) 2025-02-17 22:03:03 +02:00
Elkana Bardugo 834617ab91 fix blockquote border line direction in RTL 2025-02-17 21:57:47 +02:00
Elkana Bardugo b73b8343d5 fix table header align on rtl 2025-02-17 21:40:11 +02:00
Elkana Bardugo ab94017e3a Auto text direction (fix for RTL) in most cases
to test it use the prompt:
"write please a sample headers (level 1,2,3), text (bold/italic), list, numbered list, quetes, table, code
write each example in hebrew
dont output markdown in code block"

need to be fixed:
* Redundant border properties in quotes:
Removed border-left as border-inline-start properly handles both LTR/RTL and by this prevents double borders in RTL layouts

* table header have `text-align: left` instead of start
e.g.
text-align and direction is not the save thing
the text direction is ok. the `dir="auto"` is fixing the direction שמג the text-align by default is set by the direction, if it set manually is should be start/end to respect the direction attribute
2025-02-17 21:27:05 +02:00
Timothy Jaeryang Baek 82189066e8 refac 2025-02-16 18:35:09 -08:00
Timothy Jaeryang Baek 3b856b64ed refac 2025-02-16 02:33:25 -08:00
Timothy Jaeryang Baek 63cf80a456 refac 2025-02-16 00:11:18 -08:00
Timothy Jaeryang Baek 91de8e082e refac: styling 2025-02-15 22:35:42 -08:00
Timothy Jaeryang Baek 19c340d3fb refac: pipelines 2025-02-15 22:25:18 -08:00
Timothy Jaeryang Baek ea15d91e29 Merge pull request #10016 from ferret99gt/tweak-and-add-advanced-parameters-for-ollama
feat: Tweak and add advanced parameters for ollama
2025-02-15 22:07:59 -08:00
Timothy Jaeryang Baek 5af926bac8 refac 2025-02-15 21:14:03 -08:00
Timothy Jaeryang Baek e4d6fc65ca refac: theme 2025-02-15 21:04:07 -08:00
Timothy Jaeryang Baek 87ff1a9038 refac: styling 2025-02-15 20:35:10 -08:00
Timothy Jaeryang Baek f2006c7efe refac: styling 2025-02-15 20:22:57 -08:00
Timothy Jaeryang Baek fab093395b Merge pull request #10065 from LuyiTian/dev
fix:Update Chat.svelte error handling of previous message
2025-02-15 20:11:40 -08:00
Timothy Jaeryang Baek b0ad5cd863 Merge pull request #10076 from crizCraig/local_date
fix: return local date from `getFormattedDate`
2025-02-15 20:10:56 -08:00
Timothy Jaeryang Baek fd8998a38f refac 2025-02-15 19:50:40 -08:00
Timothy Jaeryang Baek 052531ac13 refac 2025-02-15 19:41:09 -08:00
Timothy Jaeryang Baek 60e322962e refac 2025-02-15 19:35:12 -08:00
Timothy Jaeryang Baek e3fa48b6ce chore: tailwind v4 migration 2025-02-15 19:27:25 -08:00
Timothy Jaeryang Baek 30182d77cc refac 2025-02-15 19:08:07 -08:00
Timothy Jaeryang Baek a0126f371e refac: general admin settings 2025-02-15 18:24:13 -08:00
Timothy Jaeryang Baek ff60754091 refac: general admin settings 2025-02-15 18:15:50 -08:00
Timothy Jaeryang Baek b99f296494 Merge pull request #10075 from TiancongLx/dev
i18n: update zh-TW
2025-02-15 17:28:52 -08:00
Timothy Jaeryang Baek bbda717b69 refac: citations 2025-02-15 17:24:29 -08:00
Timothy Jaeryang Baek 3d0c06ccee refac: duckduckgo 2025-02-15 16:45:56 -08:00
i-infra 5e3742f899 and one last edit 2025-02-15 19:42:58 -05:00
i-infra fb12ee3f52 fix exception where openrouter doesn't populate - no longer index without fallback 2025-02-15 19:41:41 -05:00
Timothy Jaeryang Baek 38291e9af2 refac 2025-02-15 16:21:19 -08:00
Timothy Jaeryang Baek f0feb7bd35 refac 2025-02-15 16:10:35 -08:00
Craig Quiter e67eb89e05 style: black format 2025-02-15 10:53:16 -08:00
Craig Quiter 0dae8ded13 fix: return local date from getFormattedDate 2025-02-15 10:37:40 -08:00
Tiancong Li 4045e96e96 i18n: update zh-TW 2025-02-16 01:54:58 +08:00
Luyi Tian 1aa963a2fe Update Chat.svelte error handling of previous message
related to this discussion: https://github.com/open-webui/open-webui/discussions/10034. in some cases the previous message have error but it still generate a response. in such case we should also next round chat. so I update the if condition to add another checking of the content in previous message.
2025-02-15 21:25:12 +08:00
Timothy Jaeryang Baek 74c8690cd1 Merge pull request #9989 from Yimi81/feate-webloader-support-proxy
feat: web loader support proxy
2025-02-14 18:08:51 -08:00
Guofeng Yi b38acc8559 Merge branch 'dev' into feate-webloader-support-proxy 2025-02-15 09:50:02 +08:00
ferret99gt bb72957535 Update field label for Ollama convention 2025-02-14 18:02:30 -05:00
Timothy Jaeryang Baek 3e543691a4 Merge pull request #9988 from Yimi81/feat-support-async-load
feat: websearch support async docs load
2025-02-14 14:10:46 -08:00
Timothy Jaeryang Baek 2a67d3d6cd Merge pull request #10005 from bogdanr/main
Added Romanian translations
2025-02-14 14:09:35 -08:00
Timothy Jaeryang Baek a13f90fc60 Merge pull request #10002 from alpha-pet/fix-markdown-inline-whitespace
fix: remove whitespace to improve typesetting for inline Markdown
2025-02-14 13:28:18 -08:00
Timothy Jaeryang Baek c797ef3357 Merge pull request #9990 from panda44312/patch-4
i18n Update zh-cn
2025-02-14 13:27:55 -08:00
Timothy Jaeryang Baek 0746e2f682 Merge pull request #10021 from silentoplayz/ASCII-font-change
enh: "Open WebUI" ASCII Art with ANSI Shadow Font
2025-02-14 13:27:32 -08:00
Timothy Jaeryang Baek 8d63ee55f3 Merge pull request #10019 from liucoj/dev
fix: Domain filter list in Tavily search
2025-02-14 13:27:13 -08:00
Timothy Jaeryang Baek 7705ba16e4 Merge pull request #10024 from Kylapaallikko/main
18n: Update Finnish translation
2025-02-14 13:26:55 -08:00
Timothy Jaeryang Baek 3d26bca7c5 refac: deprecate [ ] prompt variables 2025-02-14 12:40:42 -08:00
Kylapaallikko 7037ae2f55 Update translation.json 2025-02-14 20:06:59 +02:00
Kylapaallikko 2448c4e290 Update translation.json 2025-02-14 20:01:14 +02:00
Kylapaallikko fcb1178dc7 Update translation.json 2025-02-14 19:57:58 +02:00
silentoplayz 665b67ff35 Update main.py
Change ASCII font for "Open WebUI" from "Standard" to "ANSI Shadow"
2025-02-14 12:52:57 -05:00
Kylapaallikko c70947c967 Update translation.json 2025-02-14 19:52:20 +02:00
Kylapaallikko 6b853ec36b Update translation.json 2025-02-14 19:47:48 +02:00
Kylapaallikko 9a170790ad Update translation.json 2025-02-14 19:31:05 +02:00
LiuC0j ca8458024e Merge pull request #1 from liucoj/liucoj-patch-1
fix: Domain Filter list in tavily.py
2025-02-14 18:21:38 +01:00
ferret99gt 941c787806 Add presence penalty (openAi/ollama/others) and repeat penalty (ollama only?)
- Add Presence Penalty, which I believe most providers have.
- Add repeat_penalty, an Ollama / Llama.cpp only parameter
- Widen temperature range to 2 (supported by many providers and similar front ends)
- Widen Top K range (supported by many providers and similar front ends)
- Adjust description of freqency_penalty in line with OpenAI documentation
- Add presence_penalty with an OpenAI documentation based description
- Add repeat_penalty with an Ollama documentation based description
2025-02-14 12:02:55 -05:00
LiuC0j a8e310429c FIx: Domain Filter list in tavily.py 2025-02-14 14:57:16 +01:00
LiuC0j 5ca39eb9fd Update tavily.py 2025-02-14 14:56:01 +01:00
Bogdan Radulescu 8d898f2d12 Added romanian translations 2025-02-14 15:17:25 +02:00
Thomas Rehn 9072959858 fix: remove whitespace to improve typesetting for inline Markdown 2025-02-14 13:16:03 +01:00
Panda 42119e2c52 Update translation.json 2025-02-14 09:04:48 +01:00
Panda f376a61578 Merge branch 'open-webui:main' into patch-4 2025-02-14 09:02:45 +01:00
Panda 85c22889da Update translation.json 2025-02-14 09:00:58 +01:00
Yimi81 d3f71930f0 web loader support proxy 2025-02-14 07:15:09 +00:00
Timothy Jaeryang Baek 2017856791 Merge pull request #9945 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.12
2025-02-13 23:12:46 -08:00
Timothy Jaeryang Baek 893bfe9d3a doc: changelog 2025-02-13 23:11:58 -08:00
Yimi81 ceef600223 support async load for websearch 2025-02-14 07:05:10 +00:00
Timothy Jaeryang Baek 99b50b39fe refac 2025-02-13 22:57:12 -08:00
Timothy Jaeryang Baek 304aed0f13 chore: format 2025-02-13 22:54:45 -08:00
Timothy Jaeryang Baek 7b37cdcebb Merge pull request #9980 from xring/web_search_serpapi
feat: add web search via SerpApi
2025-02-13 22:51:14 -08:00
Timothy Jaeryang Baek d9ee53b504 refac: placeholder performance 2025-02-13 22:45:03 -08:00
Timothy Jaeryang Baek fa92abce31 refac 2025-02-13 22:37:01 -08:00
Timothy Jaeryang Baek b7aeb432ed refac 2025-02-13 22:11:56 -08:00
Timothy Jaeryang Baek c9a8808b0d refac 2025-02-13 21:45:29 -08:00
xring 27d395ba06 feat: add web search via SerpApi 2025-02-14 12:24:58 +08:00
Timothy Jaeryang Baek da757069de refac 2025-02-13 17:06:55 -08:00
Timothy Jaeryang Baek e1435501aa fix 2025-02-13 15:17:41 -08:00
Timothy Jaeryang Baek 68519d6ca7 enh: multiple tool calls support 2025-02-13 14:50:46 -08:00
Timothy Jaeryang Baek f20b7c2f33 fix: user direct connections 2025-02-13 14:21:34 -08:00
Timothy Jaeryang Baek deeb04e695 Merge pull request #9942 from SleepyYui/bugfix-9635
fix: Fix text completion on playground page
2025-02-13 13:14:11 -08:00
Timothy Jaeryang Baek cd27845c75 fix: direct connections 2025-02-13 13:05:57 -08:00
SleepyYui ba4b0db462 fix playground text completion 2025-02-13 22:05:41 +01:00
Timothy Jaeryang Baek 95d56c737d Merge pull request #9907 from smonux/dev
fix: re-enable tools in native function calls after first invocation
2025-02-13 12:52:47 -08:00
Timothy Jaeryang Baek df1ff8f994 chore: remove flask 2025-02-13 12:52:17 -08:00
Timothy Jaeryang Baek c315634365 Merge pull request #9917 from nachogmd/dev
feat: Optional title generation
2025-02-13 12:51:54 -08:00
Kylapaallikko 70e8337536 Update translation.json 2025-02-13 22:36:45 +02:00
Nacho G. Mac Dowell c47eddce32 Optional title generation
The boolean configuration var ENABLE_TITLE_GENERATION makes title generation optionaL
2025-02-13 16:28:39 +01:00
Samuel e98f859a14 format 2025-02-13 12:19:24 +00:00
Samuel 86074cff6c fix: re-enable tool use when sending tool output 2025-02-13 12:18:21 +00:00
Timothy Jaeryang Baek 3ac3615ecd Merge pull request #9890 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-02-13 02:22:36 -08:00
Simon d590647d09 Update translation.json 2025-02-13 11:21:18 +01:00
Timothy Jaeryang Baek e4d7d41df6 Merge pull request #9889 from open-webui/dev
doc: changelog
2025-02-13 02:11:00 -08:00
Timothy Jaeryang Baek a461861030 doc: changelog 2025-02-13 02:10:36 -08:00
Timothy Jaeryang Baek 4d667e447d Merge pull request #9480 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.11
2025-02-13 02:06:04 -08:00
Timothy Jaeryang Baek 57e256be3e chore: bump 2025-02-13 02:05:26 -08:00
Timothy Jaeryang Baek f831104708 doc: changelog 2025-02-13 02:03:13 -08:00
Timothy Jaeryang Baek acb3eef619 refac 2025-02-13 01:57:02 -08:00
Timothy Jaeryang Baek 6acda2e6ec refac: code interpreter 2025-02-13 00:40:04 -08:00
Timothy Jaeryang Baek 6d899b80d0 refac: direct connections 2025-02-13 00:34:45 -08:00
Timothy Jaeryang Baek eb568695e7 chore: format 2025-02-13 00:13:33 -08:00
Timothy Jaeryang Baek 7ff719938a refac: citations 2025-02-12 23:55:14 -08:00
Timothy Jaeryang Baek a1dc2664c2 refac 2025-02-12 23:49:00 -08:00
Timothy Jaeryang Baek c28d82b10d Merge branch 'dev' of https://github.com/open-webui/open-webui into dev 2025-02-12 23:38:53 -08:00
Timothy Jaeryang Baek c5b92dbffa chore: bump 2025-02-12 23:38:48 -08:00
Timothy Jaeryang Baek c0dfafca70 Merge pull request #9844 from Seniorsimo/bug-9773
Fix - Added support for API tool_calls when stream false
2025-02-12 23:37:24 -08:00
Timothy Jaeryang Baek 7e73790d6c refac 2025-02-12 23:33:53 -08:00
Timothy Jaeryang Baek 5626426c31 chore: format 2025-02-12 23:28:57 -08:00
Timothy Jaeryang Baek 83e5db7be7 refac 2025-02-12 23:26:47 -08:00
Timothy Jaeryang Baek 2b7f9d14d0 refac 2025-02-12 23:21:16 -08:00
Timothy Jaeryang Baek c83e68282d feat: direct connections integration 2025-02-12 22:56:33 -08:00
Simone b0ade6c04c Fixed typo 2025-02-13 07:04:02 +01:00
Timothy Jaeryang Baek 304ce2a14d Merge pull request #9865 from feynmanliang/fix-9864
fix - Fixes citation modal toggle when clicking inliine citations and # citations > 3
2025-02-12 14:32:02 -08:00
Timothy Jaeryang Baek 67958c02b4 refac 2025-02-12 14:29:43 -08:00
Timothy Jaeryang Baek 9f9adce72c fix 2025-02-12 14:23:53 -08:00
Feynman Liang 361a98980e Retab collapsible 2025-02-12 13:49:37 -08:00
Feynman Liang 113addef46 Merge branch 'dev' into fix-9864 2025-02-12 13:47:54 -08:00
Feynman Liang 08bc7d4cfe Uncollapse so all source buttons visible 2025-02-12 13:46:42 -08:00
Feynman Liang 3f24062416 Adds source-* ids when citations > 3 2025-02-12 12:30:23 -08:00
Timothy Jaeryang Baek a0df6cce40 refac 2025-02-12 01:41:59 -08:00
Timothy Jaeryang Baek 2e77d5ef0e refac 2025-02-12 01:32:49 -08:00
Timothy Jaeryang Baek 0bec9122c1 refac 2025-02-12 01:22:53 -08:00
Timothy Jaeryang Baek 431e97b03a wip: direct models 2025-02-12 01:17:30 -08:00
Simone 7dc000a6b9 added support for API tool_calls if stream false 2025-02-12 09:11:26 +01:00
Timothy Jaeryang Baek 982b1fb7e2 wip: direct connections 2025-02-12 00:08:51 -08:00
Timothy Jaeryang Baek cd2f4142d5 fix: user settings save issue 2025-02-11 23:42:31 -08:00
Timothy Jaeryang Baek 862b5a947a refac: connections 2025-02-11 23:21:09 -08:00
Timothy Jaeryang Baek 689317c5b5 refac 2025-02-11 23:13:48 -08:00
Timothy Jaeryang Baek 8daa549146 refac 2025-02-11 23:12:00 -08:00
Timothy Jaeryang Baek 153413dc54 refac 2025-02-11 22:33:03 -08:00
Timothy Jaeryang Baek afe92fd227 refac 2025-02-11 22:31:27 -08:00
Timothy Jaeryang Baek 3891161173 feat: direct api settings backend 2025-02-11 22:29:45 -08:00
Timothy Jaeryang Baek 24cbc469cc enh: allow image only message 2025-02-11 21:52:31 -08:00
Timothy Jaeryang Baek a5bba20915 Merge pull request #9837 from silverriver/patch-1
feat Make Google PSE search return more than 10 google search results
2025-02-11 21:36:53 -08:00
Timothy Jaeryang Baek f916fbba56 enh: custom code interpreter prompt 2025-02-11 21:36:16 -08:00
Timothy Jaeryang Baek 953e408077 refac 2025-02-11 21:13:42 -08:00
Timothy Jaeryang Baek ab70f1bb50 Merge pull request #9486 from rragundez/store-images
Use DB for generated images
2025-02-11 21:12:17 -08:00
Timothy Jaeryang Baek 627ed7c9ce refac 2025-02-11 21:03:38 -08:00
Silver 7e08373ae5 Update google_pse.py to return results more than 10 2025-02-12 13:01:09 +08:00
Timothy Jaeryang Baek 8906a2e260 Merge pull request #9803 from BochaAI/main
add Bocha
2025-02-11 21:01:04 -08:00
Timothy Jaeryang Baek 8199281eee Merge pull request #9784 from aleixdorca/dev
Update catalan translation.json
2025-02-11 18:58:01 -08:00
Timothy Jaeryang Baek a1554559c4 Merge pull request #9800 from rragundez/404-non-html
WIP Return 404 for non html files
2025-02-11 18:57:51 -08:00
Timothy Jaeryang Baek 591ca35275 Merge pull request #9827 from TiancongLx/dev
i18n: update zh-TW
2025-02-11 18:55:18 -08:00
Tiancong Li 7057814c3b i18n: update zh-TW 2025-02-12 05:37:15 +08:00
Rodrigo Agundez 31ee7a68a2 Only javascript files 2025-02-11 21:21:27 +08:00
Rodrigo Agundez 5d68737b31 Return 404 for non html files 2025-02-11 18:49:36 +08:00
Aleix Dorca 92a584bf3c Update catalan translation.json 2025-02-11 06:45:03 +01:00
Timothy Jaeryang Baek 16bae8f33a refac 2025-02-10 13:53:16 -08:00
Timothy Jaeryang Baek 7ebb07c04c refac 2025-02-10 13:44:25 -08:00
Timothy Jaeryang Baek a273cba0fb refac: code intepreter 2025-02-10 13:12:05 -08:00
Timothy Jaeryang Baek 610f9d039a fix: account page issue 2025-02-10 13:00:44 -08:00
Timothy Jaeryang Baek 0f12c4d14f fix: ollama tool calling
Co-Authored-By: Simone <6941100+seniorsimo@users.noreply.github.com>
2025-02-10 12:18:49 -08:00
Timothy Jaeryang Baek 293f64353d Merge pull request #9751 from pseudorm/bug/user-signup/fix-oauth-username-claim-has-no-effect
Fix: Setting OAUTH_USERNAME_CLAIM has no effect on username for user during oauth signup
2025-02-10 11:26:56 -08:00
Timothy Jaeryang Baek 63a5ee3adb refac 2025-02-10 11:25:38 -08:00
Timothy Jaeryang Baek b935a839fe Merge pull request #9744 from hurxxxx/fix/title-tag-gen-at-model
fix: title and tag generation did not work when selecting "atSelectedModel"
2025-02-10 11:16:03 -08:00
Timothy Jaeryang Baek 54e0299dda chore: bump 2025-02-10 11:14:04 -08:00
pseudorm 336d1d95c5 Merge branch 'dev' into bug/user-signup/fix-oauth-username-claim-has-no-effect 2025-02-10 22:00:20 +08:00
chester.cheng 1b6e12a255 add warning 2025-02-10 20:49:55 +08:00
chester.cheng db0a12f9f1 check name is none 2025-02-10 20:36:01 +08:00
Timothy Jaeryang Baek abfe868732 enh: code interpreter global toggle 2025-02-10 02:28:01 -08:00
Timothy Jaeryang Baek f9c5819314 enh: code interpreter jupyter support 2025-02-10 02:25:02 -08:00
luckyman-yan 31360fe991 add Bocha 2025-02-10 16:44:47 +08:00
Gunwoo Hur 91d0632bf4 fix: Fix issue where title and tag generation did not work when selecting the At model 2025-02-10 17:06:22 +09:00
Timothy Jaeryang Baek 29c781875d refac 2025-02-10 00:02:23 -08:00
Timothy Jaeryang Baek d95e5e0ba5 enh: kokorojs call support 2025-02-09 23:54:24 -08:00
Timothy Jaeryang Baek 205ce635f6 feat: Kokoro-js TTS support 2025-02-09 23:42:27 -08:00
Timothy Jaeryang Baek a22d1d5410 refac: code block 2025-02-09 22:43:32 -08:00
Timothy Jaeryang Baek 60095598ec chore: format 2025-02-09 22:20:47 -08:00
Timothy Jaeryang Baek 426f8f29ad refac 2025-02-09 22:19:02 -08:00
Timothy Jaeryang Baek 5551a4bff5 Merge pull request #9739 from yukiteruamano/fix-spanish
i18n: fix Spanish (es_ES) translations
2025-02-09 22:15:39 -08:00
Timothy Jaeryang Baek de2026b5cc Merge pull request #9733 from rragundez/patch-ssrf
Prevent SSRF and HTML injection
2025-02-09 22:15:03 -08:00
Jose Maldonado aka Yukiteru b1711e9491 i18n: fix a bad (badword: propmt) translation in es_ES (Spanish) 2025-02-10 01:07:32 -04:00
Jose Maldonado aka Yukiteru a756adfaee i18n: fix translations for es_ES (Spanish) 2025-02-10 00:37:59 -04:00
Rodrigo Agundez 167c8bf00d Prevent SSRF and HTML injection 2025-02-10 10:35:10 +08:00
Timothy Jaeryang Baek 3e1452902f chore: bump 2025-02-09 16:02:09 -08:00
Timothy Jaeryang Baek 790a747647 fix 2025-02-09 13:18:26 -08:00
Timothy Jaeryang Baek a1b31f99ae Merge pull request #9688 from yfzhou0904/main
feat: if autocomplete-generation is turned off, frontend should not attempt autocomplete request
2025-02-09 13:07:43 -08:00
Timothy Jaeryang Baek d5a815b19c Merge pull request #9693 from vinsdragonis/main
fix: Fixed error occurring when using OpenSearch as a vector db
2025-02-09 13:06:19 -08:00
Timothy Jaeryang Baek 40bba83cee Delete docs/OpenSearch docker setup.md 2025-02-09 13:06:05 -08:00
Timothy Jaeryang Baek f25df15997 Merge pull request #9596 from engineeringpatrick/main
feat: add s3 key prefix support
2025-02-09 13:05:41 -08:00
Timothy Jaeryang Baek b7f676311f Merge pull request #9675 from manwallet/patch-1
Update translation.json
2025-02-09 13:05:15 -08:00
Timothy Jaeryang Baek ae6ca4f16c Merge pull request #9704 from s1adem4n/main
i18n: Add missing German translations
2025-02-09 13:05:02 -08:00
Jonathan 7ab674991e add missing german translations 2025-02-09 17:19:21 +01:00
Vineeth B V b9d049cb05 Create OpenSearch docker setup.md 2025-02-09 16:43:34 +05:30
Vineeth B V 5a1652bc63 Update requirements.txt 2025-02-09 16:14:41 +05:30
yfzhou 11f0c48434 frontend stores ENABLE_AUTOCOMPLETE_GENERATION state, prevents making autocomplete request if turned off 2025-02-09 17:13:54 +08:00
btcman bd9d60b5a0 Update translation.json
Some Chinese translation issues
2025-02-09 10:45:19 +08:00
Timothy Jaeryang Baek 43df7e1424 fix: sensitive input button type 2025-02-08 14:02:59 -08:00
Timothy Jaeryang Baek 425a8a9178 fix: connection config shifting 2025-02-08 14:01:56 -08:00
Timothy Jaeryang Baek 338a34563a fix: web search config 2025-02-08 13:44:08 -08:00
Rodrigo Agundez 2b05c9d944 Move file metadata into the [meta][data] key 2025-02-08 19:35:27 +09:00
Timothy Jaeryang Baek 181fca4707 refac: transformer.js 2025-02-08 02:34:30 -08:00
Timothy Jaeryang Baek 9be8bea6f4 fix: filter 2025-02-08 01:07:05 -08:00
Timothy Jaeryang Baek ee0152c1fa Merge pull request #9634 from zoupingshi/dev
chore: fix some typos
2025-02-07 23:07:25 -08:00
Timothy Jaeryang Baek 8c2164928a Merge pull request #9242 from NachoNoCheese/dev
feat: Add Deepgram STT Support
2025-02-07 23:06:57 -08:00
Timothy Jaeryang Baek 79c0b45543 Merge pull request #9631 from XingjianXie/remove_inlet_outlet_duplication
Refactor common code between inlet and outlet
2025-02-07 22:57:49 -08:00
Timothy Jaeryang Baek 3dde2f67cf refac 2025-02-07 22:57:39 -08:00
Timothy Jaeryang Baek dee07a6260 Merge pull request #9629 from rragundez/handle-no-picture
Handle no picture when retrieving from Microsoft EntraID
2025-02-07 22:52:35 -08:00
Timothy Jaeryang Baek 6862081d6b Merge pull request #9641 from SentinalMax/bugfix/GGUF-upload-issue
Fix: GGUF model upload instability
2025-02-07 22:51:44 -08:00
SentinalMax 95aaacfeb4 fixed GGUF model upload instability 2025-02-07 22:52:24 -06:00
zoupingshi d39a274ef8 chore: fix some typos
Signed-off-by: zoupingshi <hangfachang@outlook.com>
2025-02-08 12:14:01 +08:00
Rodrigo Agundez 546ef6ab42 Check is response is OK from retrieve the picture if not then default 2025-02-08 09:49:16 +09:00
Timothy Jaeryang Baek 4271b652a5 Merge pull request #9619 from XingjianXie/fix_after_tag
Fix tag_content_handler issue
2025-02-07 14:42:14 -08:00
Timothy Jaeryang Baek febc5d392f Merge pull request #9620 from tarmst/add-oauth-debug-logs
feat: Adding debug logs for oauth role & group management
2025-02-07 14:41:43 -08:00
Xingjian Xie c56bedc5ff Fix tag_content_handler issue: after_tag should be remove from the current content_blocks 2025-02-07 20:15:54 +00:00
tarmst 85912d726e Adding debug logs for oauth role & group management 2025-02-07 19:53:25 +00:00
Timothy Jaeryang Baek 4b4a86d4e7 chore: dependencies 2025-02-07 11:51:27 -08:00
Patrick Deniso f8a8218149 fix bug where '/' was not properly inserted in s3 key strings 2025-02-07 14:42:16 -05:00
Timothy Jaeryang Baek 3c5ac4ace5 Merge pull request #9416 from abdalrohman/fix_filter_domains
feat(ui): implement domain filter list for web search settings
2025-02-07 11:23:15 -08:00
Timothy Jaeryang Baek c092db379e fix 2025-02-07 11:23:04 -08:00
Timothy Jaeryang Baek 8a54df1ed2 Merge pull request #9516 from cloudron-io/oauth_username
oidc: pick up username correctly
2025-02-07 11:18:53 -08:00
Timothy Jaeryang Baek 65cef7c3e4 Merge pull request #9541 from EntropyYue/dev
feat: optimize time display
2025-02-07 11:17:39 -08:00
Timothy Jaeryang Baek 035330f70e Merge pull request #9505 from binxn/patch-1
Update jina_search.py
2025-02-07 11:17:13 -08:00
Timothy Jaeryang Baek 897c9b2616 Merge pull request #9588 from TiancongLx/dev
i18n: update zh-TW
2025-02-07 11:15:54 -08:00
Timothy Jaeryang Baek 4ed544a4b5 Merge pull request #9598 from Mistrick/patch
fix: correct max seed for comfyui
2025-02-07 11:15:41 -08:00
Patrick Deniso 7f82476926 use key_prefix in rest of S3StorageProvider 2025-02-07 13:56:57 -05:00
Mistrick 94f56db5ee fix max seed for comfyui 2025-02-08 01:10:18 +07:00
Patrick Deniso 5ca6afc0fc add s3 key prefix support 2025-02-07 12:15:54 -05:00
Tiancong Li 5ef00dece9 i18n: update zh-TW 2025-02-07 19:13:51 +08:00
EntropyYue 3a2d964d39 enh: optimize time display 2025-02-07 07:43:39 +08:00
Rodrigo Agundez ffb9e73975 Save image metadata to DB 2025-02-07 08:32:06 +09:00
Rodrigo Agundez 312f273a1b Add extension to image filename 2025-02-07 08:22:20 +09:00
Rodrigo Agundez 4974c9cbb0 Refactor upload function 2025-02-07 08:12:04 +09:00
Xingjian Xie 89669a21fc Refactor common code between inlet and outlet 2025-02-06 23:07:47 +00:00
Rodrigo Agundez 7e97e9dcc9 Improve style 2025-02-07 07:37:18 +09:00
Rodrigo Agundez ac3338265d Set get_config as the name of the function 2025-02-07 07:30:58 +09:00
Girish Ramakrishnan 8215aa36d0 oidc: pick up username correctly 2025-02-06 17:59:16 +01:00
binxn 88db4ca7ba Update jina_search.py
Updated Jina's search function in order to use POST and make use of the result count passed by the user

Note: Jina supports a max of 10 result count
2025-02-06 14:30:27 +01:00
Timothy Jaeryang Baek 14398ab628 refac: styling 2025-02-06 01:28:33 -08:00
Rodrigo Agundez 8d43fdadc1 Add functionality in other image generation types 2025-02-06 18:24:57 +09:00
Timothy Jaeryang Baek 8ca21ea838 refac: styling 2025-02-06 01:07:01 -08:00
Rodrigo Agundez 159578dfd4 Enable usage of the DB to store generated images 2025-02-06 17:59:59 +09:00
Timothy Jaeryang Baek 4a33956e77 Merge pull request #9378 from open-webui/dependabot/npm_and_yarn/npm_and_yarn-4be97f86df
build(deps-dev): bump vitest from 1.6.0 to 1.6.1 in the npm_and_yarn group across 1 directory
2025-02-06 00:55:18 -08:00
Timothy Jaeryang Baek a1e26016bb fix: new connections config 2025-02-06 00:52:22 -08:00
Timothy Jaeryang Baek feffdf197f doc: readme 2025-02-06 00:38:06 -08:00
Timothy Jaeryang Baek a023667e1e fix: user params save issue 2025-02-06 00:37:10 -08:00
Timothy Jaeryang Baek b3a7cb938f Merge pull request #9463 from roryeckel/cleanup-code-interpreter-markdown
fix: Improve automatic cleanup of code_interpreter formatting
2025-02-06 00:32:55 -08:00
Timothy Jaeryang Baek 10e2b3c055 Merge pull request #9471 from hurxxxx/fix/o3_not_support_max_tokens
fix: o3 does not support max_tokens parameter
2025-02-06 00:31:48 -08:00
Vineeth B V 7c78facfd9 Update opensearch.py 2025-02-06 13:36:11 +05:30
hurxxxx b9480c0e8a fix : o1 should also be applied 2025-02-06 16:53:04 +09:00
hurxxxx 80e123f58f fix : O3 also does not support the max_tokens parameter, so title generation is not possible when using the O3 model 2025-02-06 16:41:24 +09:00
Vineeth B V fd6b039859 Added a query method for OpenSearch vector db.
- This PR aims to address the error 400: "**'OpenSearchClient' object has no attribute 'query'**".
- With the implemented query() method, this issue should be resolved and allow uploaded documents to be vectorized and retrieved based on the given query.
2025-02-06 12:04:14 +05:30
Rory 74b971b888 refac: clean up solution for correcting code_interpreter backticks 2025-02-05 23:38:35 -06:00
Rory c676303a55 enh: automatically remove incorrect backticks before code_interpreter tags 2025-02-05 23:26:13 -06:00
Timothy Jaeryang Baek 397595de9d Merge pull request #9456 from mitomac/fix-oidc-email-claim-new
fix: check for email claim before skipping userinfo endpoint
2025-02-05 20:00:35 -08:00
D. MacAlpine 34b62e71cc fix: check for email claim before skipping userinfo endpoint 2025-02-05 21:31:55 -05:00
Timothy Jaeryang Baek e9d6ada25c Merge pull request #9445 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.10
2025-02-05 14:46:41 -08:00
Timothy Jaeryang Baek b8fe9497fb refac 2025-02-05 14:46:25 -08:00
Timothy Jaeryang Baek ed49c2e75a doc: changelog 2025-02-05 14:42:28 -08:00
Timothy Jaeryang Baek 7ba717d171 refac: system prompt template 2025-02-05 14:35:18 -08:00
Timothy Jaeryang Baek fb92694d61 Merge pull request #9415 from aindriu80/main
i18n Updated Irish translations
2025-02-05 14:29:26 -08:00
Timothy Jaeryang Baek d9f17225ef refac 2025-02-05 14:26:09 -08:00
Timothy Jaeryang Baek 1e67956db9 refac 2025-02-05 14:20:51 -08:00
Timothy Jaeryang Baek 648878782b refac: response parse 2025-02-05 14:10:53 -08:00
Timothy Jaeryang Baek ff84c120f5 fix: api usage 2025-02-05 13:18:17 -08:00
M.Abdulrahman Alnaseer 68703951e8 feat(ui): implement domain filter list for web search settings 2025-02-05 19:14:40 +03:00
Aindriú Mac Giolla Eoin e56b8d99ee Updated Irish translations from 0.5.9 2025-02-05 15:50:52 +00:00
Timothy Jaeryang Baek ab94468ffa Merge pull request #9387 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.9
2025-02-05 02:38:21 -08:00
Timothy Jaeryang Baek 13c7d96515 refac 2025-02-05 02:38:05 -08:00
Timothy Jaeryang Baek bffbf7e238 doc: changelog 2025-02-05 02:35:16 -08:00
Timothy Jaeryang Baek 90cd6f272f fix: tag support 2025-02-05 02:33:40 -08:00
Timothy Jaeryang Baek c36c63f1e8 fix: <think> tag 2025-02-05 02:10:28 -08:00
dependabot[bot] 98ba0c37b9 build(deps-dev): bump vitest
Bumps the npm_and_yarn group with 1 update in the / directory: [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `vitest` from 1.6.0 to 1.6.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v1.6.1/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-05 09:25:26 +00:00
Timothy Jaeryang Baek dc3b2f1f1e Merge pull request #9190 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.8
2025-02-05 01:16:35 -08:00
Timothy Jaeryang Baek 1ab66e83c6 refac: styling 2025-02-05 01:14:46 -08:00
Timothy Jaeryang Baek 3b7926f00d doc: changelog 2025-02-05 01:09:23 -08:00
Timothy Jaeryang Baek e27de61318 Merge pull request #9377 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-02-05 01:08:39 -08:00
Timothy Jaeryang Baek cda6b5ad17 refac: styling 2025-02-05 01:07:38 -08:00
Simon 3f188b0b86 Update translation.json 2025-02-05 10:05:38 +01:00
Timothy Jaeryang Baek b33b49789e enh: responsive styling 2025-02-05 01:03:40 -08:00
Timothy Jaeryang Baek cce1762cb8 refac: tool calling 2025-02-05 01:03:16 -08:00
Simon 1fbbd36433 Update translation.json 2025-02-05 10:02:59 +01:00
Timothy Jaeryang Baek f6b955e6a0 chore: format 2025-02-05 00:44:07 -08:00
Timothy Jaeryang Baek 41ca399dfd chore: format 2025-02-05 00:42:51 -08:00
Timothy Jaeryang Baek 5385d39420 Merge pull request #9374 from KarlLee830/translate
i18n: Update Chinese translation
2025-02-05 00:38:18 -08:00
Timothy Jaeryang Baek d4a18a5765 refac: always on web search 2025-02-05 00:37:47 -08:00
KarlLee830 3f547eb395 i18n: Update Chinese translation 2025-02-05 16:34:51 +08:00
Timothy Jaeryang Baek 336984c254 Merge pull request #9370 from hurxxxx/feat/always-web-search
feat: Add "Always-On Web Search" configuration.
2025-02-05 00:27:29 -08:00
Timothy Jaeryang Baek 58bb4758df chore: bump 2025-02-05 00:25:11 -08:00
Timothy Jaeryang Baek 102b56b938 doc: changelog 2025-02-05 00:23:30 -08:00
Timothy Jaeryang Baek e41a2682f5 chore: format 2025-02-05 00:07:45 -08:00
Timothy Jaeryang Baek f6f8c08cb0 Merge pull request #9068 from df-cgdm/main
**feat** Add user related headers when calling an external embedding api
2025-02-05 00:05:44 -08:00
Timothy Jaeryang Baek 8e831a5433 Merge pull request #9358 from KarlLee830/translate
i18n: Update Chinese translation
2025-02-05 00:04:12 -08:00
Timothy Jaeryang Baek c778345e84 refac 2025-02-05 00:01:24 -08:00
hurxxxx 0e3c1ac392 Merge remote-tracking branch 'upstream/dev' into feat/always-web-search
# Conflicts:
#	src/lib/components/chat/Chat.svelte
2025-02-05 16:56:34 +09:00
Timothy Jaeryang Baek 2b0af33274 refac 2025-02-04 23:48:01 -08:00
Timothy Jaeryang Baek 9624493b55 refac 2025-02-04 23:44:51 -08:00
Timothy Jaeryang Baek e349e26cd8 refac 2025-02-04 23:20:09 -08:00
Timothy Jaeryang Baek 314b674f32 feat: native tool calling support 2025-02-04 23:05:14 -08:00
Timothy Jaeryang Baek 7766a08b70 refac: ollama tool calls 2025-02-04 21:42:49 -08:00
Timothy Jaeryang Baek c5a9cfacf1 refac 2025-02-04 21:20:03 -08:00
Timothy Jaeryang Baek 8d520eeecd fix: ollama native tool calling 2025-02-04 21:08:26 -08:00
Timothy Jaeryang Baek f88a80ac47 wip: pyodide kernel 2025-02-04 21:02:11 -08:00
Timothy Jaeryang Baek e7da506add refac: middleware 2025-02-04 21:01:53 -08:00
Timothy Jaeryang Baek 2e61ea7cc9 refac: chat 2025-02-04 20:56:13 -08:00
KarlLee830 0a5b421ac1 i18n: Update Chinese translation 2025-02-05 12:53:06 +08:00
Timothy Jaeryang Baek a4ea2e0996 fix: multi response message 2025-02-04 20:33:02 -08:00
Timothy Jaeryang Baek 23df351239 refac: ollama tool calling support
Co-Authored-By: smonux <85928277+smonux@users.noreply.github.com>
2025-02-04 19:22:08 -08:00
Timothy Jaeryang Baek ec9be0d20d feat: native tool calling frontend 2025-02-04 19:14:59 -08:00
Timothy Jaeryang Baek 533b62f062 refac: deprecate non web worker pyodide 2025-02-04 18:41:53 -08:00
Timothy Jaeryang Baek fba25c6cec refac 2025-02-04 18:36:22 -08:00
Timothy Jaeryang Baek 6ee924924e refac: code interpreter 2025-02-04 18:33:22 -08:00
Timothy Jaeryang Baek 9c0a783991 refac: pyodide 2025-02-04 17:29:10 -08:00
Timothy Jaeryang Baek 266899acc0 refac 2025-02-04 14:45:47 -08:00
Timothy Jaeryang Baek 2b0c5f322f fix: suggestions 2025-02-04 14:41:42 -08:00
Timothy Jaeryang Baek 08ce91ad86 Merge pull request #9325 from Mavyre/patch-1
fix: Corrected chat generation error after failed web search query generation
2025-02-04 14:02:10 -08:00
Timothy Jaeryang Baek d062af4129 Merge pull request #9332 from le-ross/main
fixing french typo
2025-02-04 14:01:38 -08:00
Timothy Jaeryang Baek 5cda8a57e7 Merge pull request #9337 from abdalrohman/exa_integration
feat: implement Exa search engine integration
2025-02-04 14:00:06 -08:00
Timothy Jaeryang Baek b02a38487c Merge pull request #9328 from kahghi/fix-unstructured-pkg-version
fix: increment unstructured package version to fix UnstructuredExcelLoader
2025-02-04 13:59:39 -08:00
M.Abdulrahman Alnaseer 2bb6b49f11 feat: implement Exa search engine integration 2025-02-04 21:13:16 +03:00
rossm d6b88c7ef7 fixing french typo 2025-02-04 10:43:19 -05:00
kahghi 1868cf0a91 increment unstructured package version 2025-02-04 22:40:49 +08:00
Mavyre bbffdeb758 Corrected chat generation error after failed web search query generation 2025-02-04 12:13:54 +01:00
Timothy Jaeryang Baek ce08ea5f6c refac 2025-02-04 01:06:03 -08:00
Timothy Jaeryang Baek 12a7f4d43b refac: styling 2025-02-04 01:03:41 -08:00
Timothy Jaeryang Baek 6ba9ccdd79 refac: styling 2025-02-04 00:55:55 -08:00
Timothy Jaeryang Baek 0b01fd9d6f Merge pull request #9322 from jannikstdl/smart-suggestions
feat: smart suggestions
2025-02-04 00:38:56 -08:00
Jannik Streidl 17c1c2f0f3 fix: only animate on change 2025-02-04 09:36:11 +01:00
Jannik Streidl b8b82b9156 i18n 2025-02-04 09:17:55 +01:00
Jannik Streidl 43a490457d feat: smart suggestions 2025-02-04 09:14:02 +01:00
Timothy Jaeryang Baek f58ae6bd17 refac: styling 2025-02-03 22:11:01 -08:00
Timothy Jaeryang Baek 49e5d01213 refac: pyodide 2025-02-03 22:07:59 -08:00
Timothy Jaeryang Baek b20603e01a refac 2025-02-03 22:05:52 -08:00
Timothy Jaeryang Baek 3adfa29f7d chore: format 2025-02-03 21:56:35 -08:00
Timothy Jaeryang Baek a38ce75eda refac: styling 2025-02-03 21:25:16 -08:00
Timothy Jaeryang Baek 7e971f9dd1 refac 2025-02-03 21:13:27 -08:00
Timothy Jaeryang Baek 074a443790 refac 2025-02-03 21:10:49 -08:00
Timothy Jaeryang Baek 454294a101 refac: design
thanks @jannikstdl for nudging me to redesign 🙌

Co-Authored-By: Jannik S. <jannik@streidl.dev>
2025-02-03 20:53:46 -08:00
Timothy Jaeryang Baek d1c7a3cff3 enh: code interpreter prompt 2025-02-03 20:49:54 -08:00
Timothy Jaeryang Baek 1b8dc673e7 Merge pull request #9314 from roryeckel/validate-rag-urls
Validate URLs returned by search engine
2025-02-03 20:26:34 -08:00
Timothy Jaeryang Baek 71039b00a8 chore: format 2025-02-03 20:21:49 -08:00
Timothy Jaeryang Baek 16d62d7869 refac: tooltip 2025-02-03 20:21:11 -08:00
Timothy Jaeryang Baek 0ae7d12343 refac 2025-02-03 20:15:19 -08:00
Timothy Jaeryang Baek 1c518ab688 refac: styling 2025-02-03 20:13:14 -08:00
Timothy Jaeryang Baek ebde5c47d9 refac: styling 2025-02-03 20:06:58 -08:00
Timothy Jaeryang Baek 2bf68593af refac: styling 2025-02-03 20:06:17 -08:00
Timothy Jaeryang Baek b61138e676 refac: message input 2025-02-03 19:54:20 -08:00
Timothy Jaeryang Baek 2517a2b653 refac: styling 2025-02-03 19:34:36 -08:00
Timothy Jaeryang Baek 6d797820f5 refac: input ui 2025-02-03 19:18:31 -08:00
Timothy Jaeryang Baek f527f31d2b refac 2025-02-03 19:11:23 -08:00
Timothy Jaeryang Baek 94c6428ae2 refac: chat 2025-02-03 19:06:15 -08:00
Rory 1a1cc1514f Merge branch 'dev' into validate-rag-urls 2025-02-03 20:44:59 -06:00
Timothy Jaeryang Baek 8fdd3024f7 refac 2025-02-03 18:36:49 -08:00
Timothy Jaeryang Baek aa7184ae0d refac 2025-02-03 18:17:38 -08:00
Timothy Jaeryang Baek 51de2ffcc0 fix: ff 2025-02-03 18:03:38 -08:00
Timothy Jaeryang Baek e5bb0f014a refac 2025-02-03 17:19:33 -08:00
Rory 310a18eef2 Merge branch 'dev' into validate-rag-urls 2025-02-03 19:09:11 -06:00
Timothy Jaeryang Baek df07d671ce refac 2025-02-03 16:21:44 -08:00
Rory 3db6b4352f fix: Filter out invalid RAG web URLs (continued) 2025-02-03 18:18:49 -06:00
Timothy Jaeryang Baek 31c176a485 refac 2025-02-03 16:18:07 -08:00
Timothy Jaeryang Baek 943a57474f refac 2025-02-03 16:14:00 -08:00
Timothy Jaeryang Baek 1635dcb69b refac 2025-02-03 16:07:43 -08:00
Rory 121a13d4ed fix: Filter to valid RAG web search URLs 2025-02-03 17:37:20 -06:00
Timothy Jaeryang Baek e3214d08b5 refac 2025-02-03 15:20:27 -08:00
Timothy Jaeryang Baek db0ff839dd refac 2025-02-03 14:24:30 -08:00
Timothy Jaeryang Baek d87a7b2f8b refac: styling 2025-02-03 14:16:22 -08:00
Timothy Jaeryang Baek b891a398fd refac 2025-02-03 13:50:57 -08:00
Timothy Jaeryang Baek c85ce07f92 refac 2025-02-03 13:42:03 -08:00
Timothy Jaeryang Baek 5c0854c6b3 Merge pull request #9287 from taylorwilsdon/authenticate_gooogle_settings
fix: Restrict Google Drive credentials to authenticated users
2025-02-03 13:40:39 -08:00
Timothy Jaeryang Baek 3061d4bd10 Merge pull request #9291 from moblangeois/french-translation
i18n: Update French translation
2025-02-03 13:38:26 -08:00
Timothy Jaeryang Baek d37a7a1fe1 Merge branch 'dev' into french-translation 2025-02-03 13:38:08 -08:00
Timothy Jaeryang Baek 70b8199030 chore: format 2025-02-03 13:37:29 -08:00
Timothy Jaeryang Baek 9b78dde8a9 Merge pull request #9295 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-02-03 13:36:42 -08:00
Simon bc56f442c1 Update translation.json 2025-02-03 21:04:17 +01:00
Morgan Blangeois 6c81cf9795 i18n: Add French translation 2025-02-03 20:16:53 +01:00
Taylor Wilsdon 74cf5e2a2e fix: Restrict Google Drive credentials to authenticated users 2025-02-03 11:55:52 -05:00
Timothy Jaeryang Baek 70116e7251 refac: styling 2025-02-03 01:18:16 -08:00
Timothy Jaeryang Baek 95c6d0e78c enh: code interpreter toggle 2025-02-03 01:14:38 -08:00
Timothy Jaeryang Baek ca4b839e6d enh: code interpreter 2025-02-03 00:24:09 -08:00
Timothy Jaeryang Baek 58d2fd9ac1 refac: styling 2025-02-03 00:03:41 -08:00
Timothy Jaeryang Baek 78853a67e4 refac 2025-02-02 23:35:58 -08:00
Timothy Jaeryang Baek 09cd95bffe enh: pyodide 2025-02-02 23:08:37 -08:00
Timothy Jaeryang Baek 8685256002 feat: code interpreter 2025-02-02 22:38:19 -08:00
Timothy Jaeryang Baek 0d33725d21 enh: pyodide 2025-02-02 21:18:52 -08:00
Timothy Jaeryang Baek eb1ede119e refac: reasoning tag 2025-02-02 20:50:54 -08:00
Timothy Jaeryang Baek e4fc2e0e51 refac 2025-02-02 19:24:07 -08:00
Timothy Jaeryang Baek 74172461c7 enh: pyodide python result handling 2025-02-02 16:56:21 -08:00
Timothy Jaeryang Baek b94b691993 enh: utils 2025-02-02 15:11:50 -08:00
Tristan Morris a004bf2a6d Merge branch into dev 2025-02-02 08:44:57 -06:00
Tristan Morris 5df474abb9 Add support for Deepgram STT 2025-02-02 08:12:13 -06:00
Timothy Jaeryang Baek fc1cec10be chore: bump 2025-02-01 21:05:08 -08:00
Timothy Jaeryang Baek 95c94d8a85 Merge pull request #9191 from NYU-ITS/milvus_auth_token
[feat] Milvus: new optional config var, MILVUS_TOKEN
2025-02-01 21:04:30 -08:00
Timothy Jaeryang Baek 5ce4b1c570 Merge pull request #9173 from KarlLee830/fix-duckduckgo
Fix DuckDuckGo's 202 Error RateLimit
2025-02-01 21:02:31 -08:00
Timothy Jaeryang Baek af6f0641cb chore: bump 2025-02-01 21:02:12 -08:00
Timothy Jaeryang Baek 642a093d02 refac: tool calls 2025-02-01 21:01:06 -08:00
Timothy Jaeryang Baek 28cf616147 Merge pull request #9200 from open-webui/dependabot/pip/backend/dev/opencv-python-headless-4.11.0.86
build(deps): bump opencv-python-headless from 4.10.0.84 to 4.11.0.86 in /backend
2025-02-01 10:04:26 -08:00
Timothy Jaeryang Baek ffe1abfda9 Merge pull request #9198 from open-webui/dependabot/pip/backend/dev/aiohttp-3.11.11
build(deps): bump aiohttp from 3.11.8 to 3.11.11 in /backend
2025-02-01 10:04:18 -08:00
Timothy Jaeryang Baek 6b879abdb9 Merge pull request #9199 from open-webui/dependabot/pip/backend/dev/soundfile-0.13.1
build(deps): bump soundfile from 0.12.1 to 0.13.1 in /backend
2025-02-01 10:04:11 -08:00
Timothy Jaeryang Baek a4b8e3d958 Merge pull request #9201 from open-webui/dependabot/pip/backend/dev/authlib-1.4.1
build(deps): bump authlib from 1.3.2 to 1.4.1 in /backend
2025-02-01 10:04:02 -08:00
Timothy Jaeryang Baek 442ad41d2b Merge pull request #9202 from open-webui/dependabot/pip/backend/dev/pymdown-extensions-10.14.2
build(deps): bump pymdown-extensions from 10.11.2 to 10.14.2 in /backend
2025-02-01 10:03:53 -08:00
Timothy Jaeryang Baek 3502e09d9f fix: image prompt gen template 2025-01-31 23:50:58 -08:00
Timothy Jaeryang Baek af10f87075 refac: textarea 2025-01-31 23:49:25 -08:00
Timothy Jaeryang Baek 4e375c892a refac 2025-01-31 23:15:49 -08:00
Timothy Jaeryang Baek bc5452ea41 Merge pull request #9195 from rragundez/default-picture
[EASY] Set default picture to user.png
2025-01-31 19:35:52 -08:00
dependabot[bot] aa2eb37e10 build(deps): bump pymdown-extensions from 10.11.2 to 10.14.2 in /backend
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.11.2 to 10.14.2.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.11.2...10.14.2)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 02:36:20 +00:00
dependabot[bot] c00d1a2f87 build(deps): bump authlib from 1.3.2 to 1.4.1 in /backend
Bumps [authlib](https://github.com/lepture/authlib) from 1.3.2 to 1.4.1.
- [Release notes](https://github.com/lepture/authlib/releases)
- [Changelog](https://github.com/lepture/authlib/blob/master/docs/changelog.rst)
- [Commits](https://github.com/lepture/authlib/compare/v1.3.2...v1.4.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 02:36:17 +00:00
dependabot[bot] 824aad4703 build(deps): bump opencv-python-headless in /backend
Bumps [opencv-python-headless](https://github.com/opencv/opencv-python) from 4.10.0.84 to 4.11.0.86.
- [Release notes](https://github.com/opencv/opencv-python/releases)
- [Commits](https://github.com/opencv/opencv-python/commits)

---
updated-dependencies:
- dependency-name: opencv-python-headless
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 02:36:14 +00:00
dependabot[bot] f661bfde7d build(deps): bump soundfile from 0.12.1 to 0.13.1 in /backend
Bumps [soundfile](https://github.com/bastibe/python-soundfile) from 0.12.1 to 0.13.1.
- [Release notes](https://github.com/bastibe/python-soundfile/releases)
- [Commits](https://github.com/bastibe/python-soundfile/compare/0.12.1...0.13.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 02:36:11 +00:00
dependabot[bot] 9d52120c62 build(deps): bump aiohttp from 3.11.8 to 3.11.11 in /backend
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-01 02:36:08 +00:00
Rodrigo Agundez 30e4b32a5b Set default picture to user.png 2025-02-01 08:32:04 +09:00
Sajid Ali 7b31c75271 Milvus: new optional config var, MILVUS_TOKEN
modified:   backend/open_webui/config.py
	modified:   backend/open_webui/retrieval/vector/dbs/milvus.py
2025-01-31 17:01:00 -05:00
Timothy Jaeryang Baek 9b640b0ac2 Merge pull request #9163 from rragundez/default-role
Bug fix, configurable default if using oauth via the `DEFAULT_USER_ROLE` env variable
2025-01-31 12:38:32 -08:00
KarlLee830 e7de41499a fix: DuckDuckGo's RateLimit exception 2025-02-01 02:01:22 +08:00
Rodrigo Agundez 1f2b5fa68f move comment to top 2025-01-31 23:05:33 +09:00
Rodrigo Agundez 681851ca6b Configurable default role for oauth 2025-01-31 23:00:24 +09:00
Timothy Jaeryang Baek 74aec6bdf9 Merge pull request #9143 from kostich/dev
i18n: Update Serbian translation
2025-01-31 00:47:50 -08:00
Timothy Jaeryang Baek e3e7176b0b Merge pull request #9142 from dannyl1u/feat/delete-unused-response-message
feat: delete unused response message from other "branches"
2025-01-30 21:28:10 -08:00
Марко М. Костић (Marko M. Kostić) 968787ac3e Update Serbian translation 2025-01-31 06:16:08 +01:00
dannyl1u e9f05b6cd7 feat: delete message responses 2025-01-30 20:48:06 -08:00
Timothy Jaeryang Baek 8fdb37a17f Merge pull request #9131 from 0xThresh/dev
fix: Add error catching for function pip install issues
2025-01-30 15:02:47 -08:00
Timothy Jaeryang Baek 4d376c011a Merge pull request #9129 from imJack6/dev
i18n: Corrected localization typos in Simplified Chinese
2025-01-30 15:00:21 -08:00
Timothy Jaeryang Baek 5ebcd44163 Merge pull request #9110 from muhac/dev
i18n: improved Chinese translations of think tag
2025-01-30 15:00:04 -08:00
James W. 124fdcc48c Merge branch 'open-webui:dev' into dev 2025-01-30 13:34:01 -07:00
James Westbrook 48c2fb0d24 fix: Add error catching for function pip install issues 2025-01-30 13:32:11 -07:00
Jack Lei 0d4d81e012 i18n: Corrected localization typos in Simplified Chinese 2025-01-31 03:47:37 +08:00
Muhan Li 14bbcd7046 i18n: improved Chinese translations of think tag 2025-01-30 05:22:59 -05:00
Timothy Jaeryang Baek cc99673906 refac: prompt variables 2025-01-29 21:56:51 -08:00
Timothy Jaeryang Baek d6c82939e6 refac 2025-01-29 21:56:28 -08:00
Timothy Jaeryang Baek 33fffd6b1d refac: rm print statement 2025-01-29 21:36:05 -08:00
Timothy Jaeryang Baek cd088b2f71 fix 2025-01-29 21:32:07 -08:00
Timothy Jaeryang Baek a8f2919e9e fix: #9003 2025-01-29 21:31:18 -08:00
Timothy Jaeryang Baek be665f2a3e Merge pull request #8493 from kyunwang/fix/oidc-500-error-name-field
fix: Check OAuth name type with fallback
2025-01-29 21:08:21 -08:00
Timothy Jaeryang Baek 8727b91664 fix: display usage for non-streaming ollama response 2025-01-29 21:07:22 -08:00
Timothy Jaeryang Baek 248fcf1636 refac: styling 2025-01-29 21:06:01 -08:00
Timothy Jaeryang Baek 7f61d01fde refac: playground 2025-01-29 20:35:37 -08:00
Timothy Jaeryang Baek 52010accc9 refac 2025-01-29 20:11:37 -08:00
Timothy Jaeryang Baek 6658e67113 fix: title gen 2025-01-29 20:03:46 -08:00
Timothy Jaeryang Baek 08ad4ee018 enh: check for reasoning tags 2025-01-29 14:59:23 -08:00
Timothy Jaeryang Baek 5420c165c6 refac: title generation 2025-01-29 14:40:36 -08:00
Timothy Jaeryang Baek 4abede9a2b fix: uploaded file should not be deleted 2025-01-29 14:20:51 -08:00
Timothy Jaeryang Baek 40a4443949 Merge pull request #9082 from Alex1607/main
FIX max_tokens not being set properly
2025-01-29 11:48:05 -08:00
Timothy Jaeryang Baek 84c700b92c Merge pull request #9059 from jrespeto/jrespeto-patch-1
fix:  Update ollama.py - Missing await in coroutine
2025-01-29 11:39:42 -08:00
Timothy Jaeryang Baek 5fef92c95e Merge pull request #9064 from iidx/i18n-typo
i18n: update ko-KR
2025-01-29 11:39:29 -08:00
Alexander c2e742afe1 Fix max_tokens not being set properly 2025-01-29 19:28:09 +01:00
Jonathan Respeto a32782e527 fix: Update openai.py
need to await get_filtered_models
2025-01-29 08:47:54 -05:00
Didier FOURNOUT 6ca295ec59 Add user related headers when calling an external embedding api 2025-01-29 10:55:52 +00:00
iidx 938a151f12 i18n: some korean update 2025-01-29 17:30:53 +09:00
iidx f502e7db51 i18n: some korean update 2025-01-29 17:18:13 +09:00
iidx bd24ed953a i18n: Corrected typos in korean 2025-01-29 15:57:44 +09:00
Timothy Jaeryang Baek 9418364062 chore: fastapi bump 2025-01-28 18:36:23 -08:00
Jonathan Respeto b4be58f6b6 Update ollama.py
Missing await in coroutine

causing 500 errors on /ollama/api/tags since v0.5.0
2025-01-28 18:27:41 -05:00
Timothy Jaeryang Baek c5b5303a55 Merge pull request #9031 from bunnerz-king/main
LDAP auth, return user permissions in response
2025-01-28 14:50:04 -08:00
Timothy Jaeryang Baek c021aba094 enh: chat "clone" i18n 2025-01-28 12:49:06 -08:00
hurxxxx 8e0c0b4ae3 fix : Infinite loading issue occurs. 2025-01-29 04:42:41 +09:00
hurxxxx 857b3f0da8 Merge remote-tracking branch 'upstream/dev' into feat/rag-status 2025-01-29 02:01:50 +09:00
hurxxxx af979ce49a feat: Add user setting for "Always Enable Search" 2025-01-29 02:01:46 +09:00
Andrew King a1b5c18ef7 ldap pass user permissions into response 2025-01-28 09:51:21 -05:00
Timothy Jaeryang Baek 8c7e52e5a1 Merge pull request #9010 from Shadowfita/patch-1
fix: Include empty delta object on openai_chat_chunk_message_template per OpenAI API documentation
2025-01-27 17:51:09 -08:00
Orion 95f4d99e3b Update misc.py
Include empty delta object on openai_chat_chunk_message_template per OpenAI API documentation. 

https://platform.openai.com/docs/api-reference/chat/streaming#chat/streaming
2025-01-28 09:53:22 +10:00
Timothy Jaeryang Baek ce6e8d1264 Merge pull request #9006 from TiancongLx/dev
i18n: update zh-TW
2025-01-27 15:15:32 -08:00
Tiancong Li 45e19fb9bd i18n: update zh-TW 2025-01-28 05:08:19 +08:00
Timothy Jaeryang Baek cd560a359c Merge pull request #8946 from zeroxs/feat/localize-dates
feat: Localized dates and times
2025-01-27 10:33:59 -08:00
Timothy Jaeryang Baek ec62104211 Merge pull request #8978 from antpyykk-kone/feature/separate-auth-cookie-config
fix: Separate cookie configuration between session & auth cookies to prevent oauth flow from breaking
2025-01-27 10:32:13 -08:00
Timothy Jaeryang Baek acdcb32ac5 Merge pull request #9003 from tarmst/fix-no-file-upload-for-write-groups
Fix: Users with write adding docs to knowledge, deleting, and fixing read/write toggle on model & knowledge creation
2025-01-27 10:30:18 -08:00
tarmst 751a61a364 Adding more checks for write access. Adding accessRoles to Model & Knowledge creation 2025-01-27 18:11:52 +00:00
Kangyun Wang 564c0fed95 Fallback using email
- Use Email ass fallback for missing "name" field
- "email" because the email scope is required unlike the profile scope
2025-01-27 13:11:20 +01:00
Kevin Wang 9eaf01c323 Merge branch 'open-webui:main' into fix/oidc-500-error-name-field 2025-01-27 13:10:08 +01:00
Timothy Jaeryang Baek 6eb51ab62e enh: file upload permission indicator 2025-01-26 23:30:46 -08:00
Timothy Jaeryang Baek 6f3c92f6d5 enh: chat loading screen 2025-01-26 23:17:58 -08:00
Timothy Jaeryang Baek 91221cd4b1 Merge pull request #8948 from kokutaro/i18n/japanese-translation
Fix typo on Japanese
2025-01-26 20:11:14 -08:00
kokutaro 2d562ed317 Fixed the type on translation.json
Fixed the typo on Japanese translation file
2025-01-26 17:51:20 +09:00
Sharon Fox dda945f4ad feat: Localized dates and times 2025-01-26 02:47:23 -05:00
Timothy Jaeryang Baek 24ab79f5e9 Merge pull request #8857 from KarlLee830/translate
i18n: Update Chinese Translation
2025-01-24 08:41:02 -08:00
KarlLee830 6e306beaa2 i18n: Update Chinese Translation 2025-01-24 16:02:35 +08:00
Timothy Jaeryang Baek b72150c881 Merge pull request #8835 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.7
2025-01-23 13:47:40 -08:00
Timothy Jaeryang Baek ceaefd8053 refac 2025-01-23 13:45:45 -08:00
Timothy Jaeryang Baek 2d84db2621 doc: changelog 2025-01-23 13:36:38 -08:00
Timothy Jaeryang Baek 3527023d88 refac: think tag 2025-01-23 13:04:37 -08:00
Timothy Jaeryang Baek e99453219a chore: format 2025-01-23 12:58:39 -08:00
Timothy Jaeryang Baek 45f88b80ad refac: think status 2025-01-23 12:53:17 -08:00
Timothy Jaeryang Baek ba81b47800 refac 2025-01-23 12:06:25 -08:00
Timothy Jaeryang Baek 646671c576 Merge pull request #8837 from TiancongLx/dev
i18n: update zh-TW
2025-01-23 11:23:40 -08:00
Tiancong Li 3d5e361a21 i18n: update zh-TW 2025-01-24 03:13:10 +08:00
Timothy Jaeryang Baek bb45d35a36 fix: ollama prefix id 2025-01-23 11:04:50 -08:00
Timothy Jaeryang Baek 6a9978dd12 Merge pull request #8801 from aleixdorca/dev
i18n: Update catalan translation.json
2025-01-23 10:53:24 -08:00
Timothy Jaeryang Baek 1dd8e5fafd Merge pull request #8821 from panda44312/patch-3
i18n - Update zh-cn Updated translation
2025-01-23 10:52:26 -08:00
Timothy Jaeryang Baek dd6de749d5 fix: model update access 2025-01-23 10:40:49 -08:00
Timothy Jaeryang Baek 8fc5532e2f fix: tools valves access 2025-01-23 10:37:44 -08:00
Panda 863f73de20 Update zh-cn 2025-01-23 18:44:52 +01:00
Antti Pyykkönen 412923dc91 feat: separate cookie settings between session & auth cookies
Introducing two new env config options to control cookies settings regarding
authentication. These values are taken into use when setting 'token' and 'oauth_id_token'.
To maintain backwards compatibility, the original session cookie values are used as
fallback.

Separation is done to prevent issues with the session cookie. When the config value was
set as 'strict', the oauth flow was broken (since the session cookie was not provided
after the callback).

Providing a separate config for auth & session cookies allows us to keep the 'strict'
settings for auth related cookies, while also allowing the session cookie to behave as
intended (e.g., by configuring it as 'lax').

The original config was added in commit #af4f8aa. However a later commit #a2e889c reused
this config option for other type of cookies, which was not the original intent.
2025-01-23 16:16:50 +02:00
Aleix Dorca 6f17c29d93 Update catalan translation.json 2025-01-23 08:39:02 +01:00
Timothy Jaeryang Baek 6c8d68b6fc Merge pull request #8793 from open-webui/dev
fix: model ids not being applied
2025-01-22 19:01:31 -08:00
Timothy Jaeryang Baek 4a2792b4da fix: model ids not being applied 2025-01-22 19:00:46 -08:00
Timothy Jaeryang Baek 9dd45ddf7c Merge pull request #8779 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
fix: changelog
2025-01-22 14:13:56 -08:00
Timothy Jaeryang Baek dcc57500dd fix: changelog 2025-01-22 14:13:06 -08:00
Timothy Jaeryang Baek e3ae30e42f Merge pull request #8776 from open-webui/dev
refac: image gen
2025-01-22 12:49:53 -08:00
Timothy Jaeryang Baek 14e650077a refac 2025-01-22 12:49:29 -08:00
Timothy Jaeryang Baek 36b9bcee0f Merge pull request #8770 from open-webui/dev
0.5.6
2025-01-22 12:47:05 -08:00
Timothy Jaeryang Baek 6186dec781 doc: changelog 2025-01-22 12:43:46 -08:00
Timothy Jaeryang Baek 667928efb0 chore: format 2025-01-22 12:38:42 -08:00
Timothy Jaeryang Baek f4b5039adf enh: new ollama create model method 2025-01-22 12:36:18 -08:00
Timothy Jaeryang Baek 8380d3f5ae refac 2025-01-22 12:19:11 -08:00
Timothy Jaeryang Baek 419005a578 refac: manage ollama 2025-01-22 12:19:01 -08:00
Timothy Jaeryang Baek 7593d11b97 refac: wording 2025-01-22 12:08:08 -08:00
Timothy Jaeryang Baek aaf97b85dc enh: reasoning_effort param support for openai 2025-01-22 12:07:04 -08:00
Timothy Jaeryang Baek ea7d4ec6ea refac: styling 2025-01-22 11:56:39 -08:00
Timothy Jaeryang Baek 568dbc545c Merge pull request #8351 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.5
2025-01-22 11:34:03 -08:00
Timothy Jaeryang Baek b641756020 refac: disable duplicate attachments 2025-01-22 11:28:54 -08:00
Timothy Jaeryang Baek c6660f1d7c refac: favicon 2025-01-22 11:27:03 -08:00
Timothy Jaeryang Baek 195e632ed4 chore: logo 2025-01-22 11:19:42 -08:00
Timothy Jaeryang Baek b7f743d24c refac: favicon styling 2025-01-22 11:11:12 -08:00
Timothy Jaeryang Baek 1080ecd22c refac 2025-01-22 11:06:06 -08:00
Timothy Jaeryang Baek b5bce9cbea fix: file attachment 2025-01-22 11:04:12 -08:00
Timothy Jaeryang Baek 647195c578 chore: format 2025-01-22 10:51:29 -08:00
Timothy Jaeryang Baek ab4372600c chore: bump duckduckgo-search 2025-01-22 10:46:57 -08:00
Timothy Jaeryang Baek 8114223b54 infra: disable integration test 2025-01-22 10:43:35 -08:00
Timothy Jaeryang Baek a1a658d55b chore: dependecies 2025-01-22 10:43:08 -08:00
Timothy Jaeryang Baek 6eb4c0aecd doc: changelog 2025-01-22 10:40:48 -08:00
Timothy Jaeryang Baek 8b998420fe refac 2025-01-22 10:12:09 -08:00
Timothy Jaeryang Baek c2b5200663 refac 2025-01-22 09:42:40 -08:00
Timothy Jaeryang Baek ed7db1dd41 enh: 'notification' event type 2025-01-22 09:38:44 -08:00
Timothy Jaeryang Baek a1e4e7c007 refac: omit reasoning content in payload 2025-01-22 09:29:26 -08:00
Timothy Jaeryang Baek 9feed97f22 refac: think tag 2025-01-22 09:24:40 -08:00
Timothy Jaeryang Baek cf470e70e2 Merge pull request #8743 from open-webui/dependabot/npm_and_yarn/npm_and_yarn-37a58caa2a
build(deps): bump the npm_and_yarn group across 1 directory with 2 updates
2025-01-22 01:10:21 -08:00
Timothy Jaeryang Baek 2b1c2942a8 refac: message timestamp 2025-01-22 01:02:52 -08:00
Timothy Jaeryang Baek 10ffbca34b refac: more reasoning tags support 2025-01-22 00:56:55 -08:00
Timothy Jaeryang Baek d42f811a8d refac: think tag 2025-01-22 00:49:41 -08:00
Timothy Jaeryang Baek 039d685547 refac 2025-01-22 00:32:09 -08:00
Timothy Jaeryang Baek 2777d3ec49 refac: think tag styling 2025-01-22 00:23:47 -08:00
Timothy Jaeryang Baek c9dc7299c5 enh: <think> tag support 2025-01-22 00:13:24 -08:00
dependabot[bot] 0e6c680db8 build(deps): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [katex](https://github.com/KaTeX/KaTeX) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `katex` from 0.16.10 to 0.16.21
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.10...v0.16.21)

Updates `vite` from 5.4.6 to 5.4.14
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-22 06:16:45 +00:00
Timothy Jaeryang Baek 8d3c73aed5 chore: format backend 2025-01-21 19:33:33 -08:00
Timothy Jaeryang Baek 4c86f39a84 chore: format 2025-01-21 19:33:26 -08:00
Timothy Jaeryang Baek 35547c4246 Merge pull request #8736 from aqiray/dev
i18n: Update Turkish Translation
2025-01-21 19:32:20 -08:00
Giray a839808991 i18n: Update Turkish Translation 2025-01-22 05:29:46 +03:00
Timothy Jaeryang Baek 80e004c31f Merge pull request #8637 from kahghi/add-gcs-storage-provider
feat:add GCSStorageProvider
2025-01-21 18:14:22 -08:00
Timothy Jaeryang Baek ffd10e8b6f Merge pull request #8712 from rragundez/improve-s3-provider-tests
Improve s3 provider tests
2025-01-21 11:35:46 -08:00
Timothy Jaeryang Baek 8cca31e6fe Merge pull request #8711 from KarlLee830/translate
i18n: Update Chinese Translation
2025-01-21 11:34:25 -08:00
Rodrigo Agundez bdc6082221 Move patchin of S3 obects inside the init 2025-01-21 17:21:54 +08:00
kahghi a93124cafb tidied up test fixture for gcp storage emulator 2025-01-21 17:19:56 +08:00
KarlLee830 b2178b982d i18n: Update Chinese Translation 2025-01-21 15:50:16 +08:00
Timothy Jaeryang Baek 6408452e8f refac: model editor 2025-01-20 23:44:47 -08:00
kahghi 4aa9b8d5e7 removed unused pytest-env and imports 2025-01-21 15:38:41 +08:00
Timothy Jaeryang Baek 94d0f84c0c refac: styling 2025-01-20 23:26:43 -08:00
Timothy Jaeryang Baek 45f4bc18f8 refac: access controls 2025-01-20 23:20:47 -08:00
Timothy Jaeryang Baek aa442f694b enh: validate user id before saving group 2025-01-20 23:09:55 -08:00
Timothy Jaeryang Baek 31ed1fcdb8 fix: tools permissions 2025-01-20 22:57:40 -08:00
Timothy Jaeryang Baek 7a70fd1312 fix: bing search 2025-01-20 22:52:19 -08:00
kahghi b1887fef1b added suggestions 2025-01-21 14:47:56 +08:00
Timothy Jaeryang Baek 006208f9a9 enh: title edit should auto focus to input 2025-01-20 22:44:59 -08:00
Timothy Jaeryang Baek a863f98c53 refac: toast error 2025-01-20 22:41:32 -08:00
Timothy Jaeryang Baek af48f346f1 enh: reintroduce model management to models settings 2025-01-20 22:39:00 -08:00
kahghi 55cc127b03 gcs tests pass, updated get_file and delete_file to retrieve blob instead of instantiating blob 2025-01-21 12:53:49 +08:00
kahghi 4b56c15a3f test_upload_file working, added gcp-storage-emulator, updated gcs client instantiation 2025-01-20 22:35:43 +08:00
kahghi 44574c434d removed unnecessary GCS_PROJECT_ID variable 2025-01-20 15:27:55 +08:00
Timothy Jaeryang Baek 59c349de00 refac: styling 2025-01-19 12:46:02 -08:00
Timothy Jaeryang Baek bdc60e7850 chore: format backend 2025-01-19 11:59:07 -08:00
Timothy Jaeryang Baek a1e33a82f0 chore: format 2025-01-19 11:58:58 -08:00
Timothy Jaeryang Baek 9a794b1f8f refac 2025-01-19 11:49:51 -08:00
Timothy Jaeryang Baek 149eea2377 refac: chat controls 2025-01-19 11:49:16 -08:00
Timothy Jaeryang Baek db8b3d29da refac: app styling 2025-01-19 11:48:05 -08:00
Timothy Jaeryang Baek 6b7fe4f81a Merge pull request #8683 from OriginalSimon/dev
18n: Update Ukrainian translation
2025-01-19 11:47:42 -08:00
Timothy Jaeryang Baek e240b95026 Merge pull request #8675 from jyje/i18n/ko-KR
i18n: update ko-KR for settings and admin settings
2025-01-19 11:47:01 -08:00
Simon 475568d9e7 Update translation.json 2025-01-19 19:32:16 +01:00
Simon fd3348d564 Update translation.json 2025-01-19 19:25:15 +01:00
kahghi 49f31ddcd8 added alternative storage client instantiation method, corrected filepaths, added missing type hinting 2025-01-19 22:42:29 +08:00
jyje 4fa234b6e9 i18n: fix ko-KR for awkward words 2025-01-19 19:57:08 +09:00
jyje 04b646eac0 i18n: update ko-KR for 'admin settings audio'
- admin Settings/Audio
2025-01-19 19:49:31 +09:00
jyje 5e65471560 i18n: update ko-KR for 'admin settings interface' 2025-01-19 19:40:29 +09:00
jyje a98a84859e i18n: update ko-KR for 'admin settings web search'
- admin Settings/WebSearch
2025-01-19 19:30:14 +09:00
jyje 39bc76cbc4 i18n: update ko-KR for 'admin settings documents'
- admin Settings/Documents
2025-01-19 18:59:07 +09:00
jyje 03d54f97ab i18n: update ko-KR for 'admin settings evaluations'
- Settings/Evaulations/ArenaModelModal
- Workspace/Common/AccessControl
2025-01-19 17:33:04 +09:00
jyje 568b873fff i18n: update ko-KR for admin Settings/Models, Settings/Models/ConfigureModelsModal 2025-01-19 17:14:28 +09:00
jyje 09827064fe i18n: update ko-KR for admin Settings/Connections 2025-01-19 17:08:18 +09:00
jyje c26e7820e1 i18n: update ko-KR for admin Settings/General 2025-01-19 17:00:39 +09:00
jyje 0a6ba21e57 i18n: update ko-KR for Settings/Account 2025-01-19 16:47:43 +09:00
jyje 42db2748e6 i18n: update ko-KR for Settings/General, Settings/Advanced 2025-01-19 16:37:34 +09:00
Timothy Jaeryang Baek 3fefbb9e20 refac 2025-01-18 17:50:03 -08:00
Timothy Jaeryang Baek 71ab8b9c93 enh: allow connections with duplicate urls 2025-01-18 17:22:29 -08:00
Timothy Jaeryang Baek ca0285fc91 refac: connections handling 2025-01-18 17:10:15 -08:00
Timothy Jaeryang Baek 430854e223 refac 2025-01-18 16:34:13 -08:00
Timothy Jaeryang Baek 43c4b8e518 fix: ollama connections 2025-01-18 16:28:39 -08:00
Timothy Jaeryang Baek 2b66a7a8dc Merge pull request #8670 from SlavikCA/main
docs: Update incorrect hint about `num_gpu` parameter
2025-01-18 15:42:10 -08:00
slavik.fursov c7c3c4a23c Update incorrect hint about num_gpu parameter 2025-01-18 14:22:01 -08:00
Timothy Jaeryang Baek 27f1ed18f7 Merge pull request #8668 from jyje/i18n/ko-KR
i18n: update ko-KR
2025-01-18 11:07:18 -08:00
jyje 66796b38bc i18n: update ko-KR for Settings/Interface, Messages/RateComment and etc 2025-01-19 02:11:39 +09:00
Timothy Jaeryang Baek f5437dae4a refac: electron app 2025-01-17 17:05:52 -08:00
Timothy Jaeryang Baek 70aced7f58 refac: styling 2025-01-17 17:03:25 -08:00
Timothy Jaeryang Baek 2535e1b53a Merge pull request #8659 from open-webui/dependabot/npm_and_yarn/npm_and_yarn-7064c9a8ac
build(deps): bump katex from 0.16.10 to 0.16.21 in the npm_and_yarn group across 1 directory
2025-01-17 15:25:45 -08:00
Timothy Jaeryang Baek 7519f683ed Merge pull request #8660 from jk-f5/fix/decode
Remove unnecessary decode statement from release_lock method
2025-01-17 15:25:36 -08:00
Jason Kidd c5b67ea430 fix: Remove unnecessary decode statement
Since we create our Redis instance with , we don't
need to worry about decoding this value here. Plus this doesn't work
in python3 anyway
2025-01-17 13:43:56 -08:00
dependabot[bot] 1827eaca2d build(deps): bump katex in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [katex](https://github.com/KaTeX/KaTeX).


Updates `katex` from 0.16.10 to 0.16.21
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.16.10...v0.16.21)

---
updated-dependencies:
- dependency-name: katex
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-17 21:34:21 +00:00
Timothy Jaeryang Baek 217e3a13c8 feat: Add ability to change permissions on group creation API 2025-01-17 12:03:24 -08:00
Timothy Jaeryang Baek 2818f3cf95 Merge pull request #8610 from excho0/patch-1
🌟 Chore: Add Svelte Inspector for Easier Development 🌟
2025-01-17 10:30:03 -08:00
Timothy Jaeryang Baek 3994b1c6f7 Merge pull request #8623 from Tryanks/autocompletion
feat: non-english (chinese) autocompletion handling
2025-01-17 10:28:32 -08:00
Timothy Jaeryang Baek 382dece228 Merge pull request #8638 from Tryanks/oauth2-github
refactor: Extend OIDC support to all OAuth authentication methods
2025-01-17 10:27:49 -08:00
kahghi 1764de41f3 updated to use credentials json, tested with local built image and upload to gcs works 2025-01-17 21:53:41 +08:00
kahghi 8efc543f49 added google-cloud-storage package, added gcs related functions 2025-01-17 16:16:25 +08:00
Tryanks e7971b5840 enh: add github oauth2 provider support 2025-01-17 13:22:35 +08:00
Tryanks f3e6dacf0d refac: Extend OIDC support to all OAuth authentication methods 2025-01-17 12:56:03 +08:00
kahghi 42b7beb8a7 add GCSStorageProvider 2025-01-17 09:39:59 +08:00
Timothy Jaeryang Baek 7cf4c9c89c refac: comfyui 2025-01-16 11:17:37 -08:00
Timothy Jaeryang Baek 5526c43853 Merge pull request #8620 from Tryanks/dev
fix: incorrectly indexing the key userinfo in the token.
2025-01-16 11:12:41 -08:00
Timothy Jaeryang Baek eb17cf6915 Merge pull request #8608 from rragundez/hotfix-get-picture
Hotfix get picture
2025-01-16 11:12:18 -08:00
Timothy Jaeryang Baek 4645943066 refac: file handler 2025-01-16 11:11:23 -08:00
Tryanks 612ca54afa enh: non-english (chinese) autocompletion handling 2025-01-17 01:50:23 +08:00
Tryanks 4b6700a4b2 fixed: incorrectly indexing the key userinfo in the token. 2025-01-17 00:33:20 +08:00
Excho 8468085291 Update svelte.config.js
Added svelte inspector for easier development
2025-01-16 14:29:00 +02:00
Rodrigo Agundez 91df1c56b2 Add headers 2025-01-16 19:32:35 +08:00
Timothy Jaeryang Baek 0425621494 refac 2025-01-16 00:13:02 -08:00
Timothy Jaeryang Baek 0360aa5520 enh: image prompt enhancer 2025-01-16 00:06:37 -08:00
Timothy Jaeryang Baek d3a5b9c127 refac: styling 2025-01-15 23:39:29 -08:00
Timothy Jaeryang Baek a10302d909 enh: image generation toggle 2025-01-15 23:32:13 -08:00
Timothy Jaeryang Baek 92022dd81f enh: image permissions 2025-01-15 23:12:40 -08:00
Timothy Jaeryang Baek 90d4bedae8 enh: ability to set chat controls permissions 2025-01-15 23:08:54 -08:00
Timothy Jaeryang Baek 56f57928c2 enh/refac: permissions 2025-01-15 23:01:43 -08:00
Timothy Jaeryang Baek 2aa82d98cc refac: styling 2025-01-15 21:51:11 -08:00
Timothy Jaeryang Baek 8b3fb2a8b5 Merge pull request #8580 from rragundez/split-storage-providers
Split the storage providers into separate classes in preparation for adding more cloud providers
2025-01-15 21:08:04 -08:00
Rodrigo Agundez f14467c65e Update dependencies to include mock 2025-01-16 13:00:17 +08:00
Rodrigo Agundez 544243fabb Add moto as a dependency 2025-01-16 12:37:54 +08:00
Rodrigo Agundez 021c1f3900 Add test for S3 using moto 2025-01-16 12:37:44 +08:00
Rodrigo Agundez 357e7dd20f Add tests for local provider 2025-01-16 11:13:10 +08:00
Rodrigo Agundez 3aa28de5f1 Add test classes for the providers 2025-01-16 08:31:59 +08:00
Rodrigo Agundez 535e59af8d Start testing files for provider 2025-01-16 08:10:17 +08:00
Rodrigo Agundez dd16c3d5c1 add function to retrieve the storage provider so it can be easily tested 2025-01-16 08:09:56 +08:00
Rodrigo Agundez 0129255478 Set default storage provider to local 2025-01-16 08:01:26 +08:00
Rodrigo Agundez a3f737c0c6 Split the storage providers into separate classes in preparation for other storage providers like GCS 2025-01-16 07:50:12 +08:00
Timothy Jaeryang Baek 372658be6d Merge pull request #8547 from juananpe/file-deletion
fix: File deletion doesn't properly clean up database entries
2025-01-15 10:11:29 -08:00
Timothy Jaeryang Baek d1bde9f348 Merge pull request #8562 from NYU-ITS/milvus_custom_db
[feat] Milvus: add new config var, MILVUS_DB
2025-01-15 10:10:10 -08:00
Timothy Jaeryang Baek b797112322 Merge pull request #8565 from mykola-mmm/fix/show_user_chats_from_folders
Fix: updated get_chat_list_by_user_id method to return all user cha…
2025-01-15 10:09:43 -08:00
Timothy Jaeryang Baek 3328e2ea97 Merge pull request #8566 from rragundez/ms-auth-picture
Add functionality to retrive picture for microsoft oauth
2025-01-15 10:02:28 -08:00
Timothy Jaeryang Baek 0775ddbdf5 Merge pull request #8585 from TiancongLx/dev
i18n: update zh-TW
2025-01-15 09:53:34 -08:00
Tiancong Li 6e088df173 i18n: update zh-TW 2025-01-16 01:05:26 +08:00
Timothy Jaeryang Baek 5e548fa7b8 refac: styling 2025-01-14 23:53:19 -08:00
Rodrigo Agundez 32f121f019 Add functionality to retrive picture for microsoft oauth 2025-01-15 09:18:16 +08:00
Rodrigo Agundez c8be0b20cc Add functionality to retrive picture for microsoft oauth 2025-01-15 09:17:22 +08:00
mykola b1568878e7 Fixed: updated get_chat_list_by_user_id method to return all user chats (including those moved to the folders) 2025-01-14 23:15:56 +00:00
Sajid Ali 7a95df008e Milvus: add new config var, MILVUS_DB
modified:   backend/open_webui/config.py
	modified:   backend/open_webui/retrieval/vector/dbs/milvus.py
2025-01-14 15:48:15 -05:00
Juanan Pereira f477f4f790 fix: fixess issue #7181 2025-01-14 09:32:04 +01:00
Timothy Jaeryang Baek f6a54c96bc Merge pull request #8470 from tarmst/add-read-write-toggle-to-access-control
feat: Add toggle to read/write perms on access control
2025-01-13 21:22:36 -08:00
Timothy Jaeryang Baek bb261fcc4a refac: styling 2025-01-13 21:20:31 -08:00
Timothy Jaeryang Baek 4044d80024 fix 2025-01-13 21:15:59 -08:00
Timothy Jaeryang Baek 3a3764bf68 refac: drag region 2025-01-13 21:15:22 -08:00
Timothy Jaeryang Baek c4486b4a77 refac: styling 2025-01-13 21:13:16 -08:00
Timothy Jaeryang Baek ac5bdac161 refac: styling 2025-01-13 20:21:47 -08:00
Timothy Jaeryang Baek ce1712fed1 fix: electron api 2025-01-13 19:53:00 -08:00
Timothy Jaeryang Baek 15e591f30d enh: electron window focus detect 2025-01-13 19:47:24 -08:00
Timothy Jaeryang Baek 84b7583982 refac: styling 2025-01-13 19:39:18 -08:00
Timothy Jaeryang Baek 4fcf0caa4d refac 2025-01-13 19:25:07 -08:00
Timothy Jaeryang Baek 22ad0264c1 refac: styling 2025-01-13 19:23:57 -08:00
Timothy Jaeryang Baek f299e19002 refac: styling 2025-01-13 19:18:32 -08:00
Timothy Jaeryang Baek 147eb0d2d1 refac: electron drag region 2025-01-13 18:19:46 -08:00
Timothy Jaeryang Baek 44da86895d refac: styling 2025-01-13 18:08:17 -08:00
Timothy Jaeryang Baek eb99785047 Merge pull request #8450 from d4v3y0rk/fix-integration-test-error
ci: fix for cypress error in integration test
2025-01-13 13:16:57 -08:00
Timothy Jaeryang Baek d83acb2f03 Merge pull request #8510 from MadsLang/main
Add DATABASE_SCHEMA as env var
2025-01-13 13:16:06 -08:00
Timothy Jaeryang Baek b6128efb43 chore: bump 2025-01-13 00:34:44 -08:00
Timothy Jaeryang Baek ad93341b74 refac 2025-01-13 00:34:15 -08:00
MadsLang d4a26f8031 Merge branch 'open-webui:main' into main 2025-01-13 08:28:13 +01:00
Timothy Jaeryang Baek 42e8d8ce49 Merge pull request #8447 from steelcg/dev
feat: add LDAP_ATTRIBUTE_FOR_MAIL to env-configuration
2025-01-12 18:15:28 -08:00
Timothy Jaeryang Baek 8e08c218c5 Merge pull request #8499 from juananpe/file-cleanup-knowledge
fix: complete file cleanup when removing from Knowledge Base
2025-01-12 18:14:48 -08:00
Timothy Jaeryang Baek 3e7036151e Merge pull request #8490 from flefevre/i18n-fr-FR
i18n - Updated French Translation
2025-01-12 12:03:54 -08:00
Juanan Pereira 913e0d391b fix: complete file cleanup when removing from Knowledge Base 2025-01-12 20:04:25 +01:00
Kangyun Wang 1ad9be9c07 Check OAuth name type with fallback 2025-01-12 12:21:53 +01:00
Francois LE FEVRE 805e0ec7d1 i18n - Updated French Translation 2025-01-12 10:28:19 +01:00
Timothy Jaeryang Baek b1bcca90b5 refac 2025-01-11 20:23:26 -08:00
Timothy Jaeryang Baek 5d3f778b2a fix: openai proxy endpoints 2025-01-11 16:56:25 -08:00
Timothy Jaeryang Baek ccbd98dfab refac: action buttons styling 2025-01-11 13:47:02 -08:00
Timothy Jaeryang Baek 21abe43f3b refac 2025-01-10 16:27:37 -08:00
Timothy Jaeryang Baek dd08b457d8 Merge pull request #8471 from open-webui/main
Update About.svelte
2025-01-10 11:46:59 -08:00
Timothy Jaeryang Baek 4269df041f Update About.svelte 2025-01-10 11:46:35 -08:00
Timothy Jaeryang Baek 042c9fbef3 Merge pull request #8469 from open-webui/main
Update README.md
2025-01-10 11:09:14 -08:00
Timothy Jaeryang Baek 1972f33100 Update README.md 2025-01-10 11:07:45 -08:00
Timothy Jaeryang Baek 6aee001ab0 Merge pull request #8468 from open-webui/main
Update LICENSE
2025-01-10 10:55:37 -08:00
tarmst 49eca68e28 Add toggle to read/write perms on access control 2025-01-10 18:44:50 +00:00
Timothy Jaeryang Baek a76068d69c Update LICENSE 2025-01-10 10:40:39 -08:00
Timothy Jaeryang Baek cd20c578d2 Merge pull request #8449 from hurxxxx/main
fix : incorrect element id
2025-01-10 08:28:47 -08:00
Timothy Jaeryang Baek a078403c34 Merge pull request #8457 from panda44312/patch-2
i18n - zh-cn Updated translation
2025-01-10 08:28:04 -08:00
Timothy Jaeryang Baek 5ccdddff99 Merge pull request #8458 from aindriu80/main
i18n - Updated Irish translations
2025-01-10 08:27:52 -08:00
Aindriú Mac Giolla Eoin 0be703ecb7 Updating Irish translations 2025-01-10 15:11:46 +00:00
Panda 35ca4f565f Update translation.json 2025-01-10 15:48:52 +01:00
Dave York 4d349ac5c9 potential fix for cypress error 2025-01-10 01:27:00 -05:00
Gunwoo Heo a6ca6c1712 fix : incorrect element id 2025-01-10 14:41:39 +09:00
Li, Steel ab6dffffd0 feat: add LDAP_ATTRIBUTE_FOR_MAIL to env-configuration 2025-01-10 08:53:03 +08:00
Timothy Jaeryang Baek 88614ec70a Merge pull request #8432 from juananpe/kb-detachment-from-models
fix: Knowledge Base Detachment from Models
2025-01-09 10:24:57 -08:00
Juanan Pereira f72490093a fix frontend format 2025-01-09 18:56:40 +01:00
Juanan Pereira de735b3c8c fix format 2025-01-09 18:41:18 +01:00
Juanan Pereira 5b616dd34f fix: Knowledge Base Detachment from Models 2025-01-09 16:47:12 +01:00
Timothy Jaeryang Baek 942fd384de refac: chroma 2025-01-08 13:18:14 -08:00
Timothy Jaeryang Baek bd9bc4d7eb Merge pull request #8401 from TiancongLx/dev
i18n: update zh-TW
2025-01-08 11:34:48 -08:00
Timothy Jaeryang Baek 6c4b2b0a8e Merge pull request #8407 from LuisMalhadas/no_line_break_pdf(#8405)
fix: solves the lack of line breaks in chat download as pdf #8405
2025-01-08 11:34:32 -08:00
Timothy Jaeryang Baek 256b9161e6 Merge pull request #8409 from vertago1/main
**docs**: docs/apache.md: Add websocket proxy
2025-01-08 11:34:17 -08:00
Timothy Jaeryang Baek 9516eb3f32 refac: bump chromadb 2025-01-08 11:33:30 -08:00
Allen Webb 76f99d193f docs/apache.md: Add websocket proxy
After 0.5 websocket support is required so update the proxy instructions
to include websockets.

Issue #8074#issuecomment-2562017399
2025-01-08 10:13:00 -06:00
Luis Malhadas 882d8f4ce1 solves the lack of line breaks in chat download as pdf #8405 2025-01-08 16:49:53 +01:00
Tiancong Li 130a771b60 i18n: update zh-TW 2025-01-08 20:17:41 +08:00
Timothy Jaeryang Baek e6db4d017b fix 2025-01-08 00:59:03 -08:00
Timothy Jaeryang Baek 987664f9b5 fix
Co-Authored-By: Izhar Firdaus <480984+kagesenshi@users.noreply.github.com>
2025-01-08 00:57:52 -08:00
Timothy Jaeryang Baek b3c7ecaea1 fix: oauth webhook
Co-Authored-By: Izhar Firdaus <480984+kagesenshi@users.noreply.github.com>
2025-01-08 00:38:00 -08:00
Timothy Jaeryang Baek c79b975ad0 refac: chroma 2025-01-08 00:21:50 -08:00
Timothy Jaeryang Baek 656a887db6 refac 2025-01-08 00:10:38 -08:00
Timothy Jaeryang Baek e63c0aeb1b refac: styling 2025-01-08 00:03:31 -08:00
Timothy Jaeryang Baek 433168c450 fix: add user modal 2025-01-08 00:02:33 -08:00
Timothy Jaeryang Baek 0129f463b4 fix: &lt; rendering issue 2025-01-07 23:59:58 -08:00
Timothy Jaeryang Baek e0054298a4 Merge pull request #8377 from Vojtech-Siler/multiple-error-fixes
fix: Multiple Fixes for Range and Type Errors
2025-01-07 23:53:48 -08:00
Timothy Jaeryang Baek 0e7c3d4eb8 Merge pull request #8379 from qiaozhi199/main
Fix the issue of inaccurate answers after enabling RAG query generation
2025-01-07 23:53:31 -08:00
Timothy Jaeryang Baek 70914d2764 Merge pull request #8376 from steelcg/dev
perf: use ldap3.NONE for parameter get_info in ldap_auth function to accelerate login
2025-01-07 23:50:11 -08:00
Timothy Jaeryang Baek 156579ab3a fix/doc: changelog date 2025-01-07 17:19:46 -08:00
Timothy Jaeryang Baek 81d03540bd Merge pull request #8385 from jk-f5/refac/pgvectorlength
fix: Pgvector vector column size check was failing on initialization …
2025-01-07 14:35:47 -08:00
Jason Kidd b3a52be401 fix: Pgvector vector column size check was failing on initialization of database 2025-01-07 09:15:13 -08:00
zhiguo.qiao 91f22a8a8d Return the top k results with the highest similarity. 2025-01-07 17:41:30 +08:00
Vojtěch Šiler 70a6a0d9e8 Fix errors for RangeErrors, Fix for accessing undefined objects in Chat.svelte 2025-01-07 08:54:42 +01:00
Li, Steel 82ff6c371b use ldap3.NONE for parameter get_info in ldap_auth function to accelerate login 2025-01-07 14:13:18 +08:00
Timothy Jaeryang Baek 0c5bb6df80 Merge pull request #8371 from tarmst/fix-admins-added-to-groups-from-oauth-group-mgmt
fix: Fix admins added to groups from oauth group management
2025-01-06 10:30:08 -08:00
tarmst 8117bf8603 Add admin check 2025-01-06 18:23:42 +00:00
Timothy Jaeryang Baek a0e63b08c3 fix: searchapi engine not showing 2025-01-06 10:10:25 -08:00
Timothy Jaeryang Baek 960683eced fix: torch mps not working
Co-Authored-By: Rich Tong <1782087+richtong@users.noreply.github.com>
2025-01-06 10:08:12 -08:00
Timothy Jaeryang Baek 15a182c9d6 refac: styling 2025-01-05 13:58:29 -08:00
Timothy Jaeryang Baek 61303a216c refac: styling 2025-01-05 13:56:42 -08:00
Timothy Jaeryang Baek 1dfb479d36 Merge pull request #8329 from open-webui/dev
infra: disable codespell
2025-01-05 01:53:03 -08:00
Timothy Jaeryang Baek c517fa906f infra: disable codespell 2025-01-05 01:52:47 -08:00
Timothy Jaeryang Baek 506dc0149c Merge pull request #8246 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.4
2025-01-05 01:38:42 -08:00
Timothy Jaeryang Baek d651cdb1e4 doc: changelog 2025-01-05 01:38:34 -08:00
Timothy Jaeryang Baek 0c40319371 doc: readme 2025-01-05 01:36:27 -08:00
Timothy Jaeryang Baek c74083ace7 doc: wording 2025-01-05 01:34:31 -08:00
Timothy Jaeryang Baek a3ca39f377 refac 2025-01-05 01:33:12 -08:00
Timothy Jaeryang Baek d11e6e40b1 doc: changelog 2025-01-05 01:32:26 -08:00
Timothy Jaeryang Baek 71bbdf25c6 refac 2025-01-05 01:18:30 -08:00
Timothy Jaeryang Baek f4a595db11 chore: format 2025-01-05 01:12:19 -08:00
Timothy Jaeryang Baek c8be0ee1e0 fix: temp chat json export 2025-01-05 01:10:45 -08:00
Timothy Jaeryang Baek 6ed8c3cb70 Update pyproject.toml 2025-01-05 00:56:32 -08:00
Timothy Jaeryang Baek c434aff1a2 refac: styling 2025-01-05 00:48:41 -08:00
Timothy Jaeryang Baek 3886c82ed0 Merge pull request #8286 from srajangarg/sg
feat: functionality to clone shared chats
2025-01-05 00:45:55 -08:00
Timothy Jaeryang Baek fe59b7f39c refac 2025-01-05 00:44:38 -08:00
Timothy Jaeryang Baek a4132322d9 refac 2025-01-05 00:28:36 -08:00
Timothy Jaeryang Baek d477f73c7e enh: torch mps support 2025-01-05 00:27:44 -08:00
Timothy Jaeryang Baek 614379a427 fix: chat drag and drop 2025-01-04 02:42:26 -08:00
Timothy Jaeryang Baek b4d91b5cb4 refac 2025-01-04 02:34:12 -08:00
Timothy Jaeryang Baek f31e2af349 refac: play sound only in last active tab 2025-01-04 02:30:51 -08:00
Timothy Jaeryang Baek afcbf2cafb enh: native notifications 2025-01-04 02:15:57 -08:00
Timothy Jaeryang Baek c0b93791dc refac 2025-01-04 02:05:42 -08:00
Timothy Jaeryang Baek f3a4d61f81 fix: actions 2025-01-03 21:31:24 -08:00
Timothy Jaeryang Baek 99c3194181 fix: API_KEY_ALLOWED_ENDPOINTS 2025-01-03 13:08:21 -08:00
Timothy Jaeryang Baek 0e805e7dc4 Merge pull request #8298 from jk-f5/feat/pg_vector_size
feat: Allow setting the initial vector length on pgvector document_chunk table
2025-01-03 13:03:53 -08:00
Timothy Jaeryang Baek 226408ffe3 Merge pull request #8309 from yarikoptic/enh-codespell
ci: add codespell support (config, workflow to detect/not fix) and make it fix few typos
2025-01-03 13:02:32 -08:00
Yaroslav Halchenko 8f1953e667 [DATALAD RUNCMD] run codespell throughout fixing few left typos automagically
=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
2025-01-03 15:07:21 -05:00
Yaroslav Halchenko 1a928bc6a4 Add rudimentary codespell config 2025-01-03 15:07:21 -05:00
Yaroslav Halchenko 2e5fbba961 Add github action to codespell main on push and PRs 2025-01-03 15:07:21 -05:00
Jason Kidd 70b74b5217 feat: Allow setting the initial vector length on pgvector document_chunk table 2025-01-03 09:18:59 -08:00
Timothy Jaeryang Baek 996ade9090 refac: notification sound behaviour 2025-01-02 21:39:40 -08:00
Srajan Garg 2444327a47 add functionality to clone shared chats 2025-01-03 00:08:49 -05:00
Timothy Jaeryang Baek 6b3be4bb5b refac: sidebar styling 2025-01-02 20:53:30 -08:00
Timothy Jaeryang Baek b2b56d14cb fix: channel thread 2025-01-02 20:48:50 -08:00
Timothy Jaeryang Baek 3e9c92729d refac: styling 2025-01-02 20:28:01 -08:00
Timothy Jaeryang Baek 0c49553f79 refac: styling 2025-01-02 20:23:07 -08:00
Timothy Jaeryang Baek 66b8166397 refac: styling 2025-01-02 20:19:52 -08:00
Timothy Jaeryang Baek 87ba39df57 fix: moa 2025-01-02 14:32:25 -08:00
Timothy Jaeryang Baek 43964de675 refac: audio input 2025-01-02 02:32:51 -08:00
Timothy Jaeryang Baek fab82b51de fix: generate_queries 2025-01-01 20:13:28 -08:00
Timothy Jaeryang Baek 44716a4cb2 refac: disable ENABLE_REALTIME_CHAT_SAVE by default 2025-01-01 20:12:42 -08:00
Timothy Jaeryang Baek d9fb971196 Merge pull request #8249 from albinvar/main
fix: raise exceptions properly in process_chat_payload
2025-01-01 18:44:42 -08:00
Timothy Jaeryang Baek 51d875a14e Merge pull request #8251 from TiancongLx/dev
i18n: update zh-TW
2025-01-01 18:44:16 -08:00
Tiancong Li 45cc0e6209 i18n: update zh-TW
待翻譯:「Add Reaction」
2025-01-01 21:20:11 +08:00
Albin Varghese c561eb7bbd fix: raise exceptions properly in process_chat_payload 2025-01-01 17:15:06 +05:30
Timothy Jaeryang Baek 00a089b596 doc: readme 2024-12-31 20:37:54 -08:00
Timothy Jaeryang Baek 541fa37c5c Merge pull request #8245 from open-webui/dependabot/pip/backend/dev/peewee-3.17.8
build(deps): bump peewee from 3.17.6 to 3.17.8 in /backend
2024-12-31 20:00:01 -08:00
Timothy Jaeryang Baek 06301aa90d Merge pull request #8243 from open-webui/dependabot/pip/backend/dev/validators-0.34.0
build(deps): bump validators from 0.33.0 to 0.34.0 in /backend
2024-12-31 19:59:45 -08:00
Timothy Jaeryang Baek 55fbc60ad3 Merge pull request #8244 from open-webui/dependabot/pip/backend/dev/flask-3.1.0
build(deps): bump flask from 3.0.3 to 3.1.0 in /backend
2024-12-31 19:59:36 -08:00
Timothy Jaeryang Baek b69941caf4 Merge pull request #8242 from open-webui/dependabot/pip/backend/dev/fpdf2-2.8.2
build(deps): bump fpdf2 from 2.7.9 to 2.8.2 in /backend
2024-12-31 19:59:13 -08:00
Timothy Jaeryang Baek 80600dbe63 Merge pull request #8241 from open-webui/dependabot/pip/backend/dev/pyjwt-crypto--2.10.1
build(deps): bump pyjwt[crypto] from 2.9.0 to 2.10.1 in /backend
2024-12-31 19:58:59 -08:00
Timothy Jaeryang Baek 085f48366b refac 2024-12-31 19:54:11 -08:00
Timothy Jaeryang Baek 60fc24cd39 refac: channel message input 2024-12-31 19:42:49 -08:00
dependabot[bot] 7fb87ddf99 build(deps): bump peewee from 3.17.6 to 3.17.8 in /backend
Bumps [peewee](https://github.com/coleifer/peewee) from 3.17.6 to 3.17.8.
- [Release notes](https://github.com/coleifer/peewee/releases)
- [Changelog](https://github.com/coleifer/peewee/blob/master/CHANGELOG.md)
- [Commits](https://github.com/coleifer/peewee/compare/3.17.6...3.17.8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:15:02 +00:00
dependabot[bot] b41c1128c6 build(deps): bump flask from 3.0.3 to 3.1.0 in /backend
Bumps [flask](https://github.com/pallets/flask) from 3.0.3 to 3.1.0.
- [Release notes](https://github.com/pallets/flask/releases)
- [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/flask/compare/3.0.3...3.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:54 +00:00
dependabot[bot] 5bb308a098 build(deps): bump validators from 0.33.0 to 0.34.0 in /backend
Bumps [validators](https://github.com/python-validators/validators) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/python-validators/validators/releases)
- [Changelog](https://github.com/python-validators/validators/blob/master/CHANGES.md)
- [Commits](https://github.com/python-validators/validators/compare/0.33.0...0.34.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:51 +00:00
dependabot[bot] 236410cd95 build(deps): bump fpdf2 from 2.7.9 to 2.8.2 in /backend
Bumps [fpdf2](https://github.com/py-pdf/fpdf2) from 2.7.9 to 2.8.2.
- [Release notes](https://github.com/py-pdf/fpdf2/releases)
- [Changelog](https://github.com/py-pdf/fpdf2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/fpdf2/compare/2.7.9...2.8.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:48 +00:00
dependabot[bot] f1800e2853 build(deps): bump pyjwt[crypto] from 2.9.0 to 2.10.1 in /backend
Bumps [pyjwt[crypto]](https://github.com/jpadilla/pyjwt) from 2.9.0 to 2.10.1.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.9.0...2.10.1)

---
updated-dependencies:
- dependency-name: pyjwt[crypto]
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-01 02:14:45 +00:00
Timothy Jaeryang Baek 4bc9904b3c Merge pull request #8159 from open-webui/dev
Create and publish Docker images with specific build args / build-main-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
0.5.3
2024-12-31 13:41:03 -08:00
Timothy Jaeryang Baek 995f80002f doc: changelog 2024-12-31 13:40:49 -08:00
Timothy Jaeryang Baek ffdb45cc4b refac 2024-12-31 13:39:03 -08:00
Timothy Jaeryang Baek f5ee5deec8 chore: format 2024-12-31 13:30:59 -08:00
Timothy Jaeryang Baek 02777ffc54 doc: changelog 2024-12-31 13:23:05 -08:00
Timothy Jaeryang Baek a5627d4226 refac 2024-12-31 13:09:17 -08:00
Timothy Jaeryang Baek 435a174316 Merge pull request #8232 from okamototk/improve-jp-trans
i18n - Updated Japanese Translation
2024-12-31 13:04:58 -08:00
Timothy Jaeryang Baek d905202afb refac 2024-12-31 13:04:27 -08:00
Takashi Okamoto 221cc19ac6 i18n - Updated Japanese Translation 2024-12-31 11:58:43 +00:00
Timothy Jaeryang Baek 3d69eaf34f refac: styling 2024-12-31 03:57:43 -08:00
Timothy Jaeryang Baek e7c501677c refac 2024-12-31 03:54:43 -08:00
Timothy Jaeryang Baek 96b1c2024b refac 2024-12-31 03:45:54 -08:00
Timothy Jaeryang Baek f7ecfafe1a refac: styling 2024-12-31 03:25:25 -08:00
Timothy Jaeryang Baek 6ecee8b920 refac: emoji picker optimisation 2024-12-31 03:24:05 -08:00
Timothy Jaeryang Baek cce8f37ada refac 2024-12-31 02:58:15 -08:00
Timothy Jaeryang Baek 60247fe18d refac 2024-12-31 02:51:52 -08:00
Timothy Jaeryang Baek de4e086bd5 refac 2024-12-31 02:48:43 -08:00
Timothy Jaeryang Baek 3da7ff1721 refac 2024-12-31 02:42:46 -08:00
Timothy Jaeryang Baek c8cdc6377b refac 2024-12-31 02:30:03 -08:00
Timothy Jaeryang Baek 85038cef69 refac: styling 2024-12-31 02:27:49 -08:00
Timothy Jaeryang Baek 016b91adda chore: format 2024-12-31 02:26:30 -08:00
Timothy Jaeryang Baek 0846a08795 Merge pull request #8230 from moblangeois/dev
i18n: update fr-FR
2024-12-31 02:24:42 -08:00
Timothy Jaeryang Baek 277eef62c2 Merge pull request #8210 from open-webui/channels
enh: channel threads & reactions
2024-12-31 02:24:10 -08:00
Timothy Jaeryang Baek 9f19933e64 refac: styling 2024-12-31 02:23:26 -08:00
Timothy Jaeryang Baek 904ddcec13 refac: styling 2024-12-31 02:19:22 -08:00
Timothy Jaeryang Baek 77da0a6cc1 refac: styling 2024-12-31 02:17:39 -08:00
Timothy Jaeryang Baek a754a4388a feat: channel threads 2024-12-31 02:16:07 -08:00
Morgan Blangeois 93f262c6a2 i18n: update fr-FR 2024-12-31 11:09:31 +01:00
Timothy Jaeryang Baek 16a48ef4eb fix 2024-12-31 02:06:55 -08:00
Timothy Jaeryang Baek 841f74894e refac 2024-12-31 02:06:19 -08:00
Timothy Jaeryang Baek 9c337552e6 enh: reply count 2024-12-31 02:05:11 -08:00
Timothy Jaeryang Baek 584e9e6da5 refac: threads 2024-12-31 00:51:43 -08:00
Timothy Jaeryang Baek a3dfa90668 refac: optimisation 2024-12-31 00:11:46 -08:00
Timothy Jaeryang Baek 00e8849445 refac 2024-12-30 23:55:19 -08:00
Timothy Jaeryang Baek 2840ff405b refac 2024-12-30 23:48:55 -08:00
Timothy Jaeryang Baek 9d39404e6c refac: styling 2024-12-30 23:16:50 -08:00
Timothy Jaeryang Baek 371201d820 refac 2024-12-30 23:12:50 -08:00
Timothy Jaeryang Baek f93c2e4a8d feat: reactions 2024-12-30 23:06:34 -08:00
Timothy Jaeryang Baek 4b0fa112bb Merge pull request #8222 from open-webui/dev
dev
2024-12-30 17:55:13 -08:00
Timothy Jaeryang Baek fd0170c179 revert 2024-12-30 16:55:29 -08:00
Timothy Jaeryang Baek 9b56b64cfa Merge pull request #8212 from ashm-dev/main
feat: Small optimization
2024-12-30 16:00:18 -08:00
Timothy Jaeryang Baek 887627fa34 Merge pull request #8217 from gabriel-ecegi/batch_fix
fix: Add missing request parameter to knowledge and retrieval routes
2024-12-30 15:58:10 -08:00
Timothy Jaeryang Baek 46bcf98ef2 fix: usage stats 2024-12-30 15:52:07 -08:00
Timothy Jaeryang Baek 947f5600d6 refac 2024-12-30 15:39:35 -08:00
Timothy Jaeryang Baek 79ce6e0a3f refac 2024-12-30 11:29:18 -08:00
Gabriel Ecegi 46e57706c1 refac: formatting 2024-12-30 17:45:43 +01:00
Gabriel Ecegi ba2964cb01 fix: missing parameter 2024-12-30 17:36:34 +01:00
shamil a0aee4ff28 feat: Small optimization 2024-12-30 13:45:20 +03:00
Timothy Jaeryang Baek 45289b0fa8 refac 2024-12-30 02:21:45 -08:00
Timothy Jaeryang Baek eece28ccc6 feat: emoji picker 2024-12-30 02:20:09 -08:00
Timothy Jaeryang Baek c216d89520 enh: threads & reactions 2024-12-29 23:16:18 -08:00
Timothy Jaeryang Baek d94134ad2d refac 2024-12-29 22:11:20 -08:00
Timothy Jaeryang Baek 952822ad5d refac 2024-12-29 21:02:11 -08:00
Timothy Jaeryang Baek 79d7e67e51 fix: ollama options 2024-12-29 16:30:36 -08:00
Timothy Jaeryang Baek 6de1923151 Merge pull request #8192 from TiancongLx/dev
i18n: update zh-TW
2024-12-29 15:36:19 -08:00
Tiancong Li abdbd6a67e i18n: update zh-TW 2024-12-30 03:13:20 +08:00
Timothy Jaeryang Baek a14dee87e8 Merge pull request #8184 from vishwamartur/fix-offline-docker
Fix offline docker container startup issue
2024-12-28 22:26:38 -08:00
Vishwanath Martur 00e6ffe83c Fix offline docker container startup issue
Related to #7207

Modify the code to allow the docker container to start in an offline environment for versions >= 0.4.0.

* **backend/open_webui/retrieval/utils.py**
  - Import `OFFLINE_MODE` from `open_webui.env`.
  - Set `local_files_only` to `True` when `OFFLINE_MODE` is enabled in `snapshot_kwargs`.

* **backend/open_webui/env.py**
  - Add logic to set `HF_HUB_OFFLINE` environment variable to `1` when `OFFLINE_MODE` is enabled.

* **README.md**
  - Document setting `HF_HUB_OFFLINE` environment variable to `1` for offline environments.
2024-12-29 11:53:09 +05:30
Timothy Jaeryang Baek 36d927e1ba refac 2024-12-28 19:31:03 -08:00
Timothy Jaeryang Baek 091e9e2f40 refac 2024-12-28 18:08:30 -08:00
Timothy Jaeryang Baek 54daf3b765 fix: outlet filter hook 2024-12-28 18:05:07 -08:00
Timothy Jaeryang Baek 76decdba2a fix: delete file 2024-12-28 17:40:00 -08:00
Timothy Jaeryang Baek e6e8978f78 refac 2024-12-28 16:42:43 -08:00
Timothy Jaeryang Baek fcaeb2f539 Merge pull request #8179 from cvaz1306/add_reset_button_mermaidjs_renderer
i18n: Add reset panzoom button to SVGPanZoom and cleanup
2024-12-28 16:27:44 -08:00
cvaz1306 f7d1833f41 Fixing formatting 2024-12-28 15:50:23 -08:00
cvaz1306 95598e5435 Merge branch 'dev' into add_reset_button_mermaidjs_renderer 2024-12-28 15:46:23 -08:00
cvaz1306 8a7e8b57a8 Add reset panzoom button to SVGPanZoom and cleanup 2024-12-28 15:27:47 -08:00
Timothy Jaeryang Baek 5a720a4a31 refac 2024-12-27 22:46:50 -08:00
Timothy Jaeryang Baek d55884b50e enh: ENABLE_REALTIME_CHAT_SAVE 2024-12-27 22:36:14 -08:00
Timothy Jaeryang Baek c8fb11db59 Merge pull request #8158 from cvaz1306/add_reset_button_mermaidjs_renderer
feat: Add reset button mermaidjs renderer
2024-12-27 17:55:00 -08:00
cvaz1306 7b3a86475d Add reset panzoom button to SVGPanZoom and cleanup 2024-12-27 17:06:15 -08:00
Timothy Jaeryang Baek 59ed53ec1e Merge pull request #8145 from bernadinm/fix/modified_update_ollama_models.sh
fix: Add execute permissions to update_ollama_models.sh
2024-12-27 16:24:32 -08:00
Timothy Jaeryang Baek 7f8b6ebbd5 refac: styling 2024-12-27 12:24:58 -08:00
Miguel Bernadin 3f0dd80898 fix: chmod +x update_ollama_models.sh 2024-12-27 08:29:54 -08:00
Timothy Jaeryang Baek 2299f48430 Merge pull request #8131 from open-webui/dev
fix: api key restrictions
2024-12-27 00:32:37 -08:00
Timothy Jaeryang Baek 99386bf680 fix: api key restrictions 2024-12-27 00:32:25 -08:00
Timothy Jaeryang Baek 4e4e2792b1 Merge pull request #8130 from open-webui/dev
refac
2024-12-27 00:06:41 -08:00
Timothy Jaeryang Baek d99e3d49e1 refac 2024-12-27 00:05:32 -08:00
MadsLang 6f1065b56a Merge branch 'dev' of github.com:open-webui/open-webui 2024-12-16 10:00:24 +01:00
MadsLang 332188535e add option to add database schema as env var 2024-12-10 15:18:26 +01:00
4204 changed files with 35524 additions and 9533 deletions
+25
View File
@@ -0,0 +1,25 @@
# Codespell configuration is within pyproject.toml
---
name: Codespell
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
@@ -52,6 +52,8 @@ jobs:
- name: Cypress run
uses: cypress-io/github-action@v6
env:
LIBGL_ALWAYS_SOFTWARE: 1
with:
browser: chrome
wait-on: 'http://localhost:3000'
+199
View File
@@ -5,6 +5,205 @@ 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.5.13] - 2025-02-17
### Added
- **🌐 Full Context Mode for Web Search**: Enable highly accurate web searches by utilizing full context mode—ideal for models with large context windows, ensuring more precise and insightful results.
- **⚡ Optimized Asynchronous Web Search**: Web searches now load significantly faster with optimized async support, providing users with quicker, more efficient information retrieval.
- **🔄 Auto Text Direction for RTL Languages**: Automatic text alignment based on language input, ensuring seamless conversation flow for Arabic, Hebrew, and other right-to-left scripts.
- **🚀 Jupyter Notebook Support for Code Execution**: The "Run" button in code blocks can now use Jupyter for execution, offering a powerful, dynamic coding experience directly in the chat.
- **🗑️ Message Delete Confirmation Dialog**: Prevent accidental deletions with a new confirmation prompt before removing messages, adding an additional layer of security to your chat history.
- **📥 Download Button for SVG Diagrams**: SVG diagrams generated within chat can now be downloaded instantly, making it easier to save and share complex visual data.
- **✨ General UI/UX Improvements and Backend Stability**: A refined interface with smoother interactions, improved layouts, and backend stability enhancements for a more reliable, polished experience.
### Fixed
- **🛠️ Temporary Chat Message Continue Button Fixed**: The "Continue Response" button for temporary chats now works as expected, ensuring an uninterrupted conversation flow.
### Changed
- **📝 Prompt Variable Update**: Deprecated square bracket '[]' indicators for prompt variables; now requires double curly brackets '{{}}' for consistency and clarity.
- **🔧 Stability Enhancements**: Error handling improved in chat history, ensuring smoother operations when reviewing previous messages.
## [0.5.12] - 2025-02-13
### Added
- **🛠️ Multiple Tool Calls Support for Native Function Mode**: Functions now can call multiple tools within a single response, unlocking better automation and workflow flexibility when using native function calling.
### Fixed
- **📝 Playground Text Completion Restored**: Addressed an issue where text completion in the Playground was not functioning.
- **🔗 Direct Connections Now Work for Regular Users**: Fixed a bug where users with the 'user' role couldn't establish direct API connections, enabling seamless model usage for all user tiers.
- **⚡ Landing Page Input No Longer Lags with Long Text**: Improved input responsiveness on the landing page, ensuring fast and smooth typing experiences even when entering long messages.
- **🔧 Parameter in Functions Fixed**: Fixed an issue where the reserved parameters wasnt recognized within functions, restoring full functionality for advanced task-based automation.
## [0.5.11] - 2025-02-13
### Added
- **🎤 Kokoro-JS TTS Support**: A new on-device, high-quality text-to-speech engine has been integrated, vastly improving voice generation quality—everything runs directly in your browser.
- **🐍 Jupyter Notebook Support in Code Interpreter**: Now, you can configure Code Interpreter to run Python code not only via Pyodide but also through Jupyter, offering a more robust coding environment for AI-driven computations and analysis.
- **🔗 Direct API Connections for Private & Local Inference**: You can now connect Open WebUI to your private or localhost API inference endpoints. CORS must be enabled, but this unlocks direct, on-device AI infrastructure support.
- **🔍 Advanced Domain Filtering for Web Search**: You can now specify which domains should be included or excluded from web searches, refining results for more relevant information retrieval.
- **🚀 Improved Image Generation Metadata Handling**: Generated images now retain metadata for better organization and future retrieval.
- **📂 S3 Key Prefix Support**: Fine-grained control over S3 storage file structuring with configurable key prefixes.
- **📸 Support for Image-Only Messages**: Send messages containing only images, facilitating more visual-centric interactions.
- **🌍 Updated Translations**: German, Spanish, Traditional Chinese, and Catalan translations updated for better multilingual support.
### Fixed
- **🔧 OAuth Debug Logs & Username Claim Fixes**: Debug logs have been added for OAuth role and group management, with fixes ensuring proper OAuth username retrieval and claim handling.
- **📌 Citations Formatting & Toggle Fixes**: Inline citation toggles now function correctly, and citations with more than three sources are now fully visible when expanded.
- **📸 ComfyUI Maximum Seed Value Constraint Fixed**: The maximum allowed seed value for ComfyUI has been corrected, preventing unintended behavior.
- **🔑 Connection Settings Stability**: Addressed connection settings issues that were causing instability when saving configurations.
- **📂 GGUF Model Upload Stability**: Fixed upload inconsistencies for GGUF models, ensuring reliable local model handling.
- **🔧 Web Search Configuration Bug**: Fixed issues where web search filters and settings weren't correctly applied.
- **💾 User Settings Persistence Fix**: Ensured user-specific settings are correctly saved and applied across sessions.
- **🔄 OpenID Username Retrieval Enhancement**: Usernames are now correctly picked up and assigned for OpenID Connect (OIDC) logins.
## [0.5.10] - 2025-02-05
### Fixed
- **⚙️ System Prompts Now Properly Templated via API**: Resolved an issue where system prompts were not being correctly processed when used through the API, ensuring template variables now function as expected.
- **📝 '<thinking>' Tag Display Issue Fixed**: Fixed a bug where the 'thinking' tag was disrupting content rendering, ensuring clean and accurate text display.
- **💻 Code Interpreter Stability with Custom Functions**: Addressed failures when using the Code Interpreter with certain custom functions like Anthropic, ensuring smoother execution and better compatibility.
## [0.5.9] - 2025-02-05
### Fixed
- **💡 "Think" Tag Display Issue**: Resolved a bug where the "Think" tag was not functioning correctly, ensuring proper visualization of the model's reasoning process before delivering responses.
## [0.5.8] - 2025-02-05
### Added
- **🖥️ Code Interpreter**: Models can now execute code in real time to refine their answers dynamically, running securely within a sandboxed browser environment using Pyodide. Perfect for calculations, data analysis, and AI-assisted coding tasks!
- **💬 Redesigned Chat Input UI**: Enjoy a sleeker and more intuitive message input with improved feature selection, making it easier than ever to toggle tools, enable search, and interact with AI seamlessly.
- **🛠️ Native Tool Calling Support (Experimental)**: Supported models can now call tools natively, reducing query latency and improving contextual responses. More enhancements coming soon!
- **🔗 Exa Search Engine Integration**: A new search provider has been added, allowing users to retrieve up-to-date and relevant information without leaving the chat interface.
- **🌍 Localized Dates & Times**: Date and time formats now match your system locale, ensuring a more natural, region-specific experience.
- **📎 User Headers for External Embedding APIs**: API calls to external embedding services now include user-related headers.
- **🌍 "Always On" Web Search Toggle**: A new option under Settings > Interface allows users to enable Web Search by default—transform Open WebUI into your go-to search engine, ensuring AI-powered results with every query.
- **🚀 General Performance & Stability**: Significant improvements across the platform for a faster, more reliable experience.
- **🖼️ UI/UX Enhancements**: Numerous design refinements improving readability, responsiveness, and accessibility.
- **🌍 Improved Translations**: Chinese, Korean, French, Ukrainian and Serbian translations have been updated with refined terminologies for better clarity.
### Fixed
- **🔄 OAuth Name Field Fallback**: Resolves OAuth login failures by using the email field as a fallback when a name is missing.
- **🔑 Google Drive Credentials Restriction**: Ensures only authenticated users can access Google Drive credentials for enhanced security.
- **🌐 DuckDuckGo Search Rate Limit Handling**: Fixes issues where users would encounter 202 errors due to rate limits when using DuckDuckGo for web search.
- **📁 File Upload Permission Indicator**: Users are now notified when they lack permission to upload files, improving clarity on system restrictions.
- **🔧 Max Tokens Issue**: Fixes cases where 'max_tokens' were not applied correctly, ensuring proper model behavior.
- **🔍 Validation for RAG Web Search URLs**: Filters out invalid or unsupported URLs when using web-based retrieval augmentation.
- **🖋️ Title Generation Bug**: Fixes inconsistencies in title generation, ensuring proper chat organization.
### Removed
- **⚡ Deprecated Non-Web Worker Pyodide Execution**: Moves entirely to browser sandboxing for better performance and security.
## [0.5.7] - 2025-01-23
### Added
- **🌍 Enhanced Internationalization (i18n)**: Refined and expanded translations for greater global accessibility and a smoother experience for international users.
### Fixed
- **🔗 Connection Model ID Resolution**: Resolved an issue preventing model IDs from registering in connections.
- **💡 Prefix ID for Ollama Connections**: Fixed a bug where prefix IDs in Ollama connections were non-functional.
- **🔧 Ollama Model Enable/Disable Functionality**: Addressed the issue of enable/disable toggles not working for Ollama base models.
- **🔒 RBAC Permissions for Tools and Models**: Corrected incorrect Role-Based Access Control (RBAC) permissions for tools and models, ensuring that users now only access features according to their assigned privileges, enhancing security and role clarity.
## [0.5.6] - 2025-01-22
### Added
- **🧠 Effortful Reasoning Control for OpenAI Models**: Introduced the reasoning_effort parameter in chat controls for supported OpenAI models, enabling users to fine-tune how much cognitive effort a model dedicates to its responses, offering greater customization for complex queries and reasoning tasks.
### Fixed
- **🔄 Chat Controls Loading UI Bug**: Resolved an issue where collapsible chat controls appeared as "loading," ensuring a smoother and more intuitive user experience for managing chat settings.
### Changed
- **🔧 Updated Ollama Model Creation**: Revamped the Ollama model creation method to align with their new JSON payload format, ensuring seamless compatibility and more efficient model setup workflows.
## [0.5.5] - 2025-01-22
### Added
- **🤔 Native 'Think' Tag Support**: Introduced the new 'think' tag support that visually displays how long the model is thinking, omitting the reasoning content itself until the next turn. Ideal for creating a more streamlined and focused interaction experience.
- **🖼️ Toggle Image Generation On/Off**: In the chat input menu, you can now easily toggle image generation before initiating chats, providing greater control and flexibility to suit your needs.
- **🔒 Chat Controls Permissions**: Admins can now disable chat controls access for users, offering tighter management and customization over user interactions.
- **🔍 Web Search & Image Generation Permissions**: Easily disable web search and image generation for specific users, improving workflow governance and security for certain environments.
- **🗂️ S3 and GCS Storage Provider Support**: Scaled deployments now benefit from expanded storage options with Amazon S3 and Google Cloud Storage seamlessly integrated as providers.
- **🎨 Enhanced Model Management**: Reintroduced the ability to download and delete models directly in the admin models settings page to minimize user confusion and aid efficient model management.
- **🔗 Improved Connection Handling**: Enhanced backend to smoothly handle multiple identical base URLs, allowing more flexible multi-instance configurations with fewer hiccups.
- **✨ General UI/UX Refinements**: Numerous tweaks across the WebUI make navigation and usability even more user-friendly and intuitive.
- **🌍 Translation Enhancements**: Various translation updates ensure smoother and more polished interactions for international users.
### Fixed
- **⚡ MPS Functionality for Mac Users**: Fixed MPS support, ensuring smooth performance and compatibility for Mac users leveraging MPS.
- **📡 Ollama Connection Management**: Resolved the issue where deleting all Ollama connections prevented adding new ones.
### Changed
- **⚙️ General Stability Refac**: Backend refactoring delivers a more stable, robust platform.
- **🖥️ Desktop App Preparations**: Ongoing work to support the upcoming Open WebUI desktop app. Follow our progress and updates here: https://github.com/open-webui/desktop
## [0.5.4] - 2025-01-05
### Added
- **🔄 Clone Shared Chats**: Effortlessly clone shared chats to save time and streamline collaboration, perfect for reusing insightful discussions or custom setups.
- **📣 Native Notifications for Channel Messages**: Stay informed with integrated desktop notifications for channel messages, ensuring you never miss important updates while multitasking.
- **🔥 Torch MPS Support**: MPS support for Mac users when Open WebUI is installed directly, offering better performance and compatibility for AI workloads.
- **🌍 Enhanced Translations**: Small improvements to various translations, ensuring a smoother global user experience.
### Fixed
- **🖼️ Image-Only Messages in Channels**: You can now send images without accompanying text or content in channels.
- **❌ Proper Exception Handling**: Enhanced error feedback by ensuring exceptions are raised clearly, reducing confusion and promoting smoother debugging.
- **🔍 RAG Query Generation Restored**: Fixed query generation issues for Retrieval-Augmented Generation, improving retrieval accuracy and ensuring seamless functionality.
- **📩 MOA Response Functionality Fixed**: Addressed an error with the MOA response generation feature.
- **💬 Channel Thread Loading with 50+ Messages**: Resolved an issue where channel threads stalled when exceeding 50 messages, ensuring smooth navigation in active discussions.
- **🔑 API Endpoint Restrictions Resolution**: Fixed a critical bug where the 'API_KEY_ALLOWED_ENDPOINTS' setting was not functioning as intended, ensuring API access is limited to specified endpoints for enhanced security.
- **🛠️ Action Functions Restored**: Corrected an issue preventing action functions from working, restoring their utility for customized automations and workflows.
- **📂 Temporary Chat JSON Export Fix**: Resolved a bug blocking temporary chats from being exported in JSON format, ensuring seamless data portability.
### Changed
- **🎛️ Sidebar UI Tweaks**: Chat folders, including pinned folders, now display below the Chats section for better organization; the "New Folder" button has been relocated to the Chats section for a more intuitive workflow.
- **🏗️ Real-Time Save Disabled by Default**: The 'ENABLE_REALTIME_CHAT_SAVE' setting is now off by default, boosting response speed for users who prioritize performance in high-paced workflows or less critical scenarios.
- **🎤 Audio Input Echo Cancellation**: Audio input now features echo cancellation enabled by default, reducing audio feedback for improved clarity during conversations or voice-based interactions.
- **🔧 General Reliability Improvements**: Numerous under-the-hood enhancements have been made to improve platform stability, boost overall performance, and ensure a more seamless, dependable experience across workflows.
## [0.5.3] - 2024-12-31
### Added
- **💬 Channel Reactions with Built-In Emoji Picker**: Easily express yourself in channel threads and messages with reactions, featuring an intuitive built-in emoji picker for seamless selection.
- **🧵 Threads for Channels**: Organize discussions within channels by creating threads, improving clarity and fostering focused conversations.
- **🔄 Reset Button for SVG Pan/Zoom**: Added a handy reset button to SVG Pan/Zoom, allowing users to quickly return diagrams or visuals to their default state without hassle.
- **⚡ Realtime Chat Save Environment Variable**: Introduced the ENABLE_REALTIME_CHAT_SAVE environment variable. Choose between faster responses by disabling realtime chat saving or ensuring chunk-by-chunk data persistency for critical operations.
- **🌍 Translation Enhancements**: Updated and refined translations across multiple languages, providing a smoother experience for international users.
- **📚 Improved Documentation**: Expanded documentation on functions, including clearer guidance on function plugins and detailed instructions for migrating to v0.5. This ensures users can adapt and harness new updates more effectively. (https://docs.openwebui.com/features/plugin/)
### Fixed
- **🛠️ Ollama Parameters Respected**: Resolved an issue where input parameters for Ollama were being ignored, ensuring precise and consistent model behavior.
- **🔧 Function Plugin Outlet Hook Reliability**: Fixed a bug causing issues with 'event_emitter' and outlet hooks in filter function plugins, guaranteeing smoother operation within custom extensions.
- **🖋️ Weird Custom Status Descriptions**: Adjusted the formatting and functionality for custom user statuses, ensuring they display correctly and intuitively.
- **🔗 Restored API Functionality**: Fixed a critical issue where APIs were not operational for certain configurations, ensuring uninterrupted access.
- **⏳ Custom Pipe Function Completion**: Resolved an issue where chats using specific custom pipe function plugins werent finishing properly, restoring consistent chat workflows.
- **✅ General Stability Enhancements**: Implemented various under-the-hood improvements to boost overall reliability, ensuring smoother and more consistent performance across the WebUI.
## [0.5.2] - 2024-12-26
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
## Our Pledge
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct.
+23 -17
View File
@@ -1,21 +1,27 @@
MIT License
Copyright (c) 2023-2025 Timothy Jaeryang Baek
All rights reserved.
Copyright (c) 2023 Timothy Jaeryang Baek
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+18 -3
View File
@@ -11,10 +11,17 @@
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
Open WebUI is an [extensible](https://github.com/open-webui/pipelines), feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
![Open WebUI Demo](./demo.gif)
> [!TIP]
> **Looking for an [Enterprise Plan](https://docs.openwebui.com/enterprise)?** **[Speak with Our Sales Team Today!](mailto:sales@openwebui.com)**
>
> Get **enhanced capabilities**, including **custom theming and branding**, **Service Level Agreement (SLA) support**, **Long-Term Support (LTS) versions**, and **more!**
For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
## Key Features of Open WebUI ⭐
- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images.
@@ -172,7 +179,7 @@ docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/wa
In the last part of the command, replace `open-webui` with your container name if it is different.
Check our Migration Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/tutorials/migration/).
Check our Updating Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/updating).
### Using the Dev Branch 🌙
@@ -185,13 +192,21 @@ If you want to try out the latest bleeding-edge features and are okay with occas
docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --add-host=host.docker.internal:host-gateway --restart always ghcr.io/open-webui/open-webui:dev
```
### Offline Mode
If you are running Open WebUI in an offline environment, you can set the `HF_HUB_OFFLINE` environment variable to `1` to prevent attempts to download models from the internet.
```bash
export HF_HUB_OFFLINE=1
```
## What's Next? 🌟
Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/).
## License 📜
This project is licensed under the [MIT License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
## Support 💬
+19
View File
@@ -5,12 +5,31 @@ from pathlib import Path
import typer
import uvicorn
from typing import Optional
from typing_extensions import Annotated
app = typer.Typer()
KEY_FILE = Path.cwd() / ".webui_secret_key"
def version_callback(value: bool):
if value:
from open_webui.env import VERSION
typer.echo(f"Open WebUI version: {VERSION}")
raise typer.Exit()
@app.command()
def main(
version: Annotated[
Optional[bool], typer.Option("--version", callback=version_callback)
] = None,
):
pass
@app.command()
def serve(
host: str = "0.0.0.0",
+426 -52
View File
@@ -2,6 +2,8 @@ import json
import logging
import os
import shutil
import base64
from datetime import datetime
from pathlib import Path
from typing import Generic, Optional, TypeVar
@@ -9,22 +11,22 @@ from urllib.parse import urlparse
import chromadb
import requests
import yaml
from open_webui.internal.db import Base, get_db
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
from open_webui.env import (
OPEN_WEBUI_DIR,
DATA_DIR,
DATABASE_URL,
ENV,
FRONTEND_BUILD_DIR,
OFFLINE_MODE,
OPEN_WEBUI_DIR,
WEBUI_AUTH,
WEBUI_FAVICON_URL,
WEBUI_NAME,
log,
DATABASE_URL,
OFFLINE_MODE,
)
from pydantic import BaseModel
from sqlalchemy import JSON, Column, DateTime, Integer, func
from open_webui.internal.db import Base, get_db
class EndpointFilter(logging.Filter):
@@ -362,6 +364,30 @@ MICROSOFT_REDIRECT_URI = PersistentConfig(
os.environ.get("MICROSOFT_REDIRECT_URI", ""),
)
GITHUB_CLIENT_ID = PersistentConfig(
"GITHUB_CLIENT_ID",
"oauth.github.client_id",
os.environ.get("GITHUB_CLIENT_ID", ""),
)
GITHUB_CLIENT_SECRET = PersistentConfig(
"GITHUB_CLIENT_SECRET",
"oauth.github.client_secret",
os.environ.get("GITHUB_CLIENT_SECRET", ""),
)
GITHUB_CLIENT_SCOPE = PersistentConfig(
"GITHUB_CLIENT_SCOPE",
"oauth.github.scope",
os.environ.get("GITHUB_CLIENT_SCOPE", "user:email"),
)
GITHUB_CLIENT_REDIRECT_URI = PersistentConfig(
"GITHUB_CLIENT_REDIRECT_URI",
"oauth.github.redirect_uri",
os.environ.get("GITHUB_CLIENT_REDIRECT_URI", ""),
)
OAUTH_CLIENT_ID = PersistentConfig(
"OAUTH_CLIENT_ID",
"oauth.oidc.client_id",
@@ -468,12 +494,20 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig(
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
def google_oauth_register(client):
client.register(
name="google",
client_id=GOOGLE_CLIENT_ID.value,
client_secret=GOOGLE_CLIENT_SECRET.value,
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
redirect_uri=GOOGLE_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["google"] = {
"client_id": GOOGLE_CLIENT_ID.value,
"client_secret": GOOGLE_CLIENT_SECRET.value,
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
"scope": GOOGLE_OAUTH_SCOPE.value,
"redirect_uri": GOOGLE_REDIRECT_URI.value,
"register": google_oauth_register,
}
if (
@@ -481,12 +515,44 @@ def load_oauth_providers():
and MICROSOFT_CLIENT_SECRET.value
and MICROSOFT_CLIENT_TENANT_ID.value
):
def microsoft_oauth_register(client):
client.register(
name="microsoft",
client_id=MICROSOFT_CLIENT_ID.value,
client_secret=MICROSOFT_CLIENT_SECRET.value,
server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
client_kwargs={
"scope": MICROSOFT_OAUTH_SCOPE.value,
},
redirect_uri=MICROSOFT_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["microsoft"] = {
"client_id": MICROSOFT_CLIENT_ID.value,
"client_secret": MICROSOFT_CLIENT_SECRET.value,
"server_metadata_url": f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
"scope": MICROSOFT_OAUTH_SCOPE.value,
"redirect_uri": MICROSOFT_REDIRECT_URI.value,
"picture_url": "https://graph.microsoft.com/v1.0/me/photo/$value",
"register": microsoft_oauth_register,
}
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
def github_oauth_register(client):
client.register(
name="github",
client_id=GITHUB_CLIENT_ID.value,
client_secret=GITHUB_CLIENT_SECRET.value,
access_token_url="https://github.com/login/oauth/access_token",
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com",
userinfo_endpoint="https://api.github.com/user",
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["github"] = {
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
"register": github_oauth_register,
"sub_claim": "id",
}
if (
@@ -494,13 +560,23 @@ def load_oauth_providers():
and OAUTH_CLIENT_SECRET.value
and OPENID_PROVIDER_URL.value
):
def oidc_oauth_register(client):
client.register(
name="oidc",
client_id=OAUTH_CLIENT_ID.value,
client_secret=OAUTH_CLIENT_SECRET.value,
server_metadata_url=OPENID_PROVIDER_URL.value,
client_kwargs={
"scope": OAUTH_SCOPES.value,
},
redirect_uri=OPENID_REDIRECT_URI.value,
)
OAUTH_PROVIDERS["oidc"] = {
"client_id": OAUTH_CLIENT_ID.value,
"client_secret": OAUTH_CLIENT_SECRET.value,
"server_metadata_url": OPENID_PROVIDER_URL.value,
"scope": OAUTH_SCOPES.value,
"name": OAUTH_PROVIDER_NAME.value,
"redirect_uri": OPENID_REDIRECT_URI.value,
"register": oidc_oauth_register,
}
@@ -512,6 +588,20 @@ load_oauth_providers()
STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve()
def override_static(path: str, content: str):
# Ensure path is safe
if "/" in path or ".." in path:
log.error(f"Invalid path: {path}")
return
file_path = os.path.join(STATIC_DIR, path)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
f.write(base64.b64decode(content)) # Convert Base64 back to raw binary
frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png"
if frontend_favicon.exists():
@@ -519,8 +609,6 @@ if frontend_favicon.exists():
shutil.copyfile(frontend_favicon, STATIC_DIR / "favicon.png")
except Exception as e:
logging.error(f"An error occurred: {e}")
else:
logging.warning(f"Frontend favicon not found at {frontend_favicon}")
frontend_splash = FRONTEND_BUILD_DIR / "static" / "splash.png"
@@ -529,12 +617,18 @@ if frontend_splash.exists():
shutil.copyfile(frontend_splash, STATIC_DIR / "splash.png")
except Exception as e:
logging.error(f"An error occurred: {e}")
else:
logging.warning(f"Frontend splash not found at {frontend_splash}")
frontend_loader = FRONTEND_BUILD_DIR / "static" / "loader.js"
if frontend_loader.exists():
try:
shutil.copyfile(frontend_loader, STATIC_DIR / "loader.js")
except Exception as e:
logging.error(f"An error occurred: {e}")
####################################
# CUSTOM_NAME
# CUSTOM_NAME (Legacy)
####################################
CUSTOM_NAME = os.environ.get("CUSTOM_NAME", "")
@@ -576,18 +670,34 @@ if CUSTOM_NAME:
pass
####################################
# LICENSE_KEY
####################################
LICENSE_KEY = PersistentConfig(
"LICENSE_KEY",
"license.key",
os.environ.get("LICENSE_KEY", ""),
)
####################################
# STORAGE PROVIDER
####################################
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "") # defaults to local, s3
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "local") # defaults to local, s3
S3_ACCESS_KEY_ID = os.environ.get("S3_ACCESS_KEY_ID", None)
S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY", None)
S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None)
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None)
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
"GOOGLE_APPLICATION_CREDENTIALS_JSON", None
)
####################################
# File Upload DIR
####################################
@@ -603,6 +713,17 @@ Path(UPLOAD_DIR).mkdir(parents=True, exist_ok=True)
CACHE_DIR = f"{DATA_DIR}/cache"
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
####################################
# DIRECT CONNECTIONS
####################################
ENABLE_DIRECT_CONNECTIONS = PersistentConfig(
"ENABLE_DIRECT_CONNECTIONS",
"direct.enable",
os.environ.get("ENABLE_DIRECT_CONNECTIONS", "True").lower() == "true",
)
####################################
# OLLAMA_BASE_URL
####################################
@@ -819,6 +940,10 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
)
USER_PERMISSIONS_CHAT_CONTROLS = (
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
)
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
)
@@ -835,23 +960,46 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
)
USER_PERMISSIONS_FEATURES_IMAGE_GENERATION = (
os.environ.get("USER_PERMISSIONS_FEATURES_IMAGE_GENERATION", "True").lower()
== "true"
)
USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = (
os.environ.get("USER_PERMISSIONS_FEATURES_CODE_INTERPRETER", "True").lower()
== "true"
)
DEFAULT_USER_PERMISSIONS = {
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
},
"features": {
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
"code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
},
}
USER_PERMISSIONS = PersistentConfig(
"USER_PERMISSIONS",
"user.permissions",
{
"workspace": {
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
},
"chat": {
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
"delete": USER_PERMISSIONS_CHAT_DELETE,
"edit": USER_PERMISSIONS_CHAT_EDIT,
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
},
},
DEFAULT_USER_PERMISSIONS,
)
ENABLE_CHANNELS = PersistentConfig(
@@ -995,21 +1143,27 @@ TITLE_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
os.environ.get("TITLE_GENERATION_PROMPT_TEMPLATE", ""),
)
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """Create a concise, 3-5 word title with an emoji as a title for the chat history, in the given language. Suitable Emojis for the summary can be used to enhance understanding but avoid quotation marks or special formatting. RESPOND ONLY WITH THE TITLE TEXT.
Examples of titles:
📉 Stock Market Trends
🍪 Perfect Chocolate Chip Recipe
Evolution of Music Streaming
Remote Work Productivity Tips
Artificial Intelligence in Healthcare
🎮 Video Game Development Insights
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
Generate a concise, 3-5 word title with an emoji summarizing the chat history.
### Guidelines:
- The title should clearly represent the main theme or subject of the conversation.
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
- Write the title in the chat's primary language; default to English if multilingual.
- Prioritize accuracy over excessive creativity; keep it clear and simple.
### Output:
JSON format: { "title": "your concise title here" }
### Examples:
- { "title": "📉 Stock Market Trends" },
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
- { "title": "Evolution of Music Streaming" },
- { "title": "Remote Work Productivity Tips" },
- { "title": "Artificial Intelligence in Healthcare" },
- { "title": "🎮 Video Game Development Insights" }
### Chat History:
<chat_history>
{{MESSAGES:END:2}}
</chat_history>"""
TAGS_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
"TAGS_GENERATION_PROMPT_TEMPLATE",
"task.tags.prompt_template",
@@ -1034,12 +1188,44 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] }
{{MESSAGES:END:6}}
</chat_history>"""
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
"task.image.prompt_template",
os.environ.get("IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", ""),
)
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = """### Task:
Generate a detailed prompt for am image generation task based on the given language and context. Describe the image as if you were explaining it to someone who cannot see it. Include relevant details, colors, shapes, and any other important elements.
### Guidelines:
- Be descriptive and detailed, focusing on the most important aspects of the image.
- Avoid making assumptions or adding information not present in the image.
- Use the chat's primary language; default to English if multilingual.
- If the image is too complex, focus on the most prominent elements.
### Output:
Strictly return in JSON format:
{
"prompt": "Your detailed description here."
}
### Chat History:
<chat_history>
{{MESSAGES:END:6}}
</chat_history>"""
ENABLE_TAGS_GENERATION = PersistentConfig(
"ENABLE_TAGS_GENERATION",
"task.tags.enable",
os.environ.get("ENABLE_TAGS_GENERATION", "True").lower() == "true",
)
ENABLE_TITLE_GENERATION = PersistentConfig(
"ENABLE_TITLE_GENERATION",
"task.title.enable",
os.environ.get("ENABLE_TITLE_GENERATION", "True").lower() == "true",
)
ENABLE_SEARCH_QUERY_GENERATION = PersistentConfig(
"ENABLE_SEARCH_QUERY_GENERATION",
@@ -1152,7 +1338,28 @@ TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig(
)
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = """Available Tools: {{TOOLS}}\nReturn an empty string if no tools match the query. If a function tool matches, construct and return a JSON object in the format {\"name\": \"functionName\", \"parameters\": {\"requiredFunctionParamKey\": \"requiredFunctionParamValue\"}} using the appropriate tool and its parameters. Only return the object and limit the response to the JSON object without additional text."""
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = """Available Tools: {{TOOLS}}
Your task is to choose and return the correct tool(s) from the list of available tools based on the query. Follow these guidelines:
- Return only the JSON object, without any additional text or explanation.
- If no tools match the query, return an empty array:
{
"tool_calls": []
}
- If one or more tools match the query, construct a JSON response containing a "tool_calls" array with objects that include:
- "name": The tool's name.
- "parameters": A dictionary of required parameters and their corresponding values.
The format for the JSON response is strictly:
{
"tool_calls": [
{"name": "toolName1", "parameters": {"key1": "value1"}},
{"name": "toolName2", "parameters": {"key2": "value2"}}
]
}"""
DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE = """Your task is to reflect the speaker's likely facial expression through a fitting emoji. Interpret emotions from the message and reflect their facial expression using fitting, diverse emojis (e.g., 😊, 😢, 😡, 😱).
@@ -1165,6 +1372,115 @@ Your task is to synthesize these responses into a single, high-quality response.
Responses from models: {{responses}}"""
####################################
# Code Interpreter
####################################
CODE_EXECUTION_ENGINE = PersistentConfig(
"CODE_EXECUTION_ENGINE",
"code_execution.engine",
os.environ.get("CODE_EXECUTION_ENGINE", "pyodide"),
)
CODE_EXECUTION_JUPYTER_URL = PersistentConfig(
"CODE_EXECUTION_JUPYTER_URL",
"code_execution.jupyter.url",
os.environ.get("CODE_EXECUTION_JUPYTER_URL", ""),
)
CODE_EXECUTION_JUPYTER_AUTH = PersistentConfig(
"CODE_EXECUTION_JUPYTER_AUTH",
"code_execution.jupyter.auth",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""),
)
CODE_EXECUTION_JUPYTER_AUTH_TOKEN = PersistentConfig(
"CODE_EXECUTION_JUPYTER_AUTH_TOKEN",
"code_execution.jupyter.auth_token",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""),
)
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = PersistentConfig(
"CODE_EXECUTION_JUPYTER_AUTH_PASSWORD",
"code_execution.jupyter.auth_password",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""),
)
ENABLE_CODE_INTERPRETER = PersistentConfig(
"ENABLE_CODE_INTERPRETER",
"code_interpreter.enable",
os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true",
)
CODE_INTERPRETER_ENGINE = PersistentConfig(
"CODE_INTERPRETER_ENGINE",
"code_interpreter.engine",
os.environ.get("CODE_INTERPRETER_ENGINE", "pyodide"),
)
CODE_INTERPRETER_PROMPT_TEMPLATE = PersistentConfig(
"CODE_INTERPRETER_PROMPT_TEMPLATE",
"code_interpreter.prompt_template",
os.environ.get("CODE_INTERPRETER_PROMPT_TEMPLATE", ""),
)
CODE_INTERPRETER_JUPYTER_URL = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_URL",
"code_interpreter.jupyter.url",
os.environ.get(
"CODE_INTERPRETER_JUPYTER_URL", os.environ.get("CODE_EXECUTION_JUPYTER_URL", "")
),
)
CODE_INTERPRETER_JUPYTER_AUTH = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_AUTH",
"code_interpreter.jupyter.auth",
os.environ.get(
"CODE_INTERPRETER_JUPYTER_AUTH",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""),
),
)
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
"code_interpreter.jupyter.auth_token",
os.environ.get(
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""),
),
)
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = PersistentConfig(
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
"code_interpreter.jupyter.auth_password",
os.environ.get(
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD",
os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""),
),
)
DEFAULT_CODE_INTERPRETER_PROMPT = """
#### Tools Available
1. **Code Interpreter**: `<code_interpreter type="code" lang="python"></code_interpreter>`
- You have access to a Python shell that runs directly in the user's browser, enabling fast execution of code for analysis, calculations, or problem-solving. Use it in this response.
- The Python code you write can incorporate a wide array of libraries, handle data manipulation or visualization, perform API calls for web-related tasks, or tackle virtually any computational challenge. Use this flexibility to **think outside the box, craft elegant solutions, and harness Python's full potential**.
- To use it, **you must enclose your code within `<code_interpreter type="code" lang="python">` XML tags** and stop right away. If you don't, the code won't execute. Do NOT use triple backticks.
- When coding, **always aim to print meaningful outputs** (e.g., results, tables, summaries, or visuals) to better interpret and verify the findings. Avoid relying on implicit outputs; prioritize explicit and clear print statements so the results are effectively communicated to the user.
- After obtaining the printed output, **always provide a concise analysis, interpretation, or next steps to help the user understand the findings or refine the outcome further.**
- If the results are unclear, unexpected, or require validation, refine the code and execute it again as needed. Always aim to deliver meaningful insights from the results, iterating if necessary.
- **If a link to an image, audio, or any file is provided in markdown format in the output, ALWAYS regurgitate word for word, explicitly display it as part of the response to ensure the user can access it easily, do NOT change the link.**
- All responses should be communicated in the chat's primary language, ensuring seamless understanding. If the chat is multilingual, default to English for clarity.
Ensure that the tools are effectively utilized to achieve the highest-quality analysis for the user."""
####################################
# Vector Database
####################################
@@ -1193,6 +1509,8 @@ CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
# Milvus
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
# Qdrant
QDRANT_URI = os.environ.get("QDRANT_URI", None)
@@ -1211,6 +1529,9 @@ if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"):
raise ValueError(
"Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database."
)
PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
)
####################################
# Information Retrieval (RAG)
@@ -1459,11 +1780,17 @@ RAG_WEB_SEARCH_ENGINE = PersistentConfig(
os.getenv("RAG_WEB_SEARCH_ENGINE", ""),
)
RAG_WEB_SEARCH_FULL_CONTEXT = PersistentConfig(
"RAG_WEB_SEARCH_FULL_CONTEXT",
"rag.web.search.full_context",
os.getenv("RAG_WEB_SEARCH_FULL_CONTEXT", "False").lower() == "true",
)
# You can provide a list of your own websites to filter after performing a web search.
# This ensures the highest level of safety and reliability of the information sources.
RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
"RAG_WEB_SEARCH_DOMAIN_FILTER_LIST",
"rag.rag.web.search.domain.filter_list",
"rag.web.search.domain.filter_list",
[
# "wikipedia.com",
# "wikimedia.org",
@@ -1508,6 +1835,12 @@ MOJEEK_SEARCH_API_KEY = PersistentConfig(
os.getenv("MOJEEK_SEARCH_API_KEY", ""),
)
BOCHA_SEARCH_API_KEY = PersistentConfig(
"BOCHA_SEARCH_API_KEY",
"rag.web.search.bocha_search_api_key",
os.getenv("BOCHA_SEARCH_API_KEY", ""),
)
SERPSTACK_API_KEY = PersistentConfig(
"SERPSTACK_API_KEY",
"rag.web.search.serpstack_api_key",
@@ -1556,6 +1889,18 @@ SEARCHAPI_ENGINE = PersistentConfig(
os.getenv("SEARCHAPI_ENGINE", ""),
)
SERPAPI_API_KEY = PersistentConfig(
"SERPAPI_API_KEY",
"rag.web.search.serpapi_api_key",
os.getenv("SERPAPI_API_KEY", ""),
)
SERPAPI_ENGINE = PersistentConfig(
"SERPAPI_ENGINE",
"rag.web.search.serpapi_engine",
os.getenv("SERPAPI_ENGINE", ""),
)
BING_SEARCH_V7_ENDPOINT = PersistentConfig(
"BING_SEARCH_V7_ENDPOINT",
"rag.web.search.bing_search_v7_endpoint",
@@ -1570,6 +1915,11 @@ BING_SEARCH_V7_SUBSCRIPTION_KEY = PersistentConfig(
os.environ.get("BING_SEARCH_V7_SUBSCRIPTION_KEY", ""),
)
EXA_API_KEY = PersistentConfig(
"EXA_API_KEY",
"rag.web.search.exa_api_key",
os.getenv("EXA_API_KEY", ""),
)
RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig(
"RAG_WEB_SEARCH_RESULT_COUNT",
@@ -1583,6 +1933,11 @@ RAG_WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("RAG_WEB_SEARCH_CONCURRENT_REQUESTS", "10")),
)
RAG_WEB_SEARCH_TRUST_ENV = PersistentConfig(
"RAG_WEB_SEARCH_TRUST_ENV",
"rag.web.search.trust_env",
os.getenv("RAG_WEB_SEARCH_TRUST_ENV", False),
)
####################################
# Images
@@ -1599,6 +1954,13 @@ ENABLE_IMAGE_GENERATION = PersistentConfig(
"image_generation.enable",
os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true",
)
ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig(
"ENABLE_IMAGE_PROMPT_GENERATION",
"image_generation.prompt.enable",
os.environ.get("ENABLE_IMAGE_PROMPT_GENERATION", "true").lower() == "true",
)
AUTOMATIC1111_BASE_URL = PersistentConfig(
"AUTOMATIC1111_BASE_URL",
"image_generation.automatic1111.base_url",
@@ -1818,6 +2180,12 @@ WHISPER_MODEL_AUTO_UPDATE = (
and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
)
# Add Deepgram configuration
DEEPGRAM_API_KEY = PersistentConfig(
"DEEPGRAM_API_KEY",
"audio.stt.deepgram.api_key",
os.getenv("DEEPGRAM_API_KEY", ""),
)
AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig(
"AUDIO_STT_OPENAI_API_BASE_URL",
@@ -1928,6 +2296,12 @@ LDAP_SERVER_PORT = PersistentConfig(
int(os.environ.get("LDAP_SERVER_PORT", "389")),
)
LDAP_ATTRIBUTE_FOR_MAIL = PersistentConfig(
"LDAP_ATTRIBUTE_FOR_MAIL",
"ldap.server.attribute_for_mail",
os.environ.get("LDAP_ATTRIBUTE_FOR_MAIL", "mail"),
)
LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig(
"LDAP_ATTRIBUTE_FOR_USERNAME",
"ldap.server.attribute_for_username",
+2 -1
View File
@@ -57,7 +57,7 @@ class ERROR_MESSAGES(str, Enum):
)
FILE_NOT_SENT = "FILE_NOT_SENT"
FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format (e.g., JPG, PNG, PDF, TXT) and try again."
FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format and try again."
NOT_FOUND = "We could not find what you're looking for :/"
USER_NOT_FOUND = "We could not find what you're looking for :/"
@@ -113,6 +113,7 @@ class TASKS(str, Enum):
TAGS_GENERATION = "tags_generation"
EMOJI_GENERATION = "emoji_generation"
QUERY_GENERATION = "query_generation"
IMAGE_PROMPT_GENERATION = "image_prompt_generation"
AUTOCOMPLETE_GENERATION = "autocomplete_generation"
FUNCTION_CALLING = "function_calling"
MOA_RESPONSE_GENERATION = "moa_response_generation"
+33 -6
View File
@@ -53,6 +53,13 @@ if USE_CUDA.lower() == "true":
else:
DEVICE_TYPE = "cpu"
try:
import torch
if torch.backends.mps.is_available() and torch.backends.mps.is_built():
DEVICE_TYPE = "mps"
except Exception:
pass
####################################
# LOGGING
@@ -85,6 +92,7 @@ log_sources = [
"RAG",
"WEBHOOK",
"SOCKET",
"OAUTH",
]
SRC_LOG_LEVELS = {}
@@ -105,6 +113,7 @@ if WEBUI_NAME != "Open WebUI":
WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png"
TRUSTED_SIGNATURE_KEY = os.environ.get("TRUSTED_SIGNATURE_KEY", "")
####################################
# ENV (dev,test,prod)
@@ -267,6 +276,8 @@ DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
if "postgres://" in DATABASE_URL:
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
if DATABASE_POOL_SIZE == "":
@@ -311,6 +322,11 @@ RESET_CONFIG_ON_START = (
os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
)
ENABLE_REALTIME_CHAT_SAVE = (
os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
)
####################################
# REDIS
####################################
@@ -342,14 +358,22 @@ WEBUI_SECRET_KEY = os.environ.get(
), # DEPRECATED: remove at next major version
)
WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get(
"WEBUI_SESSION_COOKIE_SAME_SITE",
os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax"),
WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax")
WEBUI_SESSION_COOKIE_SECURE = (
os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true"
)
WEBUI_SESSION_COOKIE_SECURE = os.environ.get(
"WEBUI_SESSION_COOKIE_SECURE",
os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true",
WEBUI_AUTH_COOKIE_SAME_SITE = os.environ.get(
"WEBUI_AUTH_COOKIE_SAME_SITE", WEBUI_SESSION_COOKIE_SAME_SITE
)
WEBUI_AUTH_COOKIE_SECURE = (
os.environ.get(
"WEBUI_AUTH_COOKIE_SECURE",
os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false"),
).lower()
== "true"
)
if WEBUI_AUTH and WEBUI_SECRET_KEY == "":
@@ -392,3 +416,6 @@ else:
####################################
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
if OFFLINE_MODE:
os.environ["HF_HUB_OFFLINE"] = "1"
+1 -1
View File
@@ -250,7 +250,7 @@ async def generate_function_chat_completion(
params = model_info.params.model_dump()
form_data = apply_model_params_to_body_openai(params, form_data)
form_data = apply_model_system_prompt_to_body(params, form_data, user)
form_data = apply_model_system_prompt_to_body(params, form_data, metadata, user)
pipe_id = get_pipe_id(form_data)
function_module = get_function_module_by_id(request, pipe_id)
+4 -2
View File
@@ -7,6 +7,7 @@ from open_webui.internal.wrappers import register_connection
from open_webui.env import (
OPEN_WEBUI_DIR,
DATABASE_URL,
DATABASE_SCHEMA,
SRC_LOG_LEVELS,
DATABASE_POOL_MAX_OVERFLOW,
DATABASE_POOL_RECYCLE,
@@ -14,7 +15,7 @@ from open_webui.env import (
DATABASE_POOL_TIMEOUT,
)
from peewee_migrate import Router
from sqlalchemy import Dialect, create_engine, types
from sqlalchemy import Dialect, create_engine, MetaData, types
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy.pool import QueuePool, NullPool
@@ -99,7 +100,8 @@ else:
SessionLocal = sessionmaker(
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
)
Base = declarative_base()
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
Base = declarative_base(metadata=metadata_obj)
Session = scoped_session(SessionLocal)
+170 -34
View File
@@ -88,6 +88,7 @@ from open_webui.models.models import Models
from open_webui.models.users import UserModel, Users
from open_webui.config import (
LICENSE_KEY,
# Ollama
ENABLE_OLLAMA_API,
OLLAMA_BASE_URLS,
@@ -97,6 +98,21 @@ from open_webui.config import (
OPENAI_API_BASE_URLS,
OPENAI_API_KEYS,
OPENAI_API_CONFIGS,
# Direct Connections
ENABLE_DIRECT_CONNECTIONS,
# Code Execution
CODE_EXECUTION_ENGINE,
CODE_EXECUTION_JUPYTER_URL,
CODE_EXECUTION_JUPYTER_AUTH,
CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
ENABLE_CODE_INTERPRETER,
CODE_INTERPRETER_ENGINE,
CODE_INTERPRETER_PROMPT_TEMPLATE,
CODE_INTERPRETER_JUPYTER_URL,
CODE_INTERPRETER_JUPYTER_AUTH,
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
# Image
AUTOMATIC1111_API_AUTH,
AUTOMATIC1111_BASE_URL,
@@ -108,6 +124,7 @@ from open_webui.config import (
COMFYUI_WORKFLOW,
COMFYUI_WORKFLOW_NODES,
ENABLE_IMAGE_GENERATION,
ENABLE_IMAGE_PROMPT_GENERATION,
IMAGE_GENERATION_ENGINE,
IMAGE_GENERATION_MODEL,
IMAGE_SIZE,
@@ -129,6 +146,7 @@ from open_webui.config import (
AUDIO_TTS_AZURE_SPEECH_REGION,
AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT,
WHISPER_MODEL,
DEEPGRAM_API_KEY,
WHISPER_MODEL_AUTO_UPDATE,
WHISPER_MODEL_DIR,
# Retrieval
@@ -161,12 +179,16 @@ from open_webui.config import (
YOUTUBE_LOADER_PROXY_URL,
# Retrieval (Web Search)
RAG_WEB_SEARCH_ENGINE,
RAG_WEB_SEARCH_FULL_CONTEXT,
RAG_WEB_SEARCH_RESULT_COUNT,
RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
RAG_WEB_SEARCH_TRUST_ENV,
RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
JINA_API_KEY,
SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE,
SERPAPI_API_KEY,
SERPAPI_ENGINE,
SEARXNG_QUERY_URL,
SERPER_API_KEY,
SERPLY_API_KEY,
@@ -176,8 +198,10 @@ from open_webui.config import (
BING_SEARCH_V7_ENDPOINT,
BING_SEARCH_V7_SUBSCRIPTION_KEY,
BRAVE_SEARCH_API_KEY,
EXA_API_KEY,
KAGI_SEARCH_API_KEY,
MOJEEK_SEARCH_API_KEY,
BOCHA_SEARCH_API_KEY,
GOOGLE_PSE_API_KEY,
GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID,
@@ -225,6 +249,7 @@ from open_webui.config import (
LDAP_SERVER_LABEL,
LDAP_SERVER_HOST,
LDAP_SERVER_PORT,
LDAP_ATTRIBUTE_FOR_MAIL,
LDAP_ATTRIBUTE_FOR_USERNAME,
LDAP_SEARCH_FILTERS,
LDAP_SEARCH_BASE,
@@ -249,11 +274,13 @@ from open_webui.config import (
TASK_MODEL,
TASK_MODEL_EXTERNAL,
ENABLE_TAGS_GENERATION,
ENABLE_TITLE_GENERATION,
ENABLE_SEARCH_QUERY_GENERATION,
ENABLE_RETRIEVAL_QUERY_GENERATION,
ENABLE_AUTOCOMPLETE_GENERATION,
TITLE_GENERATION_PROMPT_TEMPLATE,
TAGS_GENERATION_PROMPT_TEMPLATE,
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
@@ -294,15 +321,17 @@ from open_webui.utils.middleware import process_chat_payload, process_chat_respo
from open_webui.utils.access_control import has_access
from open_webui.utils.auth import (
get_license_data,
decode_token,
get_admin_user,
get_verified_user,
)
from open_webui.utils.oauth import oauth_manager
from open_webui.utils.oauth import OAuthManager
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.tasks import stop_task, list_tasks # Import from tasks.py
if SAFE_MODE:
print("SAFE MODE ENABLED")
Functions.deactivate_all_functions()
@@ -318,19 +347,23 @@ class SPAStaticFiles(StaticFiles):
return await super().get_response(path, scope)
except (HTTPException, StarletteHTTPException) as ex:
if ex.status_code == 404:
return await super().get_response("index.html", scope)
if path.endswith(".js"):
# Return 404 for javascript files
raise ex
else:
return await super().get_response("index.html", scope)
else:
raise ex
print(
rf"""
___ __ __ _ _ _ ___
/ _ \ _ __ ___ _ __ \ \ / /__| |__ | | | |_ _|
| | | | '_ \ / _ \ '_ \ \ \ /\ / / _ \ '_ \| | | || |
| |_| | |_) | __/ | | | \ V V / __/ |_) | |_| || |
\___/| .__/ \___|_| |_| \_/\_/ \___|_.__/ \___/|___|
|_|
v{VERSION} - building the best open-source AI user interface.
@@ -345,6 +378,9 @@ async def lifespan(app: FastAPI):
if RESET_CONFIG_ON_START:
reset_config()
if app.state.config.LICENSE_KEY:
get_license_data(app, app.state.config.LICENSE_KEY)
asyncio.create_task(periodic_usage_pool_cleanup())
yield
@@ -356,8 +392,12 @@ app = FastAPI(
lifespan=lifespan,
)
oauth_manager = OAuthManager(app)
app.state.config = AppConfig()
app.state.WEBUI_NAME = WEBUI_NAME
app.state.config.LICENSE_KEY = LICENSE_KEY
########################################
#
@@ -385,6 +425,14 @@ app.state.config.OPENAI_API_CONFIGS = OPENAI_API_CONFIGS
app.state.OPENAI_MODELS = {}
########################################
#
# DIRECT CONNECTIONS
#
########################################
app.state.config.ENABLE_DIRECT_CONNECTIONS = ENABLE_DIRECT_CONNECTIONS
########################################
#
# WEBUI
@@ -437,6 +485,7 @@ app.state.config.ENABLE_LDAP = ENABLE_LDAP
app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL
app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST
app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT
app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = LDAP_ATTRIBUTE_FOR_MAIL
app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME
app.state.config.LDAP_APP_DN = LDAP_APP_DN
app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD
@@ -450,10 +499,10 @@ app.state.config.LDAP_CIPHERS = LDAP_CIPHERS
app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER
app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER
app.state.USER_COUNT = None
app.state.TOOLS = {}
app.state.FUNCTIONS = {}
########################################
#
# RETRIEVAL
@@ -500,6 +549,7 @@ app.state.config.YOUTUBE_LOADER_PROXY_URL = YOUTUBE_LOADER_PROXY_URL
app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH
app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE
app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT = RAG_WEB_SEARCH_FULL_CONTEXT
app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
@@ -509,6 +559,7 @@ app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY
app.state.config.KAGI_SEARCH_API_KEY = KAGI_SEARCH_API_KEY
app.state.config.MOJEEK_SEARCH_API_KEY = MOJEEK_SEARCH_API_KEY
app.state.config.BOCHA_SEARCH_API_KEY = BOCHA_SEARCH_API_KEY
app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY
app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS
app.state.config.SERPER_API_KEY = SERPER_API_KEY
@@ -516,12 +567,16 @@ app.state.config.SERPLY_API_KEY = SERPLY_API_KEY
app.state.config.TAVILY_API_KEY = TAVILY_API_KEY
app.state.config.SEARCHAPI_API_KEY = SEARCHAPI_API_KEY
app.state.config.SEARCHAPI_ENGINE = SEARCHAPI_ENGINE
app.state.config.SERPAPI_API_KEY = SERPAPI_API_KEY
app.state.config.SERPAPI_ENGINE = SERPAPI_ENGINE
app.state.config.JINA_API_KEY = JINA_API_KEY
app.state.config.BING_SEARCH_V7_ENDPOINT = BING_SEARCH_V7_ENDPOINT
app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = BING_SEARCH_V7_SUBSCRIPTION_KEY
app.state.config.EXA_API_KEY = EXA_API_KEY
app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT
app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.RAG_WEB_SEARCH_TRUST_ENV = RAG_WEB_SEARCH_TRUST_ENV
app.state.EMBEDDING_FUNCTION = None
app.state.ef = None
@@ -563,6 +618,32 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.RAG_EMBEDDING_BATCH_SIZE,
)
########################################
#
# CODE EXECUTION
#
########################################
app.state.config.CODE_EXECUTION_ENGINE = CODE_EXECUTION_ENGINE
app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL
app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH
app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = CODE_EXECUTION_JUPYTER_AUTH_TOKEN
app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = (
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
)
app.state.config.ENABLE_CODE_INTERPRETER = ENABLE_CODE_INTERPRETER
app.state.config.CODE_INTERPRETER_ENGINE = CODE_INTERPRETER_ENGINE
app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = CODE_INTERPRETER_PROMPT_TEMPLATE
app.state.config.CODE_INTERPRETER_JUPYTER_URL = CODE_INTERPRETER_JUPYTER_URL
app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = CODE_INTERPRETER_JUPYTER_AUTH
app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = (
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
)
app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = (
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
)
########################################
#
@@ -572,6 +653,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
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_KEY = IMAGES_OPENAI_API_KEY
@@ -604,6 +686,7 @@ app.state.config.STT_ENGINE = AUDIO_STT_ENGINE
app.state.config.STT_MODEL = AUDIO_STT_MODEL
app.state.config.WHISPER_MODEL = WHISPER_MODEL
app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY
app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
@@ -638,10 +721,15 @@ app.state.config.ENABLE_SEARCH_QUERY_GENERATION = ENABLE_SEARCH_QUERY_GENERATION
app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = ENABLE_RETRIEVAL_QUERY_GENERATION
app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ENABLE_AUTOCOMPLETE_GENERATION
app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
app.state.config.ENABLE_TITLE_GENERATION = ENABLE_TITLE_GENERATION
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
)
app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
)
@@ -742,6 +830,7 @@ app.include_router(openai.router, prefix="/openai", tags=["openai"])
app.include_router(pipelines.router, prefix="/api/v1/pipelines", tags=["pipelines"])
app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["tasks"])
app.include_router(images.router, prefix="/api/v1/images", tags=["images"])
app.include_router(audio.router, prefix="/api/v1/audio", tags=["audio"])
app.include_router(retrieval.router, prefix="/api/v1/retrieval", tags=["retrieval"])
@@ -844,19 +933,30 @@ async def chat_completion(
if not request.app.state.MODELS:
await get_all_models(request)
model_item = form_data.pop("model_item", {})
tasks = form_data.pop("background_tasks", None)
try:
model_id = form_data.get("model", None)
if model_id not in request.app.state.MODELS:
raise Exception("Model not found")
model = request.app.state.MODELS[model_id]
# Check if user has access to the model
if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
try:
check_model_access(user, model)
except Exception as e:
raise e
try:
if not model_item.get("direct", False):
model_id = form_data.get("model", None)
if model_id not in request.app.state.MODELS:
raise Exception("Model not found")
model = request.app.state.MODELS[model_id]
model_info = Models.get_model_by_id(model_id)
# Check if user has access to the model
if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
try:
check_model_access(user, model)
except Exception as e:
raise e
else:
model = model_item
model_info = None
request.state.direct = True
request.state.model = model
metadata = {
"user_id": user.id,
@@ -866,13 +966,30 @@ async def chat_completion(
"tool_ids": form_data.get("tool_ids", None),
"files": form_data.get("files", None),
"features": form_data.get("features", None),
"variables": form_data.get("variables", None),
"model": model_info,
"direct": model_item.get("direct", False),
**(
{"function_calling": "native"}
if form_data.get("params", {}).get("function_calling") == "native"
or (
model_info
and model_info.params.model_dump().get("function_calling")
== "native"
)
else {}
),
}
request.state.metadata = metadata
form_data["metadata"] = metadata
form_data, events = await process_chat_payload(
form_data, metadata, events = await process_chat_payload(
request, form_data, metadata, user, model
)
except Exception as e:
log.debug(f"Error processing chat payload: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
@@ -880,6 +997,7 @@ async def chat_completion(
try:
response = await chat_completion_handler(request, form_data, user)
return await process_chat_response(
request, response, form_data, user, events, metadata, tasks
)
@@ -900,6 +1018,12 @@ async def chat_completed(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
try:
model_item = form_data.pop("model_item", {})
if model_item.get("direct", False):
request.state.direct = True
request.state.model = model_item
return await chat_completed_handler(request, form_data, user)
except Exception as e:
raise HTTPException(
@@ -913,6 +1037,12 @@ async def chat_action(
request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)
):
try:
model_item = form_data.pop("model_item", {})
if model_item.get("direct", False):
request.state.direct = True
request.state.model = model_item
return await chat_action_handler(request, action_id, form_data, user)
except Exception as e:
raise HTTPException(
@@ -966,7 +1096,7 @@ async def get_app_config(request: Request):
return {
**({"onboarding": True} if onboarding else {}),
"status": True,
"name": WEBUI_NAME,
"name": app.state.WEBUI_NAME,
"version": VERSION,
"default_locale": str(DEFAULT_LOCALE),
"oauth": {
@@ -985,27 +1115,29 @@ async def get_app_config(request: Request):
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
**(
{
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
"enable_channels": app.state.config.ENABLE_CHANNELS,
"enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER,
"enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
"enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
"enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
"enable_admin_export": ENABLE_ADMIN_EXPORT,
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
}
if user is not None
else {}
),
},
"google_drive": {
"client_id": GOOGLE_DRIVE_CLIENT_ID.value,
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
**(
{
"default_models": app.state.config.DEFAULT_MODELS,
"default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS,
"code": {
"engine": app.state.config.CODE_EXECUTION_ENGINE,
},
"audio": {
"tts": {
"engine": app.state.config.TTS_ENGINE,
@@ -1021,6 +1153,10 @@ async def get_app_config(request: Request):
"max_count": app.state.config.FILE_MAX_COUNT,
},
"permissions": {**app.state.config.USER_PERMISSIONS},
"google_drive": {
"client_id": GOOGLE_DRIVE_CLIENT_ID.value,
"api_key": GOOGLE_DRIVE_API_KEY.value,
},
}
if user is not None
else {}
@@ -1054,7 +1190,7 @@ async def get_app_version():
@app.get("/api/version/updates")
async def get_app_latest_release_version():
async def get_app_latest_release_version(user=Depends(get_verified_user)):
if OFFLINE_MODE:
log.debug(
f"Offline mode is enabled, returning current version as latest version"
@@ -1098,7 +1234,7 @@ if len(OAUTH_PROVIDERS) > 0:
@app.get("/oauth/{provider}/login")
async def oauth_login(provider: str, request: Request):
return await oauth_manager.handle_login(provider, request)
return await oauth_manager.handle_login(request, provider)
# OAuth login logic is as follows:
@@ -1109,14 +1245,14 @@ async def oauth_login(provider: str, request: Request):
# - Email addresses are considered unique, so we fail registration if the email address is already taken
@app.get("/oauth/{provider}/callback")
async def oauth_callback(provider: str, request: Request, response: Response):
return await oauth_manager.handle_callback(provider, request, response)
return await oauth_manager.handle_callback(request, provider, response)
@app.get("/manifest.json")
async def get_manifest_json():
return {
"name": WEBUI_NAME,
"short_name": WEBUI_NAME,
"name": app.state.WEBUI_NAME,
"short_name": app.state.WEBUI_NAME,
"description": "Open WebUI is an open, extensible, user-friendly interface for AI that adapts to your workflow.",
"start_url": "/",
"display": "standalone",
@@ -1143,8 +1279,8 @@ async def get_manifest_json():
async def get_opensearch_xml():
xml_content = rf"""
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
<ShortName>{WEBUI_NAME}</ShortName>
<Description>Search {WEBUI_NAME}</Description>
<ShortName>{app.state.WEBUI_NAME}</ShortName>
<Description>Search {app.state.WEBUI_NAME}</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16" type="image/x-icon">{app.state.config.WEBUI_URL}/static/favicon.png</Image>
<Url type="text/html" method="get" template="{app.state.config.WEBUI_URL}/?q={"{searchTerms}"}"/>
@@ -0,0 +1,70 @@
"""Update message & channel tables
Revision ID: 3781e22d8b01
Revises: 7826ab40b532
Create Date: 2024-12-30 03:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "3781e22d8b01"
down_revision = "7826ab40b532"
branch_labels = None
depends_on = None
def upgrade():
# Add 'type' column to the 'channel' table
op.add_column(
"channel",
sa.Column(
"type",
sa.Text(),
nullable=True,
),
)
# Add 'parent_id' column to the 'message' table for threads
op.add_column(
"message",
sa.Column("parent_id", sa.Text(), nullable=True),
)
op.create_table(
"message_reaction",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Unique reaction ID
sa.Column("user_id", sa.Text(), nullable=False), # User who reacted
sa.Column(
"message_id", sa.Text(), nullable=False
), # Message that was reacted to
sa.Column(
"name", sa.Text(), nullable=False
), # Reaction name (e.g. "thumbs_up")
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the reaction was added
)
op.create_table(
"channel_member",
sa.Column(
"id", sa.Text(), nullable=False, primary_key=True, unique=True
), # Record ID for the membership row
sa.Column("channel_id", sa.Text(), nullable=False), # Associated channel
sa.Column("user_id", sa.Text(), nullable=False), # Associated user
sa.Column(
"created_at", sa.BigInteger(), nullable=True
), # Timestamp of when the user joined the channel
)
def downgrade():
# Revert 'type' column addition to the 'channel' table
op.drop_column("channel", "type")
op.drop_column("message", "parent_id")
op.drop_table("message_reaction")
op.drop_table("channel_member")
+6 -2
View File
@@ -21,6 +21,7 @@ class Channel(Base):
id = Column(Text, primary_key=True)
user_id = Column(Text)
type = Column(Text, nullable=True)
name = Column(Text)
description = Column(Text, nullable=True)
@@ -38,9 +39,11 @@ class ChannelModel(BaseModel):
id: str
user_id: str
description: Optional[str] = None
type: Optional[str] = None
name: str
description: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_control: Optional[dict] = None
@@ -64,12 +67,13 @@ class ChannelForm(BaseModel):
class ChannelTable:
def insert_new_channel(
self, form_data: ChannelForm, user_id: str
self, type: Optional[str], form_data: ChannelForm, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
channel = ChannelModel(
**{
**form_data.model_dump(),
"type": type,
"name": form_data.name.lower(),
"id": str(uuid.uuid4()),
"user_id": user_id,
+12 -1
View File
@@ -212,6 +212,15 @@ class ChatTable:
return chat.chat.get("history", {}).get("messages", {}) or {}
def get_message_by_id_and_message_id(
self, id: str, message_id: str
) -> Optional[dict]:
chat = self.get_chat_by_id(id)
if chat is None:
return None
return chat.chat.get("history", {}).get("messages", {}).get(message_id, {})
def upsert_message_to_chat_by_id_and_message_id(
self, id: str, message_id: str, message: dict
) -> Optional[ChatModel]:
@@ -384,7 +393,7 @@ class ChatTable:
limit: int = 50,
) -> list[ChatModel]:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
query = db.query(Chat).filter_by(user_id=user_id)
if not include_archived:
query = query.filter_by(archived=False)
@@ -460,6 +469,8 @@ class ChatTable:
def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]:
try:
with get_db() as db:
# it is possible that the shared link was deleted. hence,
# we check if the chat is still shared by checking if a chat with the share_id exists
chat = db.query(Chat).filter_by(share_id=id).first()
if chat:
+21 -3
View File
@@ -80,12 +80,11 @@ class GroupResponse(BaseModel):
class GroupForm(BaseModel):
name: str
description: str
permissions: Optional[dict] = None
class GroupUpdateForm(GroupForm):
permissions: Optional[dict] = None
user_ids: Optional[list[str]] = None
admin_ids: Optional[list[str]] = None
class GroupTable:
@@ -95,7 +94,7 @@ class GroupTable:
with get_db() as db:
group = GroupModel(
**{
**form_data.model_dump(),
**form_data.model_dump(exclude_none=True),
"id": str(uuid.uuid4()),
"user_id": user_id,
"created_at": int(time.time()),
@@ -189,5 +188,24 @@ class GroupTable:
except Exception:
return False
def remove_user_from_all_groups(self, user_id: str) -> bool:
with get_db() as db:
try:
groups = self.get_groups_by_member_id(user_id)
for group in groups:
group.user_ids.remove(user_id)
db.query(Group).filter_by(id=group.id).update(
{
"user_ids": group.user_ids,
"updated_at": int(time.time()),
}
)
db.commit()
return True
except Exception:
return False
Groups = GroupTable()
+144 -6
View File
@@ -17,6 +17,25 @@ from sqlalchemy.sql import exists
####################
class MessageReaction(Base):
__tablename__ = "message_reaction"
id = Column(Text, primary_key=True)
user_id = Column(Text)
message_id = Column(Text)
name = Column(Text)
created_at = Column(BigInteger)
class MessageReactionModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
message_id: str
name: str
created_at: int # timestamp in epoch
class Message(Base):
__tablename__ = "message"
id = Column(Text, primary_key=True)
@@ -24,6 +43,8 @@ class Message(Base):
user_id = Column(Text)
channel_id = Column(Text, nullable=True)
parent_id = Column(Text, nullable=True)
content = Column(Text)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
@@ -39,6 +60,8 @@ class MessageModel(BaseModel):
user_id: str
channel_id: Optional[str] = None
parent_id: Optional[str] = None
content: str
data: Optional[dict] = None
meta: Optional[dict] = None
@@ -54,10 +77,23 @@ class MessageModel(BaseModel):
class MessageForm(BaseModel):
content: str
parent_id: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
class Reactions(BaseModel):
name: str
user_ids: list[str]
count: int
class MessageResponse(MessageModel):
latest_reply_at: Optional[int]
reply_count: int
reactions: list[Reactions]
class MessageTable:
def insert_new_message(
self, form_data: MessageForm, channel_id: str, user_id: str
@@ -71,6 +107,7 @@ class MessageTable:
"id": id,
"user_id": user_id,
"channel_id": channel_id,
"parent_id": form_data.parent_id,
"content": form_data.content,
"data": form_data.data,
"meta": form_data.meta,
@@ -85,10 +122,40 @@ class MessageTable:
db.refresh(result)
return MessageModel.model_validate(result) if result else None
def get_message_by_id(self, id: str) -> Optional[MessageModel]:
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
with get_db() as db:
message = db.get(Message, id)
return MessageModel.model_validate(message) if message else None
if not message:
return None
reactions = self.get_reactions_by_message_id(id)
replies = self.get_replies_by_message_id(id)
return MessageResponse(
**{
**MessageModel.model_validate(message).model_dump(),
"latest_reply_at": replies[0].created_at if replies else None,
"reply_count": len(replies),
"reactions": reactions,
}
)
def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(parent_id=id)
.order_by(Message.created_at.desc())
.all()
)
return [MessageModel.model_validate(message) for message in all_messages]
def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
with get_db() as db:
return [
message.user_id
for message in db.query(Message).filter_by(parent_id=id).all()
]
def get_messages_by_channel_id(
self, channel_id: str, skip: int = 0, limit: int = 50
@@ -96,7 +163,7 @@ class MessageTable:
with get_db() as db:
all_messages = (
db.query(Message)
.filter_by(channel_id=channel_id)
.filter_by(channel_id=channel_id, parent_id=None)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
@@ -104,18 +171,28 @@ class MessageTable:
)
return [MessageModel.model_validate(message) for message in all_messages]
def get_messages_by_user_id(
self, user_id: str, skip: int = 0, limit: int = 50
def get_messages_by_parent_id(
self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
) -> list[MessageModel]:
with get_db() as db:
message = db.get(Message, parent_id)
if not message:
return []
all_messages = (
db.query(Message)
.filter_by(user_id=user_id)
.filter_by(channel_id=channel_id, parent_id=parent_id)
.order_by(Message.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# If length of all_messages is less than limit, then add the parent message
if len(all_messages) < limit:
all_messages.append(message)
return [MessageModel.model_validate(message) for message in all_messages]
def update_message_by_id(
@@ -131,9 +208,70 @@ class MessageTable:
db.refresh(message)
return MessageModel.model_validate(message) if message else None
def add_reaction_to_message(
self, id: str, user_id: str, name: str
) -> Optional[MessageReactionModel]:
with get_db() as db:
reaction_id = str(uuid.uuid4())
reaction = MessageReactionModel(
id=reaction_id,
user_id=user_id,
message_id=id,
name=name,
created_at=int(time.time_ns()),
)
result = MessageReaction(**reaction.model_dump())
db.add(result)
db.commit()
db.refresh(result)
return MessageReactionModel.model_validate(result) if result else None
def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
with get_db() as db:
all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
reactions = {}
for reaction in all_reactions:
if reaction.name not in reactions:
reactions[reaction.name] = {
"name": reaction.name,
"user_ids": [],
"count": 0,
}
reactions[reaction.name]["user_ids"].append(reaction.user_id)
reactions[reaction.name]["count"] += 1
return [Reactions(**reaction) for reaction in reactions.values()]
def remove_reaction_by_id_and_user_id_and_name(
self, id: str, user_id: str, name: str
) -> bool:
with get_db() as db:
db.query(MessageReaction).filter_by(
message_id=id, user_id=user_id, name=name
).delete()
db.commit()
return True
def delete_reactions_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
def delete_replies_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(Message).filter_by(parent_id=id).delete()
db.commit()
return True
def delete_message_by_id(self, id: str) -> bool:
with get_db() as db:
db.query(Message).filter_by(id=id).delete()
# Delete all reactions to this message
db.query(MessageReaction).filter_by(message_id=id).delete()
db.commit()
return True
+31 -1
View File
@@ -2,7 +2,12 @@ import time
from typing import Optional
from open_webui.internal.db import Base, JSONField, get_db
from open_webui.models.chats import Chats
from open_webui.models.groups import Groups
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, String, Text
@@ -266,11 +271,31 @@ class UsersTable:
except Exception:
return None
def update_user_settings_by_id(self, id: str, updated: dict) -> Optional[UserModel]:
try:
with get_db() as db:
user_settings = db.query(User).filter_by(id=id).first().settings
if user_settings is None:
user_settings = {}
user_settings.update(updated)
db.query(User).filter_by(id=id).update({"settings": user_settings})
db.commit()
user = db.query(User).filter_by(id=id).first()
return UserModel.model_validate(user)
except Exception:
return None
def delete_user_by_id(self, id: str) -> bool:
try:
# Remove User from Groups
Groups.remove_user_from_all_groups(id)
# Delete User Chats
result = Chats.delete_chats_by_user_id(id)
if result:
with get_db() as db:
# Delete User
@@ -300,5 +325,10 @@ class UsersTable:
except Exception:
return None
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
with get_db() as db:
users = db.query(User).filter(User.id.in_(user_ids)).all()
return [user.id for user in users]
Users = UsersTable()
+78 -19
View File
@@ -11,10 +11,17 @@ from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriev
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
from open_webui.config import VECTOR_DB
from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
from open_webui.utils.misc import get_last_user_message
from open_webui.models.users import UserModel
from open_webui.env import SRC_LOG_LEVELS
from open_webui.env import (
SRC_LOG_LEVELS,
OFFLINE_MODE,
ENABLE_FORWARD_USER_INFO_HEADERS,
)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -59,9 +66,7 @@ class VectorSearchRetriever(BaseRetriever):
def query_doc(
collection_name: str,
query_embedding: list[float],
k: int,
collection_name: str, query_embedding: list[float], k: int, user: UserModel = None
):
try:
result = VECTOR_DB_CLIENT.search(
@@ -199,7 +204,12 @@ def query_collection(
else:
pass
return merge_and_sort_query_results(results, k=k)
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
def query_collection_with_hybrid_search(
@@ -235,7 +245,12 @@ def query_collection_with_hybrid_search(
"Hybrid search failed for all collections. Using Non hybrid search as fallback."
)
return merge_and_sort_query_results(results, k=k, reverse=True)
if VECTOR_DB == "chroma":
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
return merge_and_sort_query_results(results, k=k, reverse=False)
else:
return merge_and_sort_query_results(results, k=k, reverse=True)
def get_embedding_function(
@@ -247,26 +262,31 @@ def get_embedding_function(
embedding_batch_size,
):
if embedding_engine == "":
return lambda query: embedding_function.encode(query).tolist()
return lambda query, user=None: embedding_function.encode(query).tolist()
elif embedding_engine in ["ollama", "openai"]:
func = lambda query: generate_embeddings(
func = lambda query, user=None: generate_embeddings(
engine=embedding_engine,
model=embedding_model,
text=query,
url=url,
key=key,
user=user,
)
def generate_multiple(query, func):
def generate_multiple(query, user, func):
if isinstance(query, list):
embeddings = []
for i in range(0, len(query), embedding_batch_size):
embeddings.extend(func(query[i : i + embedding_batch_size]))
embeddings.extend(
func(query[i : i + embedding_batch_size], user=user)
)
return embeddings
else:
return func(query)
return func(query, user)
return lambda query: generate_multiple(query, func)
return lambda query, user=None: generate_multiple(query, user, func)
else:
raise ValueError(f"Unknown embedding engine: {embedding_engine}")
def get_sources_from_files(
@@ -284,7 +304,12 @@ def get_sources_from_files(
relevant_contexts = []
for file in files:
if file.get("context") == "full":
if file.get("docs"):
context = {
"documents": [[doc.get("content") for doc in file.get("docs")]],
"metadatas": [[doc.get("metadata") for doc in file.get("docs")]],
}
elif file.get("context") == "full":
context = {
"documents": [[file.get("file").get("data", {}).get("content")]],
"metadatas": [[{"file_id": file.get("id"), "name": file.get("name")}]],
@@ -375,6 +400,9 @@ def get_model_path(model: str, update_model: bool = False):
local_files_only = not update_model
if OFFLINE_MODE:
local_files_only = True
snapshot_kwargs = {
"cache_dir": cache_dir,
"local_files_only": local_files_only,
@@ -408,7 +436,11 @@ def get_model_path(model: str, update_model: bool = False):
def generate_openai_batch_embeddings(
model: str, texts: list[str], url: str = "https://api.openai.com/v1", key: str = ""
model: str,
texts: list[str],
url: str = "https://api.openai.com/v1",
key: str = "",
user: UserModel = None,
) -> Optional[list[list[float]]]:
try:
r = requests.post(
@@ -416,6 +448,16 @@ def generate_openai_batch_embeddings(
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
json={"input": texts, "model": model},
)
@@ -431,7 +473,7 @@ def generate_openai_batch_embeddings(
def generate_ollama_batch_embeddings(
model: str, texts: list[str], url: str, key: str = ""
model: str, texts: list[str], url: str, key: str = "", user: UserModel = None
) -> Optional[list[list[float]]]:
try:
r = requests.post(
@@ -439,6 +481,16 @@ def generate_ollama_batch_embeddings(
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {key}",
**(
{
"X-OpenWebUI-User-Name": user.name,
"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 {}
),
},
json={"input": texts, "model": model},
)
@@ -457,22 +509,29 @@ def generate_ollama_batch_embeddings(
def generate_embeddings(engine: str, model: str, text: Union[str, list[str]], **kwargs):
url = kwargs.get("url", "")
key = kwargs.get("key", "")
user = kwargs.get("user")
if engine == "ollama":
if isinstance(text, list):
embeddings = generate_ollama_batch_embeddings(
**{"model": model, "texts": text, "url": url, "key": key}
**{"model": model, "texts": text, "url": url, "key": key, "user": user}
)
else:
embeddings = generate_ollama_batch_embeddings(
**{"model": model, "texts": [text], "url": url, "key": key}
**{
"model": model,
"texts": [text],
"url": url,
"key": key,
"user": user,
}
)
return embeddings[0] if isinstance(text, str) else embeddings
elif engine == "openai":
if isinstance(text, list):
embeddings = generate_openai_batch_embeddings(model, text, url, key)
embeddings = generate_openai_batch_embeddings(model, text, url, key, user)
else:
embeddings = generate_openai_batch_embeddings(model, [text], url, key)
embeddings = generate_openai_batch_embeddings(model, [text], url, key, user)
return embeddings[0] if isinstance(text, str) else embeddings
@@ -51,8 +51,8 @@ class ChromaClient:
def has_collection(self, collection_name: str) -> bool:
# Check if the collection exists based on the collection name.
collections = self.client.list_collections()
return collection_name in [collection.name for collection in collections]
collection_names = self.client.list_collections()
return collection_name in collection_names
def delete_collection(self, collection_name: str):
# Delete the collection based on the collection name.
@@ -7,13 +7,18 @@ from typing import Optional
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import (
MILVUS_URI,
MILVUS_DB,
MILVUS_TOKEN,
)
class MilvusClient:
def __init__(self):
self.collection_prefix = "open_webui"
self.client = Client(uri=MILVUS_URI)
if MILVUS_TOKEN is None:
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB)
else:
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB, token=MILVUS_TOKEN)
def _result_to_get_result(self, result) -> GetResult:
ids = []
@@ -113,6 +113,34 @@ class OpenSearchClient:
return self._result_to_search_result(result)
def query(
self, collection_name: str, filter: dict, limit: Optional[int] = None
) -> Optional[GetResult]:
if not self.has_collection(collection_name):
return None
query_body = {
"query": {"bool": {"filter": []}},
"_source": ["text", "metadata"],
}
for field, value in filter.items():
query_body["query"]["bool"]["filter"].append({"term": {field: value}})
size = limit if limit else 10
try:
result = self.client.search(
index=f"{self.index_prefix}_{collection_name}",
body=query_body,
size=size,
)
return self._result_to_get_result(result)
except Exception as e:
return None
def get_or_create_index(self, index_name: str, dimension: int):
if not self.has_index(index_name):
self._create_index(index_name, dimension)
@@ -5,9 +5,11 @@ from sqlalchemy import (
create_engine,
Column,
Integer,
MetaData,
select,
text,
Text,
Table,
values,
)
from sqlalchemy.sql import true
@@ -17,11 +19,12 @@ from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
from sqlalchemy.dialects.postgresql import JSONB, array
from pgvector.sqlalchemy import Vector
from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy.exc import NoSuchTableError
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
from open_webui.config import PGVECTOR_DB_URL
from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
VECTOR_LENGTH = 1536
VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
Base = declarative_base()
@@ -56,6 +59,9 @@ class PgvectorClient:
# Ensure the pgvector extension is available
self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
# Check vector length consistency
self.check_vector_length()
# Create the tables if they do not exist
# Base.metadata.create_all requires a bind (engine or connection)
# Get the connection from the session
@@ -82,6 +88,41 @@ class PgvectorClient:
print(f"Error during initialization: {e}")
raise
def check_vector_length(self) -> None:
"""
Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
Raises an exception if there is a mismatch.
"""
metadata = MetaData()
try:
# Attempt to reflect the 'document_chunk' table
document_chunk_table = Table(
"document_chunk", metadata, autoload_with=self.session.bind
)
except NoSuchTableError:
# Table does not exist; no action needed
return
# Proceed to check the vector column
if "vector" in document_chunk_table.columns:
vector_column = document_chunk_table.columns["vector"]
vector_type = vector_column.type
if isinstance(vector_type, Vector):
db_vector_length = vector_type.dim
if db_vector_length != VECTOR_LENGTH:
raise Exception(
f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
"Cannot change vector size after initialization without migrating the data."
)
else:
raise Exception(
"The 'vector' column exists but is not of type 'Vector'."
)
else:
raise Exception(
"The 'vector' column does not exist in the 'document_chunk' table."
)
def adjust_vector_length(self, vector: List[float]) -> List[float]:
# Adjust vector to have length VECTOR_LENGTH
current_length = len(vector)
+1 -1
View File
@@ -23,7 +23,7 @@ def search_bing(
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
mkt = locale
params = {"q": query, "mkt": mkt, "answerCount": count}
params = {"q": query, "mkt": mkt, "count": count}
headers = {"Ocp-Apim-Subscription-Key": subscription_key}
try:
+65
View File
@@ -0,0 +1,65 @@
import logging
from typing import Optional
import requests
import json
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def _parse_response(response):
result = {}
if "data" in response:
data = response["data"]
if "webPages" in data:
webPages = data["webPages"]
if "value" in webPages:
result["webpage"] = [
{
"id": item.get("id", ""),
"name": item.get("name", ""),
"url": item.get("url", ""),
"snippet": item.get("snippet", ""),
"summary": item.get("summary", ""),
"siteName": item.get("siteName", ""),
"siteIcon": item.get("siteIcon", ""),
"datePublished": item.get("datePublished", "")
or item.get("dateLastCrawled", ""),
}
for item in webPages["value"]
]
return result
def search_bocha(
api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None
) -> list[SearchResult]:
"""Search using Bocha's Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Bocha Search API key
query (str): The query to search for
"""
url = "https://api.bochaai.com/v1/web-search?utm_source=ollama"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = json.dumps(
{"query": query, "summary": True, "freshness": "noLimit", "count": count}
)
response = requests.post(url, headers=headers, data=payload, timeout=5)
response.raise_for_status()
results = _parse_response(response.json())
print(results)
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["url"], title=result.get("name"), snippet=result.get("summary")
)
for result in results.get("webpage", [])[:count]
]
+10 -14
View File
@@ -32,19 +32,15 @@ def search_duckduckgo(
# Convert the search results into a list
search_results = [r for r in ddgs_gen]
# Create an empty list to store the SearchResult objects
results = []
# Iterate over each search result
for result in search_results:
# Create a SearchResult object and append it to the results list
results.append(
SearchResult(
link=result["href"],
title=result.get("title"),
snippet=result.get("body"),
)
)
if filter_list:
results = get_filtered_results(results, filter_list)
search_results = get_filtered_results(search_results, filter_list)
# Return the list of search results
return results
return [
SearchResult(
link=result["href"],
title=result.get("title"),
snippet=result.get("body"),
)
for result in search_results
]
+76
View File
@@ -0,0 +1,76 @@
import logging
from dataclasses import dataclass
from typing import Optional
import requests
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.web.main import SearchResult
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
EXA_API_BASE = "https://api.exa.ai"
@dataclass
class ExaResult:
url: str
title: str
text: str
def search_exa(
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Exa Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Exa Search API key
query (str): The query to search for
count (int): Number of results to return
filter_list (Optional[list[str]]): List of domains to filter results by
"""
log.info(f"Searching with Exa for query: {query}")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {
"query": query,
"numResults": count or 5,
"includeDomains": filter_list,
"contents": {"text": True, "highlights": True},
"type": "auto", # Use the auto search type (keyword or neural)
}
try:
response = requests.post(
f"{EXA_API_BASE}/search", headers=headers, json=payload
)
response.raise_for_status()
data = response.json()
results = []
for result in data["results"]:
results.append(
ExaResult(
url=result["url"],
title=result["title"],
text=result["text"],
)
)
log.info(f"Found {len(results)} results")
return [
SearchResult(
link=result.url,
title=result.title,
snippet=result.text,
)
for result in results
]
except Exception as e:
log.error(f"Error searching Exa: {e}")
return []
+32 -13
View File
@@ -17,34 +17,53 @@ def search_google_pse(
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Google's Programmable Search Engine API and return the results as a list of SearchResult objects.
Handles pagination for counts greater than 10.
Args:
api_key (str): A Programmable Search Engine API key
search_engine_id (str): A Programmable Search Engine ID
query (str): The query to search for
count (int): The number of results to return (max 100, as PSE max results per query is 10 and max page is 10)
filter_list (Optional[list[str]], optional): A list of keywords to filter out from results. Defaults to None.
Returns:
list[SearchResult]: A list of SearchResult objects.
"""
url = "https://www.googleapis.com/customsearch/v1"
headers = {"Content-Type": "application/json"}
params = {
"cx": search_engine_id,
"q": query,
"key": api_key,
"num": count,
}
all_results = []
start_index = 1 # Google PSE start parameter is 1-based
response = requests.request("GET", url, headers=headers, params=params)
response.raise_for_status()
while count > 0:
num_results_this_page = min(count, 10) # Google PSE max results per page is 10
params = {
"cx": search_engine_id,
"q": query,
"key": api_key,
"num": num_results_this_page,
"start": start_index,
}
response = requests.request("GET", url, headers=headers, params=params)
response.raise_for_status()
json_response = response.json()
results = json_response.get("items", [])
if results: # check if results are returned. If not, no more pages to fetch.
all_results.extend(results)
count -= len(
results
) # Decrement count by the number of results fetched in this page.
start_index += 10 # Increment start index for the next page
else:
break # No more results from Google PSE, break the loop
json_response = response.json()
results = json_response.get("items", [])
if filter_list:
results = get_filtered_results(results, filter_list)
all_results = get_filtered_results(all_results, filter_list)
return [
SearchResult(
link=result["link"],
title=result.get("title"),
snippet=result.get("snippet"),
)
for result in results
for result in all_results
]
@@ -20,14 +20,23 @@ def search_jina(api_key: str, query: str, count: int) -> list[SearchResult]:
list[SearchResult]: A list of search results
"""
jina_search_endpoint = "https://s.jina.ai/"
headers = {"Accept": "application/json", "Authorization": f"Bearer {api_key}"}
url = str(URL(jina_search_endpoint + query))
response = requests.get(url, headers=headers)
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": api_key,
"X-Retain-Images": "none",
}
payload = {"q": query, "count": count if count <= 10 else 10}
url = str(URL(jina_search_endpoint))
response = requests.post(url, headers=headers, json=payload)
response.raise_for_status()
data = response.json()
results = []
for result in data["data"][:count]:
for result in data["data"]:
results.append(
SearchResult(
link=result["url"],
+4
View File
@@ -1,3 +1,5 @@
import validators
from typing import Optional
from urllib.parse import urlparse
@@ -10,6 +12,8 @@ def get_filtered_results(results, filter_list):
filtered_results = []
for result in results:
url = result.get("url") or result.get("link", "")
if not validators.url(url):
continue
domain = urlparse(url).netloc
if any(domain.endswith(filtered_domain) for filtered_domain in filter_list):
filtered_results.append(result)
@@ -0,0 +1,48 @@
import logging
from typing import Optional
from urllib.parse import urlencode
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_serpapi(
api_key: str,
engine: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using serpapi.com's API and return the results as a list of SearchResult objects.
Args:
api_key (str): A serpapi.com API key
query (str): The query to search for
"""
url = "https://serpapi.com/search"
engine = engine or "google"
payload = {"engine": engine, "q": query, "api_key": api_key}
url = f"{url}?{urlencode(payload)}"
response = requests.request("GET", url)
json_response = response.json()
log.info(f"results from serpapi search: {json_response}")
results = sorted(
json_response.get("organic_results", []), key=lambda x: x.get("position", 0)
)
if filter_list:
results = get_filtered_results(results, filter_list)
return [
SearchResult(
link=result["link"], title=result["title"], snippet=result["snippet"]
)
for result in results[:count]
]
+8 -2
View File
@@ -1,4 +1,5 @@
import logging
from typing import Optional
import requests
from open_webui.retrieval.web.main import SearchResult
@@ -8,7 +9,13 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_tavily(api_key: str, query: str, count: int) -> list[SearchResult]:
def search_tavily(
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
# **kwargs,
) -> list[SearchResult]:
"""Search using Tavily's Search API and return the results as a list of SearchResult objects.
Args:
@@ -20,7 +27,6 @@ def search_tavily(api_key: str, query: str, count: int) -> list[SearchResult]:
"""
url = "https://api.tavily.com/search"
data = {"query": query, "api_key": api_key}
response = requests.post(url, json=data)
response.raise_for_status()
+1 -1
View File
@@ -683,7 +683,7 @@
"age": "October 29, 2022",
"extra_snippets": [
"You can pass many options to the configure script; run ./configure --help to find out more. On macOS case-insensitive file systems and on Cygwin, the executable is called python.exe; elsewhere it's just python.",
"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or useable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or usable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
"To get an optimized build of Python, configure --enable-optimizations before you run make. This sets the default make targets up to enable Profile Guided Optimization (PGO) and may be used to auto-enable Link Time Optimization (LTO) on some platforms. For more details, see the sections below.",
"Copyright © 2001-2024 Python Software Foundation. All rights reserved."
]
+105 -5
View File
@@ -1,7 +1,10 @@
import socket
import aiohttp
import asyncio
import urllib.parse
import validators
from typing import Union, Sequence, Iterator
from typing import Any, AsyncIterator, Dict, Iterator, List, Sequence, Union
from langchain_community.document_loaders import (
WebBaseLoader,
@@ -43,6 +46,17 @@ def validate_url(url: Union[str, Sequence[str]]):
return False
def safe_validate_urls(url: Sequence[str]) -> Sequence[str]:
valid_urls = []
for u in url:
try:
if validate_url(u):
valid_urls.append(u)
except ValueError:
continue
return valid_urls
def resolve_hostname(hostname):
# Get address information
addr_info = socket.getaddrinfo(hostname, None)
@@ -57,6 +71,70 @@ def resolve_hostname(hostname):
class SafeWebBaseLoader(WebBaseLoader):
"""WebBaseLoader with enhanced error handling for URLs."""
def __init__(self, trust_env: bool = False, *args, **kwargs):
"""Initialize SafeWebBaseLoader
Args:
trust_env (bool, optional): set to True if using proxy to make web requests, for example
using http(s)_proxy environment variables. Defaults to False.
"""
super().__init__(*args, **kwargs)
self.trust_env = trust_env
async def _fetch(
self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5
) -> str:
async with aiohttp.ClientSession(trust_env=self.trust_env) as session:
for i in range(retries):
try:
kwargs: Dict = dict(
headers=self.session.headers,
cookies=self.session.cookies.get_dict(),
)
if not self.session.verify:
kwargs["ssl"] = False
async with session.get(
url, **(self.requests_kwargs | kwargs)
) as response:
if self.raise_for_status:
response.raise_for_status()
return await response.text()
except aiohttp.ClientConnectionError as e:
if i == retries - 1:
raise
else:
log.warning(
f"Error fetching {url} with attempt "
f"{i + 1}/{retries}: {e}. Retrying..."
)
await asyncio.sleep(cooldown * backoff**i)
raise ValueError("retry count exceeded")
def _unpack_fetch_results(
self, results: Any, urls: List[str], parser: Union[str, None] = None
) -> List[Any]:
"""Unpack fetch results into BeautifulSoup objects."""
from bs4 import BeautifulSoup
final_results = []
for i, result in enumerate(results):
url = urls[i]
if parser is None:
if url.endswith(".xml"):
parser = "xml"
else:
parser = self.default_parser
self._check_parser(parser)
final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs))
return final_results
async def ascrape_all(
self, urls: List[str], parser: Union[str, None] = None
) -> List[Any]:
"""Async fetch all urls, then return soups for all results."""
results = await self.fetch_all(urls)
return self._unpack_fetch_results(results, urls, parser=parser)
def lazy_load(self) -> Iterator[Document]:
"""Lazy load text from the url(s) in web_path with error handling."""
for path in self.web_paths:
@@ -80,18 +158,40 @@ class SafeWebBaseLoader(WebBaseLoader):
# Log the error and continue with the next URL
log.error(f"Error loading {path}: {e}")
async def alazy_load(self) -> AsyncIterator[Document]:
"""Async lazy load text from the url(s) in web_path."""
results = await self.ascrape_all(self.web_paths)
for path, soup in zip(self.web_paths, results):
text = soup.get_text(**self.bs_get_text_kwargs)
metadata = {"source": path}
if title := soup.find("title"):
metadata["title"] = title.get_text()
if description := soup.find("meta", attrs={"name": "description"}):
metadata["description"] = description.get(
"content", "No description found."
)
if html := soup.find("html"):
metadata["language"] = html.get("lang", "No language found.")
yield Document(page_content=text, metadata=metadata)
async def aload(self) -> list[Document]:
"""Load data into Document objects."""
return [document async for document in self.alazy_load()]
def get_web_loader(
urls: Union[str, Sequence[str]],
verify_ssl: bool = True,
requests_per_second: int = 2,
trust_env: bool = False,
):
# Check if the URL is valid
if not validate_url(urls):
raise ValueError(ERROR_MESSAGES.INVALID_URL)
# Check if the URLs are valid
safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls)
return SafeWebBaseLoader(
urls,
web_path=safe_urls,
verify_ssl=verify_ssl,
requests_per_second=requests_per_second,
continue_on_failure=True,
trust_env=trust_env,
)
+68
View File
@@ -11,6 +11,7 @@ from pydub.silence import split_on_silence
import aiohttp
import aiofiles
import requests
import mimetypes
from fastapi import (
Depends,
@@ -138,6 +139,7 @@ class STTConfigForm(BaseModel):
ENGINE: str
MODEL: str
WHISPER_MODEL: str
DEEPGRAM_API_KEY: str
class AudioConfigUpdateForm(BaseModel):
@@ -165,6 +167,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
"ENGINE": request.app.state.config.STT_ENGINE,
"MODEL": request.app.state.config.STT_MODEL,
"WHISPER_MODEL": request.app.state.config.WHISPER_MODEL,
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
},
}
@@ -190,6 +193,7 @@ async def update_audio_config(
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
request.app.state.config.STT_MODEL = form_data.stt.MODEL
request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
if request.app.state.config.STT_ENGINE == "":
request.app.state.faster_whisper_model = set_faster_whisper_model(
@@ -214,6 +218,7 @@ async def update_audio_config(
"ENGINE": request.app.state.config.STT_ENGINE,
"MODEL": request.app.state.config.STT_MODEL,
"WHISPER_MODEL": request.app.state.config.WHISPER_MODEL,
"DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY,
},
}
@@ -521,6 +526,69 @@ def transcribe(request: Request, file_path):
raise Exception(detail if detail else "Open WebUI: Server Connection Error")
elif request.app.state.config.STT_ENGINE == "deepgram":
try:
# Determine the MIME type of the file
mime, _ = mimetypes.guess_type(file_path)
if not mime:
mime = "audio/wav" # fallback to wav if undetectable
# Read the audio file
with open(file_path, "rb") as f:
file_data = f.read()
# Build headers and parameters
headers = {
"Authorization": f"Token {request.app.state.config.DEEPGRAM_API_KEY}",
"Content-Type": mime,
}
# Add model if specified
params = {}
if request.app.state.config.STT_MODEL:
params["model"] = request.app.state.config.STT_MODEL
# Make request to Deepgram API
r = requests.post(
"https://api.deepgram.com/v1/listen",
headers=headers,
params=params,
data=file_data,
)
r.raise_for_status()
response_data = r.json()
# Extract transcript from Deepgram response
try:
transcript = response_data["results"]["channels"][0]["alternatives"][
0
].get("transcript", "")
except (KeyError, IndexError) as e:
log.error(f"Malformed response from Deepgram: {str(e)}")
raise Exception(
"Failed to parse Deepgram response - unexpected response format"
)
data = {"text": transcript.strip()}
# Save transcript
transcript_file = f"{file_dir}/{id}.json"
with open(transcript_file, "w") as f:
json.dump(data, f)
return data
except Exception as e:
log.exception(e)
detail = None
if r is not None:
try:
res = r.json()
if "error" in res:
detail = f"External: {res['error'].get('message', '')}"
except Exception:
detail = f"External: {e}"
raise Exception(detail if detail else "Open WebUI: Server Connection Error")
def compress_audio(file_path):
if os.path.getsize(file_path) > MAX_FILE_SIZE:
+51 -18
View File
@@ -25,8 +25,8 @@ from open_webui.env import (
WEBUI_AUTH,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
SRC_LOG_LEVELS,
)
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -51,7 +51,7 @@ from open_webui.utils.access_control import get_permissions
from typing import Optional, List
from ssl import CERT_REQUIRED, PROTOCOL_TLS
from ldap3 import Server, Connection, ALL, Tls
from ldap3 import Server, Connection, NONE, Tls
from ldap3.utils.conv import escape_filter_chars
router = APIRouter()
@@ -95,8 +95,8 @@ async def get_session_user(
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_SESSION_COOKIE_SAME_SITE,
secure=WEBUI_SESSION_COOKIE_SECURE,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
@@ -164,12 +164,13 @@ async def update_password(
############################
# LDAP Authentication
############################
@router.post("/ldap", response_model=SigninResponse)
@router.post("/ldap", response_model=SessionUserResponse)
async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
ENABLE_LDAP = request.app.state.config.ENABLE_LDAP
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
@@ -201,7 +202,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
server = Server(
host=LDAP_SERVER_HOST,
port=LDAP_SERVER_PORT,
get_info=ALL,
get_info=NONE,
use_ssl=LDAP_USE_TLS,
tls=tls,
)
@@ -218,7 +219,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
search_success = connection_app.search(
search_base=LDAP_SEARCH_BASE,
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
attributes=[f"{LDAP_ATTRIBUTE_FOR_USERNAME}", "mail", "cn"],
attributes=[
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
"cn",
],
)
if not search_success:
@@ -226,7 +231,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
entry = connection_app.entries[0]
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
mail = str(entry["mail"])
mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
if not mail or mail == "" or mail == "[]":
raise HTTPException(400, f"User {form_data.user} does not have mail.")
cn = str(entry["cn"])
user_dn = entry.entry_dn
@@ -244,9 +251,19 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
user = Users.get_user_by_email(mail)
if not user:
try:
user_count = Users.get_num_users()
if (
request.app.state.USER_COUNT
and user_count >= request.app.state.USER_COUNT
):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
role = (
"admin"
if Users.get_num_users() == 0
if user_count == 0
else request.app.state.config.DEFAULT_USER_ROLE
)
@@ -281,6 +298,10 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
httponly=True, # Ensures the cookie is not accessible via JavaScript
)
user_permissions = get_permissions(
user.id, request.app.state.config.USER_PERMISSIONS
)
return {
"token": token,
"token_type": "Bearer",
@@ -289,6 +310,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
"name": user.name,
"role": user.role,
"profile_image_url": user.profile_image_url,
"permissions": user_permissions,
}
else:
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
@@ -371,8 +393,8 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_SESSION_COOKIE_SAME_SITE,
secure=WEBUI_SESSION_COOKIE_SECURE,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
user_permissions = get_permissions(
@@ -401,6 +423,7 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
@router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm):
if WEBUI_AUTH:
if (
not request.app.state.config.ENABLE_SIGNUP
@@ -415,6 +438,12 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
user_count = Users.get_num_users()
if request.app.state.USER_COUNT and user_count >= request.app.state.USER_COUNT:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
if not validate_email_format(form_data.email.lower()):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
@@ -425,12 +454,10 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
try:
role = (
"admin"
if Users.get_num_users() == 0
else request.app.state.config.DEFAULT_USER_ROLE
"admin" if user_count == 0 else request.app.state.config.DEFAULT_USER_ROLE
)
if Users.get_num_users() == 0:
if user_count == 0:
# Disable signup after the first user is created
request.app.state.config.ENABLE_SIGNUP = False
@@ -466,12 +493,13 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
value=token,
expires=datetime_expires_at,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_SESSION_COOKIE_SAME_SITE,
secure=WEBUI_SESSION_COOKIE_SECURE,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if request.app.state.config.WEBHOOK_URL:
post_webhook(
request.app.state.WEBUI_NAME,
request.app.state.config.WEBHOOK_URL,
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
@@ -691,6 +719,7 @@ class LdapServerConfig(BaseModel):
label: str
host: str
port: Optional[int] = None
attribute_for_mail: str = "mail"
attribute_for_username: str = "uid"
app_dn: str
app_dn_password: str
@@ -707,6 +736,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
"label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST,
"port": request.app.state.config.LDAP_SERVER_PORT,
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
"app_dn": request.app.state.config.LDAP_APP_DN,
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
@@ -725,6 +755,7 @@ async def update_ldap_server(
required_fields = [
"label",
"host",
"attribute_for_mail",
"attribute_for_username",
"app_dn",
"app_dn_password",
@@ -743,6 +774,7 @@ async def update_ldap_server(
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
request.app.state.config.LDAP_SERVER_HOST = form_data.host
request.app.state.config.LDAP_SERVER_PORT = form_data.port
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
form_data.attribute_for_username
)
@@ -758,6 +790,7 @@ async def update_ldap_server(
"label": request.app.state.config.LDAP_SERVER_LABEL,
"host": request.app.state.config.LDAP_SERVER_HOST,
"port": request.app.state.config.LDAP_SERVER_PORT,
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
"app_dn": request.app.state.config.LDAP_APP_DN,
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
+332 -14
View File
@@ -11,7 +11,12 @@ from open_webui.socket.main import sio, get_user_ids_from_room
from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels, ChannelModel, ChannelForm
from open_webui.models.messages import Messages, MessageModel, MessageForm
from open_webui.models.messages import (
Messages,
MessageModel,
MessageResponse,
MessageForm,
)
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
@@ -49,7 +54,7 @@ async def get_channels(user=Depends(get_verified_user)):
@router.post("/create", response_model=Optional[ChannelModel])
async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
try:
channel = Channels.insert_new_channel(form_data, user.id)
channel = Channels.insert_new_channel(None, form_data, user.id)
return ChannelModel(**channel.model_dump())
except Exception as e:
log.exception(e)
@@ -134,11 +139,11 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
############################
class MessageUserModel(MessageModel):
class MessageUserResponse(MessageResponse):
user: UserNameResponse
@router.get("/{id}/messages", response_model=list[MessageUserModel])
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
async def get_channel_messages(
id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user)
):
@@ -164,10 +169,16 @@ async def get_channel_messages(
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
replies = Messages.get_replies_by_message_id(message.id)
latest_reply_at = replies[0].created_at if replies else None
messages.append(
MessageUserModel(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": len(replies),
"latest_reply_at": latest_reply_at,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
@@ -181,7 +192,7 @@ async def get_channel_messages(
############################
async def send_notification(webui_url, channel, message, active_user_ids):
async def send_notification(name, webui_url, channel, message, active_user_ids):
users = get_users_with_access("read", channel.access_control)
for user in users:
@@ -195,6 +206,7 @@ async def send_notification(webui_url, channel, message, active_user_ids):
if webhook_url:
post_webhook(
name,
webhook_url,
f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
{
@@ -236,10 +248,17 @@ async def post_new_message(
"message_id": message.id,
"data": {
"type": "message",
"data": {
**message.model_dump(),
"user": UserNameResponse(**user.model_dump()).model_dump(),
},
"data": MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(
message.id
),
"user": UserNameResponse(**user.model_dump()),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -251,10 +270,40 @@ async def post_new_message(
to=f"channel:{channel.id}",
)
if message.parent_id:
# If this message is a reply, emit to the parent message as well
parent_message = Messages.get_message_by_id(message.parent_id)
if parent_message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
background_tasks.add_task(
send_notification,
request.app.state.WEBUI_NAME,
request.app.state.config.WEBUI_URL,
channel,
message,
@@ -269,6 +318,101 @@ async def post_new_message(
)
############################
# GetChannelMessage
############################
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse])
async def get_channel_message(
id: str, message_id: str, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
),
}
)
############################
# GetChannelThreadMessages
############################
@router.get(
"/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse]
)
async def get_channel_thread_messages(
id: str,
message_id: str,
skip: int = 0,
limit: int = 50,
user=Depends(get_verified_user),
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit)
users = {}
messages = []
for message in message_list:
if message.user_id not in users:
user = Users.get_user_by_id(message.user_id)
users[message.user_id] = user
messages.append(
MessageUserResponse(
**{
**message.model_dump(),
"reply_count": 0,
"latest_reply_at": None,
"reactions": Messages.get_reactions_by_message_id(message.id),
"user": UserNameResponse(**users[message.user_id].model_dump()),
}
)
)
return messages
############################
# UpdateMessageById
############################
@@ -306,6 +450,8 @@ async def update_message_by_id(
try:
message = Messages.update_message_by_id(message_id, form_data)
message = Messages.get_message_by_id(message_id)
if message:
await sio.emit(
"channel-events",
@@ -314,10 +460,14 @@ async def update_message_by_id(
"message_id": message.id,
"data": {
"type": "message:update",
"data": {
**message.model_dump(),
"user": UserNameResponse(**user.model_dump()).model_dump(),
},
"data": MessageUserResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
**user.model_dump()
).model_dump(),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
@@ -333,6 +483,145 @@ async def update_message_by_id(
)
############################
# AddReactionToMessage
############################
class ReactionForm(BaseModel):
name: str
@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool)
async def add_reaction_to_message(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.add_reaction_to_message(message_id, user.id, form_data.name)
message = Messages.get_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:reaction:add",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# RemoveReactionById
############################
@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool)
async def remove_reaction_by_id_and_user_id_and_name(
id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
try:
Messages.remove_reaction_by_id_and_user_id_and_name(
message_id, user.id, form_data.name
)
message = Messages.get_message_by_id(message_id)
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": message.id,
"data": {
"type": "message:reaction:remove",
"data": {
**message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(message.user_id).model_dump()
).model_dump(),
"name": form_data.name,
},
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# DeleteMessageById
############################
@@ -386,6 +675,35 @@ async def delete_message_by_id(
to=f"channel:{channel.id}",
)
if message.parent_id:
# If this message is a reply, emit to the parent message as well
parent_message = Messages.get_message_by_id(message.parent_id)
if parent_message:
await sio.emit(
"channel-events",
{
"channel_id": channel.id,
"message_id": parent_message.id,
"data": {
"type": "message:reply",
"data": MessageUserResponse(
**{
**parent_message.model_dump(),
"user": UserNameResponse(
**Users.get_user_by_id(
parent_message.user_id
).model_dump()
),
}
).model_dump(),
},
"user": UserNameResponse(**user.model_dump()).model_dump(),
"channel": channel.model_dump(),
},
to=f"channel:{channel.id}",
)
return True
except Exception as e:
log.exception(e)
+31 -1
View File
@@ -444,9 +444,39 @@ async def pin_chat_by_id(id: str, user=Depends(get_verified_user)):
############################
class CloneForm(BaseModel):
title: Optional[str] = None
@router.post("/{id}/clone", response_model=Optional[ChatResponse])
async def clone_chat_by_id(id: str, user=Depends(get_verified_user)):
async def clone_chat_by_id(
form_data: CloneForm, id: str, user=Depends(get_verified_user)
):
chat = Chats.get_chat_by_id_and_user_id(id, user.id)
if chat:
updated_chat = {
**chat.chat,
"originalChatId": chat.id,
"branchPointMessageId": chat.chat["history"]["currentId"],
"title": form_data.title if form_data.title else f"Clone of {chat.title}",
}
chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
)
############################
# CloneSharedChatById
############################
@router.post("/{id}/clone/shared", response_model=Optional[ChatResponse])
async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
chat = Chats.get_chat_by_share_id(id)
if chat:
updated_chat = {
**chat.chat,
+122
View File
@@ -36,6 +36,128 @@ async def export_config(user=Depends(get_admin_user)):
return get_config()
############################
# Direct Connections Config
############################
class DirectConnectionsConfigForm(BaseModel):
ENABLE_DIRECT_CONNECTIONS: bool
@router.get("/direct_connections", response_model=DirectConnectionsConfigForm)
async def get_direct_connections_config(request: Request, user=Depends(get_admin_user)):
return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
}
@router.post("/direct_connections", response_model=DirectConnectionsConfigForm)
async def set_direct_connections_config(
request: Request,
form_data: DirectConnectionsConfigForm,
user=Depends(get_admin_user),
):
request.app.state.config.ENABLE_DIRECT_CONNECTIONS = (
form_data.ENABLE_DIRECT_CONNECTIONS
)
return {
"ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS,
}
############################
# CodeInterpreterConfig
############################
class CodeInterpreterConfigForm(BaseModel):
CODE_EXECUTION_ENGINE: str
CODE_EXECUTION_JUPYTER_URL: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH_TOKEN: Optional[str]
CODE_EXECUTION_JUPYTER_AUTH_PASSWORD: Optional[str]
ENABLE_CODE_INTERPRETER: bool
CODE_INTERPRETER_ENGINE: str
CODE_INTERPRETER_PROMPT_TEMPLATE: Optional[str]
CODE_INTERPRETER_JUPYTER_URL: Optional[str]
CODE_INTERPRETER_JUPYTER_AUTH: Optional[str]
CODE_INTERPRETER_JUPYTER_AUTH_TOKEN: Optional[str]
CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD: Optional[str]
@router.get("/code_execution", response_model=CodeInterpreterConfigForm)
async def get_code_execution_config(request: Request, user=Depends(get_admin_user)):
return {
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
"CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
"CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
"ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER,
"CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE,
"CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
"CODE_INTERPRETER_JUPYTER_URL": request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
"CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
}
@router.post("/code_execution", response_model=CodeInterpreterConfigForm)
async def set_code_execution_config(
request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user)
):
request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE
request.app.state.config.CODE_EXECUTION_JUPYTER_URL = (
form_data.CODE_EXECUTION_JUPYTER_URL
)
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = (
form_data.CODE_EXECUTION_JUPYTER_AUTH
)
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = (
form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
)
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = (
form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
)
request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER
request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = (
form_data.CODE_INTERPRETER_PROMPT_TEMPLATE
)
request.app.state.config.CODE_INTERPRETER_JUPYTER_URL = (
form_data.CODE_INTERPRETER_JUPYTER_URL
)
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = (
form_data.CODE_INTERPRETER_JUPYTER_AUTH
)
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = (
form_data.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN
)
request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = (
form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD
)
return {
"CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE,
"CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
"CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH,
"CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN,
"CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD,
"ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER,
"CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE,
"CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE,
"CODE_INTERPRETER_JUPYTER_URL": request.app.state.config.CODE_INTERPRETER_JUPYTER_URL,
"CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH,
"CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN,
"CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD,
}
############################
# SetDefaultModels
############################
+23 -23
View File
@@ -3,30 +3,22 @@ import os
import uuid
from pathlib import Path
from typing import Optional
from pydantic import BaseModel
import mimetypes
from urllib.parse import quote
from open_webui.storage.provider import Storage
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import (
FileForm,
FileModel,
FileModelResponse,
Files,
)
from open_webui.routers.retrieval import process_file, ProcessFileForm
from open_webui.config import UPLOAD_DIR
from open_webui.env import SRC_LOG_LEVELS
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from pydantic import BaseModel
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -41,7 +33,10 @@ router = APIRouter()
@router.post("/", response_model=FileModelResponse)
def upload_file(
request: Request, file: UploadFile = File(...), user=Depends(get_verified_user)
request: Request,
file: UploadFile = File(...),
user=Depends(get_verified_user),
file_metadata: dict = {},
):
log.info(f"file.content_type: {file.content_type}")
try:
@@ -65,13 +60,14 @@ def upload_file(
"name": name,
"content_type": file.content_type,
"size": len(contents),
"data": file_metadata,
},
}
),
)
try:
process_file(request, ProcessFileForm(file_id=id))
process_file(request, ProcessFileForm(file_id=id), user=user)
file_item = Files.get_file_by_id(id=id)
except Exception as e:
log.exception(e)
@@ -126,7 +122,7 @@ async def delete_all_files(user=Depends(get_admin_user)):
Storage.delete_all_files()
except Exception as e:
log.exception(e)
log.error(f"Error deleting files")
log.error("Error deleting files")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting files"),
@@ -193,7 +189,9 @@ async def update_file_data_content_by_id(
if file and (file.user_id == user.id or user.role == "admin"):
try:
process_file(
request, ProcessFileForm(file_id=id, content=form_data.content)
request,
ProcessFileForm(file_id=id, content=form_data.content),
user=user,
)
file = Files.get_file_by_id(id=id)
except Exception as e:
@@ -246,7 +244,7 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
)
except Exception as e:
log.exception(e)
log.error(f"Error getting file content")
log.error("Error getting file content")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error getting file content"),
@@ -277,7 +275,7 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
)
except Exception as e:
log.exception(e)
log.error(f"Error getting file content")
log.error("Error getting file content")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error getting file content"),
@@ -345,13 +343,15 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
file = Files.get_file_by_id(id)
if file and (file.user_id == user.id or user.role == "admin"):
# We should add Chroma cleanup here
result = Files.delete_file_by_id(id)
if result:
try:
Storage.delete_file(file.filename)
Storage.delete_file(file.path)
except Exception as e:
log.exception(e)
log.error(f"Error deleting files")
log.error("Error deleting files")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT("Error deleting files"),
+5
View File
@@ -2,6 +2,8 @@ import os
from pathlib import Path
from typing import Optional
from open_webui.models.users import Users
from open_webui.models.groups import (
Groups,
GroupForm,
@@ -80,6 +82,9 @@ async def update_group_by_id(
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
):
try:
if form_data.user_ids:
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
group = Groups.update_group_by_id(id, form_data)
if group:
return group
+63 -69
View File
@@ -1,32 +1,26 @@
import asyncio
import base64
import io
import json
import logging
import mimetypes
import re
import uuid
from pathlib import Path
from typing import Optional
import requests
from fastapi import Depends, FastAPI, HTTPException, Request, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
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 ENV, SRC_LOG_LEVELS, ENABLE_FORWARD_USER_INFO_HEADERS
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
from open_webui.routers.files import upload_file
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.images.comfyui import (
ComfyUIGenerateImageForm,
ComfyUIWorkflow,
comfyui_generate_image,
)
from pydantic import BaseModel
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["IMAGES"])
@@ -43,6 +37,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
return {
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
"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_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
@@ -86,6 +81,7 @@ class ComfyUIConfigForm(BaseModel):
class ConfigForm(BaseModel):
enabled: bool
engine: str
prompt_generation: bool
openai: OpenAIConfigForm
automatic1111: Automatic1111ConfigForm
comfyui: ComfyUIConfigForm
@@ -98,6 +94,10 @@ async def update_config(
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine
request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = (
form_data.prompt_generation
)
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
form_data.openai.OPENAI_API_BASE_URL
)
@@ -137,6 +137,7 @@ async def update_config(
return {
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
"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_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
@@ -264,7 +265,6 @@ async def get_image_config(request: Request, user=Depends(get_admin_user)):
async def update_image_config(
request: Request, form_data: ImageConfigForm, user=Depends(get_admin_user)
):
set_image_model(request, form_data.MODEL)
pattern = r"^\d+x\d+$"
@@ -376,57 +376,32 @@ class GenerateImageForm(BaseModel):
negative_prompt: Optional[str] = None
def save_b64_image(b64_str):
def load_b64_image_data(b64_str):
try:
image_id = str(uuid.uuid4())
if "," in b64_str:
header, encoded = b64_str.split(",", 1)
mime_type = header.split(";")[0]
img_data = base64.b64decode(encoded)
image_format = mimetypes.guess_extension(mime_type)
image_filename = f"{image_id}{image_format}"
file_path = IMAGE_CACHE_DIR / f"{image_filename}"
with open(file_path, "wb") as f:
f.write(img_data)
return image_filename
else:
image_filename = f"{image_id}.png"
file_path = IMAGE_CACHE_DIR.joinpath(image_filename)
mime_type = "image/png"
img_data = base64.b64decode(b64_str)
# Write the image data to a file
with open(file_path, "wb") as f:
f.write(img_data)
return image_filename
return img_data, mime_type
except Exception as e:
log.exception(f"Error saving image: {e}")
log.exception(f"Error loading image data: {e}")
return None
def save_url_image(url):
image_id = str(uuid.uuid4())
def load_url_image_data(url, headers=None):
try:
r = requests.get(url)
if headers:
r = requests.get(url, headers=headers)
else:
r = requests.get(url)
r.raise_for_status()
if r.headers["content-type"].split("/")[0] == "image":
mime_type = r.headers["content-type"]
image_format = mimetypes.guess_extension(mime_type)
if not image_format:
raise ValueError("Could not determine image type from MIME type")
image_filename = f"{image_id}{image_format}"
file_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}")
with open(file_path, "wb") as image_file:
for chunk in r.iter_content(chunk_size=8192):
image_file.write(chunk)
return image_filename
return r.content, mime_type
else:
log.error("Url does not point to an image.")
return None
@@ -436,6 +411,20 @@ def save_url_image(url):
return None
def upload_image(request, image_metadata, image_data, content_type, user):
image_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(image_data),
filename=f"generated-image{image_format}", # will be converted to a unique ID on upload_file
headers={
"content-type": content_type,
},
)
file_item = upload_file(request, file, user, file_metadata=image_metadata)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
@router.post("/generations")
async def image_generations(
request: Request,
@@ -489,13 +478,9 @@ async def image_generations(
images = []
for image in res["data"]:
image_filename = save_b64_image(image["b64_json"])
images.append({"url": f"/cache/image/generations/{image_filename}"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
with open(file_body_path, "w") as f:
json.dump(data, f)
image_data, content_type = load_b64_image_data(image["b64_json"])
url = upload_image(request, data, image_data, content_type, user)
images.append({"url": url})
return images
elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
@@ -535,14 +520,21 @@ async def image_generations(
images = []
for image in res["data"]:
image_filename = save_url_image(image["url"])
images.append({"url": f"/cache/image/generations/{image_filename}"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
headers = None
if request.app.state.config.COMFYUI_API_KEY:
headers = {
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
}
with open(file_body_path, "w") as f:
json.dump(form_data.model_dump(exclude_none=True), f)
log.debug(f"images: {images}")
image_data, content_type = load_url_image_data(image["url"], headers)
url = upload_image(
request,
form_data.model_dump(exclude_none=True),
image_data,
content_type,
user,
)
images.append({"url": url})
return images
elif (
request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111"
@@ -587,13 +579,15 @@ async def image_generations(
images = []
for image in res["images"]:
image_filename = save_b64_image(image)
images.append({"url": f"/cache/image/generations/{image_filename}"})
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
with open(file_body_path, "w") as f:
json.dump({**data, "info": res["info"]}, f)
image_data, content_type = load_b64_image_data(image)
url = upload_image(
request,
{**data, "info": res["info"]},
image_data,
content_type,
user,
)
images.append({"url": url})
return images
except Exception as e:
error = e
+84 -16
View File
@@ -17,7 +17,7 @@ from open_webui.routers.retrieval import (
process_files_batch,
BatchProcessFilesForm,
)
from open_webui.storage.provider import Storage
from open_webui.constants import ERROR_MESSAGES
from open_webui.utils.auth import get_verified_user
@@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.models import Models, ModelForm
log = logging.getLogger(__name__)
@@ -212,8 +213,12 @@ async def update_knowledge_by_id(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
# Is the user the original creator, in a group with write access, or an admin
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -259,7 +264,11 @@ def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -280,7 +289,9 @@ def add_file_to_knowledge_by_id(
# Add content to the vector database
try:
process_file(
request, ProcessFileForm(file_id=form_data.file_id, collection_name=id)
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
)
except Exception as e:
log.debug(e)
@@ -337,7 +348,12 @@ def update_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -358,7 +374,9 @@ def update_file_from_knowledge_by_id(
# Add content to the vector database
try:
process_file(
request, ProcessFileForm(file_id=form_data.file_id, collection_name=id)
request,
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user,
)
except Exception as e:
raise HTTPException(
@@ -401,7 +419,11 @@ def remove_file_from_knowledge_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -419,11 +441,12 @@ def remove_file_from_knowledge_by_id(
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
)
result = VECTOR_DB_CLIENT.query(
collection_name=knowledge.id,
filter={"file_id": form_data.file_id},
)
# 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)
# Delete file from database
Files.delete_file_by_id(form_data.file_id)
if knowledge:
@@ -474,12 +497,46 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})")
# Get all models
models = Models.get_all_models()
log.info(f"Found {len(models)} models to check for knowledge base {id}")
# Update models that reference this knowledge base
for model in models:
if model.meta and hasattr(model.meta, "knowledge"):
knowledge_list = model.meta.knowledge or []
# Filter out the deleted knowledge base
updated_knowledge = [k for k in knowledge_list if k.get("id") != id]
# If the knowledge list changed, update the model
if len(updated_knowledge) != len(knowledge_list):
log.info(f"Updating model {model.id} to remove knowledge base {id}")
model.meta.knowledge = updated_knowledge
# Create a ModelForm for the update
model_form = ModelForm(
id=model.id,
name=model.name,
base_model_id=model.base_model_id,
meta=model.meta,
params=model.params,
access_control=model.access_control,
is_active=model.is_active,
)
Models.update_model_by_id(model.id, model_form)
# Clean up vector DB
try:
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
except Exception as e:
@@ -503,7 +560,11 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -527,6 +588,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
@router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse])
def add_files_to_knowledge_batch(
request: Request,
id: str,
form_data: list[KnowledgeFileIdForm],
user=Depends(get_verified_user),
@@ -541,7 +603,11 @@ def add_files_to_knowledge_batch(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if knowledge.user_id != user.id and user.role != "admin":
if (
knowledge.user_id != user.id
and not has_access(user.id, "write", knowledge.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -562,7 +628,9 @@ def add_files_to_knowledge_batch(
# Process files
try:
result = process_files_batch(
BatchProcessFilesForm(files=files, collection_name=id)
request=request,
form_data=BatchProcessFilesForm(files=files, collection_name=id),
user=user,
)
except Exception as e:
log.error(
+6 -4
View File
@@ -57,7 +57,7 @@ async def add_memory(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content),
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user),
"metadata": {"created_at": memory.created_at},
}
],
@@ -82,7 +82,7 @@ async def query_memory(
):
results = VECTOR_DB_CLIENT.search(
collection_name=f"user-memory-{user.id}",
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content)],
vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user)],
limit=form_data.k,
)
@@ -105,7 +105,7 @@ async def reset_memory_from_vector_db(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content),
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content, user),
"metadata": {
"created_at": memory.created_at,
"updated_at": memory.updated_at,
@@ -160,7 +160,9 @@ async def update_memory_by_id(
{
"id": memory.id,
"text": memory.content,
"vector": request.app.state.EMBEDDING_FUNCTION(memory.content),
"vector": request.app.state.EMBEDDING_FUNCTION(
memory.content, user
),
"metadata": {
"created_at": memory.created_at,
"updated_at": memory.updated_at,
+15 -1
View File
@@ -155,6 +155,16 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
model.user_id != user.id
and not has_access(user.id, "write", model.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
model = Models.update_model_by_id(id, form_data)
return model
@@ -173,7 +183,11 @@ async def delete_model_by_id(id: str, user=Depends(get_verified_user)):
detail=ERROR_MESSAGES.NOT_FOUND,
)
if model.user_id != user.id and user.role != "admin":
if (
user.role != "admin"
and model.user_id != user.id
and not has_access(user.id, "write", model.access_control)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
+155 -90
View File
@@ -11,10 +11,8 @@ import re
import time
from typing import Optional, Union
from urllib.parse import urlparse
import aiohttp
from aiocache import cached
import requests
from fastapi import (
@@ -152,10 +150,12 @@ async def send_post_request(
)
def get_api_key(url, configs):
def get_api_key(idx, url, configs):
parsed_url = urlparse(url)
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
return configs.get(base_url, {}).get("key", None)
return configs.get(str(idx), configs.get(base_url, {})).get(
"key", None
) # Legacy support
##########################################
@@ -238,11 +238,13 @@ async def update_config(
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
# Remove any extra configs
config_urls = request.app.state.config.OLLAMA_API_CONFIGS.keys()
for url in list(request.app.state.config.OLLAMA_BASE_URLS):
if url not in config_urls:
request.app.state.config.OLLAMA_API_CONFIGS.pop(url, None)
# Remove the API configs that are not in the API URLS
keys = list(map(str, range(len(request.app.state.config.OLLAMA_BASE_URLS))))
request.app.state.config.OLLAMA_API_CONFIGS = {
key: value
for key, value in request.app.state.config.OLLAMA_API_CONFIGS.items()
if key in keys
}
return {
"ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API,
@@ -256,12 +258,19 @@ async def get_all_models(request: Request):
log.info("get_all_models()")
if request.app.state.config.ENABLE_OLLAMA_API:
request_tasks = []
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
if url not in request.app.state.config.OLLAMA_API_CONFIGS:
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and (
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
):
request_tasks.append(send_get_request(f"{url}/api/tags"))
else:
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True)
key = api_config.get("key", None)
@@ -275,7 +284,12 @@ async def get_all_models(request: Request):
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
model_ids = api_config.get("model_ids", [])
@@ -349,7 +363,7 @@ async def get_ollama_tags(
models = await get_all_models(request)
else:
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
r = None
try:
@@ -379,7 +393,7 @@ async def get_ollama_tags(
)
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
models["models"] = get_filtered_models(models, user)
models["models"] = await get_filtered_models(models, user)
return models
@@ -393,11 +407,14 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
request_tasks = [
send_get_request(
f"{url}/api/version",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
"key", None
),
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
)
for url in request.app.state.config.OLLAMA_BASE_URLS
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
responses = await asyncio.gather(*request_tasks)
responses = list(filter(lambda x: x is not None, responses))
@@ -454,11 +471,14 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
request_tasks = [
send_get_request(
f"{url}/api/ps",
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
"key", None
),
request.app.state.config.OLLAMA_API_CONFIGS.get(
str(idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(
url, {}
), # Legacy support
).get("key", None),
)
for url in request.app.state.config.OLLAMA_BASE_URLS
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
]
responses = await asyncio.gather(*request_tasks)
@@ -488,7 +508,7 @@ async def pull_model(
return await send_post_request(
url=f"{url}/api/pull",
payload=json.dumps(payload),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -524,16 +544,17 @@ async def push_model(
return await send_post_request(
url=f"{url}/api/push",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
class CreateModelForm(BaseModel):
name: str
modelfile: Optional[str] = None
model: Optional[str] = None
stream: Optional[bool] = None
path: Optional[str] = None
model_config = ConfigDict(extra="allow")
@router.post("/api/create")
@router.post("/api/create/{url_idx}")
@@ -549,7 +570,7 @@ async def create_model(
return await send_post_request(
url=f"{url}/api/create",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -579,7 +600,7 @@ async def copy_model(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -634,7 +655,7 @@ async def delete_model(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -684,7 +705,7 @@ async def show_model_info(
url_idx = random.choice(models[form_data.name]["urls"])
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -753,7 +774,7 @@ async def embed(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -822,7 +843,7 @@ async def embeddings(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
try:
r = requests.request(
@@ -897,7 +918,10 @@ async def generate_completion(
)
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
@@ -906,24 +930,26 @@ async def generate_completion(
return await send_post_request(
url=f"{url}/api/generate",
payload=form_data.model_dump_json(exclude_none=True).encode(),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
class ChatMessage(BaseModel):
role: str
content: str
tool_calls: Optional[list[dict]] = None
images: Optional[list[str]] = None
class GenerateChatCompletionForm(BaseModel):
model: str
messages: list[ChatMessage]
format: Optional[dict] = None
format: Optional[Union[dict, str]] = None
options: Optional[dict] = None
template: Optional[str] = None
stream: Optional[bool] = True
keep_alive: Optional[Union[int, str]] = None
tools: Optional[list[dict]] = None
async def get_ollama_url(request: Request, model: str, url_idx: Optional[int] = None):
@@ -936,7 +962,7 @@ async def get_ollama_url(request: Request, model: str, url_idx: Optional[int] =
)
url_idx = random.choice(models[model].get("urls", []))
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
return url
return url, url_idx
@router.post("/api/chat")
@@ -951,6 +977,7 @@ async def generate_chat_completion(
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
metadata = form_data.pop("metadata", None)
try:
form_data = GenerateChatCompletionForm(**form_data)
except Exception as e:
@@ -980,7 +1007,7 @@ async def generate_chat_completion(
payload["options"] = apply_model_params_to_body_ollama(
params, payload["options"]
)
payload = apply_model_system_prompt_to_body(params, payload, user)
payload = apply_model_system_prompt_to_body(params, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
@@ -1004,8 +1031,11 @@ async def generate_chat_completion(
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
@@ -1015,7 +1045,7 @@ async def generate_chat_completion(
url=f"{url}/api/chat",
payload=json.dumps(payload),
stream=form_data.stream,
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
content_type="application/x-ndjson",
)
@@ -1103,8 +1133,11 @@ async def generate_openai_completion(
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
@@ -1115,7 +1148,7 @@ async def generate_openai_completion(
url=f"{url}/v1/completions",
payload=json.dumps(payload),
stream=payload.get("stream", False),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -1127,6 +1160,8 @@ async def generate_openai_chat_completion(
url_idx: Optional[int] = None,
user=Depends(get_verified_user),
):
metadata = form_data.pop("metadata", None)
try:
completion_form = OpenAIChatCompletionForm(**form_data)
except Exception as e:
@@ -1153,7 +1188,7 @@ async def generate_openai_chat_completion(
if params:
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_model_system_prompt_to_body(params, payload, user)
payload = apply_model_system_prompt_to_body(params, payload, metadata, user)
# Check if user has access to the model
if user.role == "user":
@@ -1177,8 +1212,11 @@ async def generate_openai_chat_completion(
if ":" not in payload["model"]:
payload["model"] = f"{payload['model']}:latest"
url = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
url, url_idx = await get_ollama_url(request, payload["model"], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
str(url_idx),
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
if prefix_id:
@@ -1188,7 +1226,7 @@ async def generate_openai_chat_completion(
url=f"{url}/v1/chat/completions",
payload=json.dumps(payload),
stream=payload.get("stream", False),
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
)
@@ -1370,9 +1408,10 @@ async def download_model(
return None
# TODO: Progress bar does not reflect size & duration of upload.
@router.post("/models/upload")
@router.post("/models/upload/{url_idx}")
def upload_model(
async def upload_model(
request: Request,
file: UploadFile = File(...),
url_idx: Optional[int] = None,
@@ -1381,59 +1420,85 @@ def upload_model(
if url_idx is None:
url_idx = 0
ollama_url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
file_path = os.path.join(UPLOAD_DIR, file.filename)
os.makedirs(UPLOAD_DIR, exist_ok=True)
file_path = f"{UPLOAD_DIR}/{file.filename}"
# --- P1: save file locally ---
chunk_size = 1024 * 1024 * 2 # 2 MB chunks
with open(file_path, "wb") as out_f:
while True:
chunk = file.file.read(chunk_size)
# log.info(f"Chunk: {str(chunk)}") # DEBUG
if not chunk:
break
out_f.write(chunk)
# Save file in chunks
with open(file_path, "wb+") as f:
for chunk in file.file:
f.write(chunk)
def file_process_stream():
async def file_process_stream():
nonlocal ollama_url
total_size = os.path.getsize(file_path)
chunk_size = 1024 * 1024
log.info(f"Total Model Size: {str(total_size)}") # DEBUG
# --- P2: SSE progress + calculate sha256 hash ---
file_hash = calculate_sha256(file_path, chunk_size)
log.info(f"Model Hash: {str(file_hash)}") # DEBUG
try:
with open(file_path, "rb") as f:
total = 0
done = False
while not done:
chunk = f.read(chunk_size)
if not chunk:
done = True
continue
total += len(chunk)
progress = round((total / total_size) * 100, 2)
res = {
bytes_read = 0
while chunk := f.read(chunk_size):
bytes_read += len(chunk)
progress = round(bytes_read / total_size * 100, 2)
data_msg = {
"progress": progress,
"total": total_size,
"completed": total,
"completed": bytes_read,
}
yield f"data: {json.dumps(res)}\n\n"
yield f"data: {json.dumps(data_msg)}\n\n"
if done:
f.seek(0)
hashed = calculate_sha256(f)
f.seek(0)
# --- P3: Upload to ollama /api/blobs ---
with open(file_path, "rb") as f:
url = f"{ollama_url}/api/blobs/sha256:{file_hash}"
response = requests.post(url, data=f)
url = f"{ollama_url}/api/blobs/sha256:{hashed}"
response = requests.post(url, data=f)
if response.ok:
log.info(f"Uploaded to /api/blobs") # DEBUG
# Remove local file
os.remove(file_path)
if response.ok:
res = {
"done": done,
"blob": f"sha256:{hashed}",
"name": file.filename,
}
os.remove(file_path)
yield f"data: {json.dumps(res)}\n\n"
else:
raise Exception(
"Ollama: Could not create blob, Please try again."
)
# Create model in ollama
model_name, ext = os.path.splitext(file.filename)
log.info(f"Created Model: {model_name}") # DEBUG
create_payload = {
"model": model_name,
# Reference the file by its original name => the uploaded blob's digest
"files": {file.filename: f"sha256:{file_hash}"},
}
log.info(f"Model Payload: {create_payload}") # DEBUG
# Call ollama /api/create
# https://github.com/ollama/ollama/blob/main/docs/api.md#create-a-model
create_resp = requests.post(
url=f"{ollama_url}/api/create",
headers={"Content-Type": "application/json"},
data=json.dumps(create_payload),
)
if create_resp.ok:
log.info(f"API SUCCESS!") # DEBUG
done_msg = {
"done": True,
"blob": f"sha256:{file_hash}",
"name": file.filename,
"model_created": model_name,
}
yield f"data: {json.dumps(done_msg)}\n\n"
else:
raise Exception(
f"Failed to create model in Ollama. {create_resp.text}"
)
else:
raise Exception("Ollama: Could not create blob, Please try again.")
except Exception as e:
res = {"error": str(e)}
+37 -19
View File
@@ -75,9 +75,9 @@ async def cleanup_response(
await session.close()
def openai_o1_handler(payload):
def openai_o1_o3_handler(payload):
"""
Handle O1 specific parameters
Handle o1, o3 specific parameters
"""
if "max_tokens" in payload:
# Remove "max_tokens" from the payload
@@ -145,11 +145,13 @@ async def update_config(
request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS
# Remove any extra configs
config_urls = request.app.state.config.OPENAI_API_CONFIGS.keys()
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
if url not in config_urls:
request.app.state.config.OPENAI_API_CONFIGS.pop(url, None)
# Remove the API configs that are not in the API URLS
keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS))))
request.app.state.config.OPENAI_API_CONFIGS = {
key: value
for key, value in request.app.state.config.OPENAI_API_CONFIGS.items()
if key in keys
}
return {
"ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API,
@@ -264,14 +266,21 @@ async def get_all_models_responses(request: Request) -> list:
request_tasks = []
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
if url not in request.app.state.config.OPENAI_API_CONFIGS:
if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) and (
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
):
request_tasks.append(
send_get_request(
f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx]
)
)
else:
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
)
enable = api_config.get("enable", True)
model_ids = api_config.get("model_ids", [])
@@ -310,7 +319,12 @@ async def get_all_models_responses(request: Request) -> list:
for idx, response in enumerate(responses):
if response:
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {})
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
url, {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
@@ -475,7 +489,7 @@ async def get_models(
raise HTTPException(status_code=500, detail=error_detail)
if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL:
models["data"] = get_filtered_models(models, user)
models["data"] = await get_filtered_models(models, user)
return models
@@ -537,9 +551,9 @@ async def generate_chat_completion(
bypass_filter = True
idx = 0
payload = {**form_data}
if "metadata" in payload:
del payload["metadata"]
metadata = payload.pop("metadata", None)
model_id = form_data.get("model")
model_info = Models.get_model_by_id(model_id)
@@ -552,7 +566,7 @@ async def generate_chat_completion(
params = model_info.params.model_dump()
payload = apply_model_params_to_body_openai(params, payload)
payload = apply_model_system_prompt_to_body(params, payload, user)
payload = apply_model_system_prompt_to_body(params, payload, metadata, user)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
@@ -573,6 +587,7 @@ async def generate_chat_completion(
detail="Model not found",
)
await get_all_models(request)
model = request.app.state.OPENAI_MODELS.get(model_id)
if model:
idx = model["urlIdx"]
@@ -584,7 +599,10 @@ async def generate_chat_completion(
# Get the API config for the model
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
str(idx),
request.app.state.config.OPENAI_API_CONFIGS.get(
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
), # Legacy support
)
prefix_id = api_config.get("prefix_id", None)
@@ -603,10 +621,10 @@ async def generate_chat_completion(
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
key = request.app.state.config.OPENAI_API_KEYS[idx]
# Fix: O1 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens"
is_o1 = payload["model"].lower().startswith("o1-")
if is_o1:
payload = openai_o1_handler(payload)
# Fix: o1,o3 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens"
is_o1_o3 = payload["model"].lower().startswith(("o1", "o3-"))
if is_o1_o3:
payload = openai_o1_o3_handler(payload)
elif "api.openai.com" not in url:
# Remove "max_completion_tokens" from the payload for backward compatibility
if "max_completion_tokens" in payload:
+60 -52
View File
@@ -9,6 +9,7 @@ from fastapi import (
status,
APIRouter,
)
import aiohttp
import os
import logging
import shutil
@@ -56,96 +57,103 @@ def get_sorted_filters(model_id, models):
return sorted_filters
def process_pipeline_inlet_filter(request, payload, user, models):
async def process_pipeline_inlet_filter(request, payload, user, models):
user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role}
model_id = payload["model"]
sorted_filters = get_sorted_filters(model_id, models)
model = models[model_id]
if "pipeline" in model:
sorted_filters.append(model)
for filter in sorted_filters:
r = None
try:
urlIdx = filter["urlIdx"]
async with aiohttp.ClientSession() as session:
for filter in sorted_filters:
urlIdx = filter.get("urlIdx")
if urlIdx is None:
continue
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
if key == "":
if not key:
continue
headers = {"Authorization": f"Bearer {key}"}
r = requests.post(
f"{url}/{filter['id']}/filter/inlet",
headers=headers,
json={
"user": user,
"body": payload,
},
)
request_data = {
"user": user,
"body": payload,
}
r.raise_for_status()
payload = r.json()
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
if r is not None:
res = r.json()
try:
async with session.post(
f"{url}/{filter['id']}/filter/inlet",
headers=headers,
json=request_data,
) as response:
response.raise_for_status()
payload = await response.json()
except aiohttp.ClientResponseError as e:
res = (
await response.json()
if response.content_type == "application/json"
else {}
)
if "detail" in res:
raise Exception(r.status_code, res["detail"])
raise Exception(response.status, res["detail"])
except Exception as e:
print(f"Connection error: {e}")
return payload
def process_pipeline_outlet_filter(request, payload, user, models):
async def process_pipeline_outlet_filter(request, payload, user, models):
user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role}
model_id = payload["model"]
sorted_filters = get_sorted_filters(model_id, models)
model = models[model_id]
if "pipeline" in model:
sorted_filters = [model] + sorted_filters
for filter in sorted_filters:
r = None
try:
urlIdx = filter["urlIdx"]
async with aiohttp.ClientSession() as session:
for filter in sorted_filters:
urlIdx = filter.get("urlIdx")
if urlIdx is None:
continue
url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx]
key = request.app.state.config.OPENAI_API_KEYS[urlIdx]
if key != "":
r = requests.post(
if not key:
continue
headers = {"Authorization": f"Bearer {key}"}
request_data = {
"user": user,
"body": payload,
}
try:
async with session.post(
f"{url}/{filter['id']}/filter/outlet",
headers={"Authorization": f"Bearer {key}"},
json={
"user": user,
"body": payload,
},
)
r.raise_for_status()
data = r.json()
payload = data
except Exception as e:
# Handle connection error here
print(f"Connection error: {e}")
if r is not None:
headers=headers,
json=request_data,
) as response:
response.raise_for_status()
payload = await response.json()
except aiohttp.ClientResponseError as e:
try:
res = r.json()
res = (
await response.json()
if "application/json" in response.content_type
else {}
)
if "detail" in res:
return Exception(r.status_code, res)
raise Exception(response.status, res)
except Exception:
pass
else:
pass
except Exception as e:
print(f"Connection error: {e}")
return payload
+11 -2
View File
@@ -112,7 +112,12 @@ async def update_prompt_by_command(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if prompt.user_id != user.id and user.role != "admin":
# Is the user the original creator, in a group with write access, or an admin
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
@@ -142,7 +147,11 @@ async def delete_prompt_by_command(command: str, user=Depends(get_verified_user)
detail=ERROR_MESSAGES.NOT_FOUND,
)
if prompt.user_id != user.id and user.role != "admin":
if (
prompt.user_id != user.id
and not has_access(user.id, "write", prompt.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+147 -23
View File
@@ -21,6 +21,7 @@ from fastapi import (
APIRouter,
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
import tiktoken
@@ -45,16 +46,19 @@ from open_webui.retrieval.web.utils import get_web_loader
from open_webui.retrieval.web.brave import search_brave
from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.mojeek import search_mojeek
from open_webui.retrieval.web.bocha import search_bocha
from open_webui.retrieval.web.duckduckgo import search_duckduckgo
from open_webui.retrieval.web.google_pse import search_google_pse
from open_webui.retrieval.web.jina_search import search_jina
from open_webui.retrieval.web.searchapi import search_searchapi
from open_webui.retrieval.web.serpapi import search_serpapi
from open_webui.retrieval.web.searxng import search_searxng
from open_webui.retrieval.web.serper import search_serper
from open_webui.retrieval.web.serply import search_serply
from open_webui.retrieval.web.serpstack import search_serpstack
from open_webui.retrieval.web.tavily import search_tavily
from open_webui.retrieval.web.bing import search_bing
from open_webui.retrieval.web.exa import search_exa
from open_webui.retrieval.utils import (
@@ -367,7 +371,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"proxy_url": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
},
"web": {
"web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"RAG_WEB_SEARCH_FULL_CONTEXT": request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT,
"search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
@@ -378,18 +383,23 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"brave_search_api_key": request.app.state.config.BRAVE_SEARCH_API_KEY,
"kagi_search_api_key": request.app.state.config.KAGI_SEARCH_API_KEY,
"mojeek_search_api_key": request.app.state.config.MOJEEK_SEARCH_API_KEY,
"bocha_search_api_key": request.app.state.config.BOCHA_SEARCH_API_KEY,
"serpstack_api_key": request.app.state.config.SERPSTACK_API_KEY,
"serpstack_https": request.app.state.config.SERPSTACK_HTTPS,
"serper_api_key": request.app.state.config.SERPER_API_KEY,
"serply_api_key": request.app.state.config.SERPLY_API_KEY,
"tavily_api_key": request.app.state.config.TAVILY_API_KEY,
"searchapi_api_key": request.app.state.config.SEARCHAPI_API_KEY,
"seaarchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
"searchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
"serpapi_api_key": request.app.state.config.SERPAPI_API_KEY,
"serpapi_engine": request.app.state.config.SERPAPI_ENGINE,
"jina_api_key": request.app.state.config.JINA_API_KEY,
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"exa_api_key": request.app.state.config.EXA_API_KEY,
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
"concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
"domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
},
},
}
@@ -426,6 +436,7 @@ class WebSearchConfig(BaseModel):
brave_search_api_key: Optional[str] = None
kagi_search_api_key: Optional[str] = None
mojeek_search_api_key: Optional[str] = None
bocha_search_api_key: Optional[str] = None
serpstack_api_key: Optional[str] = None
serpstack_https: Optional[bool] = None
serper_api_key: Optional[str] = None
@@ -433,16 +444,22 @@ class WebSearchConfig(BaseModel):
tavily_api_key: Optional[str] = None
searchapi_api_key: Optional[str] = None
searchapi_engine: Optional[str] = None
serpapi_api_key: Optional[str] = None
serpapi_engine: Optional[str] = None
jina_api_key: Optional[str] = None
bing_search_v7_endpoint: Optional[str] = None
bing_search_v7_subscription_key: Optional[str] = None
exa_api_key: Optional[str] = None
result_count: Optional[int] = None
concurrent_requests: Optional[int] = None
trust_env: Optional[bool] = None
domain_filter_list: Optional[List[str]] = []
class WebConfig(BaseModel):
search: WebSearchConfig
web_loader_ssl_verification: Optional[bool] = None
ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
RAG_WEB_SEARCH_FULL_CONTEXT: Optional[bool] = None
class ConfigUpdateForm(BaseModel):
@@ -497,11 +514,16 @@ async def update_rag_config(
if form_data.web is not None:
request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = (
# Note: When UI "Bypass SSL verification for Websites"=True then ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION=False
form_data.web.web_loader_ssl_verification
form_data.web.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION
)
request.app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled
request.app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine
request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT = (
form_data.web.RAG_WEB_SEARCH_FULL_CONTEXT
)
request.app.state.config.SEARXNG_QUERY_URL = (
form_data.web.search.searxng_query_url
)
@@ -520,6 +542,9 @@ async def update_rag_config(
request.app.state.config.MOJEEK_SEARCH_API_KEY = (
form_data.web.search.mojeek_search_api_key
)
request.app.state.config.BOCHA_SEARCH_API_KEY = (
form_data.web.search.bocha_search_api_key
)
request.app.state.config.SERPSTACK_API_KEY = (
form_data.web.search.serpstack_api_key
)
@@ -534,6 +559,9 @@ async def update_rag_config(
form_data.web.search.searchapi_engine
)
request.app.state.config.SERPAPI_API_KEY = form_data.web.search.serpapi_api_key
request.app.state.config.SERPAPI_ENGINE = form_data.web.search.serpapi_engine
request.app.state.config.JINA_API_KEY = form_data.web.search.jina_api_key
request.app.state.config.BING_SEARCH_V7_ENDPOINT = (
form_data.web.search.bing_search_v7_endpoint
@@ -542,12 +570,20 @@ async def update_rag_config(
form_data.web.search.bing_search_v7_subscription_key
)
request.app.state.config.EXA_API_KEY = form_data.web.search.exa_api_key
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = (
form_data.web.search.result_count
)
request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = (
form_data.web.search.concurrent_requests
)
request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV = (
form_data.web.search.trust_env
)
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = (
form_data.web.search.domain_filter_list
)
return {
"status": True,
@@ -571,7 +607,8 @@ async def update_rag_config(
"translation": request.app.state.YOUTUBE_LOADER_TRANSLATION,
},
"web": {
"web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
"RAG_WEB_SEARCH_FULL_CONTEXT": request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT,
"search": {
"enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
"engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
@@ -581,18 +618,24 @@ async def update_rag_config(
"brave_search_api_key": request.app.state.config.BRAVE_SEARCH_API_KEY,
"kagi_search_api_key": request.app.state.config.KAGI_SEARCH_API_KEY,
"mojeek_search_api_key": request.app.state.config.MOJEEK_SEARCH_API_KEY,
"bocha_search_api_key": request.app.state.config.BOCHA_SEARCH_API_KEY,
"serpstack_api_key": request.app.state.config.SERPSTACK_API_KEY,
"serpstack_https": request.app.state.config.SERPSTACK_HTTPS,
"serper_api_key": request.app.state.config.SERPER_API_KEY,
"serply_api_key": request.app.state.config.SERPLY_API_KEY,
"serachapi_api_key": request.app.state.config.SEARCHAPI_API_KEY,
"searchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
"serpapi_api_key": request.app.state.config.SERPAPI_API_KEY,
"serpapi_engine": request.app.state.config.SERPAPI_ENGINE,
"tavily_api_key": request.app.state.config.TAVILY_API_KEY,
"jina_api_key": request.app.state.config.JINA_API_KEY,
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
"exa_api_key": request.app.state.config.EXA_API_KEY,
"result_count": request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
"concurrent_requests": request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
"trust_env": request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
"domain_filter_list": request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
},
},
}
@@ -660,6 +703,7 @@ def save_docs_to_vector_db(
overwrite: bool = False,
split: bool = True,
add: bool = False,
user=None,
) -> bool:
def _get_docs_info(docs: list[Document]) -> str:
docs_info = set()
@@ -740,7 +784,11 @@ def save_docs_to_vector_db(
# for meta-data so convert them to string.
for metadata in metadatas:
for key, value in metadata.items():
if isinstance(value, datetime):
if (
isinstance(value, datetime)
or isinstance(value, list)
or isinstance(value, dict)
):
metadata[key] = str(value)
try:
@@ -775,7 +823,7 @@ def save_docs_to_vector_db(
)
embeddings = embedding_function(
list(map(lambda x: x.replace("\n", " "), texts))
list(map(lambda x: x.replace("\n", " "), texts)), user=user
)
items = [
@@ -933,6 +981,7 @@ def process_file(
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
if result:
@@ -990,7 +1039,7 @@ def process_text(
text_content = form_data.content
log.debug(f"text_content: {text_content}")
result = save_docs_to_vector_db(request, docs, collection_name)
result = save_docs_to_vector_db(request, docs, collection_name, user=user)
if result:
return {
"status": True,
@@ -1023,7 +1072,9 @@ def process_youtube_video(
content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {content}")
save_docs_to_vector_db(request, docs, collection_name, overwrite=True)
save_docs_to_vector_db(
request, docs, collection_name, overwrite=True, user=user
)
return {
"status": True,
@@ -1064,7 +1115,9 @@ def process_web(
content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {content}")
save_docs_to_vector_db(request, docs, collection_name, overwrite=True)
save_docs_to_vector_db(
request, docs, collection_name, overwrite=True, user=user
)
return {
"status": True,
@@ -1095,11 +1148,14 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
- BRAVE_SEARCH_API_KEY
- KAGI_SEARCH_API_KEY
- MOJEEK_SEARCH_API_KEY
- BOCHA_SEARCH_API_KEY
- SERPSTACK_API_KEY
- SERPER_API_KEY
- SERPLY_API_KEY
- TAVILY_API_KEY
- EXA_API_KEY
- SEARCHAPI_API_KEY + SEARCHAPI_ENGINE (by default `google`)
- SERPAPI_API_KEY + SERPAPI_ENGINE (by default `google`)
Args:
query (str): The query to search for
"""
@@ -1161,6 +1217,16 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
)
else:
raise Exception("No MOJEEK_SEARCH_API_KEY found in environment variables")
elif engine == "bocha":
if request.app.state.config.BOCHA_SEARCH_API_KEY:
return search_bocha(
request.app.state.config.BOCHA_SEARCH_API_KEY,
query,
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No BOCHA_SEARCH_API_KEY found in environment variables")
elif engine == "serpstack":
if request.app.state.config.SERPSTACK_API_KEY:
return search_serpstack(
@@ -1204,6 +1270,7 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.TAVILY_API_KEY,
query,
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No TAVILY_API_KEY found in environment variables")
@@ -1218,6 +1285,17 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
)
else:
raise Exception("No SEARCHAPI_API_KEY found in environment variables")
elif engine == "serpapi":
if request.app.state.config.SERPAPI_API_KEY:
return search_serpapi(
request.app.state.config.SERPAPI_API_KEY,
request.app.state.config.SERPAPI_ENGINE,
query,
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No SERPAPI_API_KEY found in environment variables")
elif engine == "jina":
return search_jina(
request.app.state.config.JINA_API_KEY,
@@ -1233,12 +1311,19 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
elif engine == "exa":
return search_exa(
request.app.state.config.EXA_API_KEY,
query,
request.app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
request.app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No search engine API key found in environment variables")
@router.post("/process/web/search")
def process_web_search(
async def process_web_search(
request: Request, form_data: SearchForm, user=Depends(get_verified_user)
):
try:
@@ -1270,15 +1355,39 @@ def process_web_search(
urls,
verify_ssl=request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
requests_per_second=request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
trust_env=request.app.state.config.RAG_WEB_SEARCH_TRUST_ENV,
)
docs = loader.load()
save_docs_to_vector_db(request, docs, collection_name, overwrite=True)
docs = await loader.aload()
return {
"status": True,
"collection_name": collection_name,
"filenames": urls,
}
if request.app.state.config.RAG_WEB_SEARCH_FULL_CONTEXT:
return {
"status": True,
"docs": [
{
"content": doc.page_content,
"metadata": doc.metadata,
}
for doc in docs
],
"filenames": urls,
"loaded_count": len(docs),
}
else:
await run_in_threadpool(
save_docs_to_vector_db,
request,
docs,
collection_name,
overwrite=True,
user=user,
)
return {
"status": True,
"collection_name": collection_name,
"filenames": urls,
"loaded_count": len(docs),
}
except Exception as e:
log.exception(e)
raise HTTPException(
@@ -1306,7 +1415,9 @@ def query_doc_handler(
return query_doc_with_hybrid_search(
collection_name=form_data.collection_name,
query=form_data.query,
embedding_function=request.app.state.EMBEDDING_FUNCTION,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
r=(
@@ -1314,12 +1425,16 @@ def query_doc_handler(
if form_data.r
else request.app.state.config.RELEVANCE_THRESHOLD
),
user=user,
)
else:
return query_doc(
collection_name=form_data.collection_name,
query_embedding=request.app.state.EMBEDDING_FUNCTION(form_data.query),
query_embedding=request.app.state.EMBEDDING_FUNCTION(
form_data.query, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
user=user,
)
except Exception as e:
log.exception(e)
@@ -1348,7 +1463,9 @@ def query_collection_handler(
return query_collection_with_hybrid_search(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=request.app.state.EMBEDDING_FUNCTION,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
reranking_function=request.app.state.rf,
r=(
@@ -1361,7 +1478,9 @@ def query_collection_handler(
return query_collection(
collection_names=form_data.collection_names,
queries=[form_data.query],
embedding_function=request.app.state.EMBEDDING_FUNCTION,
embedding_function=lambda query: request.app.state.EMBEDDING_FUNCTION(
query, user=user
),
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
)
@@ -1458,6 +1577,7 @@ class BatchProcessFilesResponse(BaseModel):
@router.post("/process/files/batch")
def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
) -> BatchProcessFilesResponse:
@@ -1504,7 +1624,11 @@ def process_files_batch(
if all_docs:
try:
save_docs_to_vector_db(
docs=all_docs, collection_name=collection_name, add=True
request=request,
docs=all_docs,
collection_name=collection_name,
add=True,
user=user,
)
# Update all files with collection name
+151 -13
View File
@@ -4,11 +4,13 @@ from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel
from typing import Optional
import logging
import re
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.task import (
title_generation_template,
query_generation_template,
image_prompt_generation_template,
autocomplete_generation_template,
tags_generation_template,
emoji_generation_template,
@@ -23,6 +25,7 @@ from open_webui.utils.task import get_task_model_id
from open_webui.config import (
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE,
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE,
DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE,
@@ -50,10 +53,12 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
"TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
"ENABLE_TAGS_GENERATION": request.app.state.config.ENABLE_TAGS_GENERATION,
"ENABLE_TITLE_GENERATION": request.app.state.config.ENABLE_TITLE_GENERATION,
"ENABLE_SEARCH_QUERY_GENERATION": request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION,
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
@@ -64,7 +69,9 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
class TaskConfigForm(BaseModel):
TASK_MODEL: Optional[str]
TASK_MODEL_EXTERNAL: Optional[str]
ENABLE_TITLE_GENERATION: bool
TITLE_GENERATION_PROMPT_TEMPLATE: str
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str
ENABLE_AUTOCOMPLETE_GENERATION: bool
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
TAGS_GENERATION_PROMPT_TEMPLATE: str
@@ -81,10 +88,15 @@ async def update_task_config(
):
request.app.state.config.TASK_MODEL = form_data.TASK_MODEL
request.app.state.config.TASK_MODEL_EXTERNAL = form_data.TASK_MODEL_EXTERNAL
request.app.state.config.ENABLE_TITLE_GENERATION = form_data.ENABLE_TITLE_GENERATION
request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = (
form_data.TITLE_GENERATION_PROMPT_TEMPLATE
)
request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
)
request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = (
form_data.ENABLE_AUTOCOMPLETE_GENERATION
)
@@ -113,7 +125,9 @@ async def update_task_config(
return {
"TASK_MODEL": request.app.state.config.TASK_MODEL,
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
"ENABLE_TITLE_GENERATION": request.app.state.config.ENABLE_TITLE_GENERATION,
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
@@ -129,7 +143,19 @@ async def update_task_config(
async def generate_title(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
models = request.app.state.MODELS
if not request.app.state.config.ENABLE_TITLE_GENERATION:
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"detail": "Title generation is disabled"},
)
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
@@ -156,9 +182,20 @@ async def generate_title(
else:
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
messages = form_data["messages"]
# Remove reasoning details from the messages
for message in messages:
message["content"] = re.sub(
r"<details\s+type=\"reasoning\"[^>]*>.*?<\/details>",
"",
message["content"],
flags=re.S,
).strip()
content = title_generation_template(
template,
form_data["messages"],
messages,
{
"name": user.name,
"location": user.info.get("location") if user.info else None,
@@ -170,13 +207,14 @@ async def generate_title(
"messages": [{"role": "user", "content": content}],
"stream": False,
**(
{"max_tokens": 50}
if models[task_model_id]["owned_by"] == "ollama"
{"max_tokens": 1000}
if models[task_model_id].get("owned_by") == "ollama"
else {
"max_completion_tokens": 50,
"max_completion_tokens": 1000,
}
),
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.TITLE_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
@@ -204,7 +242,12 @@ async def generate_chat_tags(
content={"detail": "Tags generation is disabled"},
)
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
@@ -240,6 +283,7 @@ async def generate_chat_tags(
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.TAGS_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
@@ -256,6 +300,72 @@ async def generate_chat_tags(
)
@router.post("/image_prompt/completions")
async def generate_image_prompt(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Model not found",
)
# Check if the user has a custom task model
# If the user has a custom task model, use that model
task_model_id = get_task_model_id(
model_id,
request.app.state.config.TASK_MODEL,
request.app.state.config.TASK_MODEL_EXTERNAL,
models,
)
log.debug(
f"generating image prompt using model {task_model_id} for user {user.email} "
)
if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != "":
template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
else:
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
content = image_prompt_generation_template(
template,
form_data["messages"],
user={
"name": user.name,
},
)
payload = {
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.IMAGE_PROMPT_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
},
}
try:
return await generate_chat_completion(request, form_data=payload, user=user)
except Exception as e:
log.error("Exception occurred", exc_info=True)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": "An internal error has occurred."},
)
@router.post("/queries/completions")
async def generate_queries(
request: Request, form_data: dict, user=Depends(get_verified_user)
@@ -275,7 +385,12 @@ async def generate_queries(
detail=f"Query generation is disabled",
)
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
@@ -311,6 +426,7 @@ async def generate_queries(
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.QUERY_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
@@ -350,7 +466,12 @@ async def generate_autocompletion(
detail=f"Input prompt exceeds maximum length of {request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}",
)
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
@@ -386,6 +507,7 @@ async def generate_autocompletion(
"messages": [{"role": "user", "content": content}],
"stream": False,
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.AUTOCOMPLETE_GENERATION),
"task_body": form_data,
"chat_id": form_data.get("chat_id", None),
@@ -407,7 +529,12 @@ async def generate_emoji(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
@@ -444,13 +571,17 @@ async def generate_emoji(
"stream": False,
**(
{"max_tokens": 4}
if models[task_model_id]["owned_by"] == "ollama"
if models[task_model_id].get("owned_by") == "ollama"
else {
"max_completion_tokens": 4,
}
),
"chat_id": form_data.get("chat_id", None),
"metadata": {"task": str(TASKS.EMOJI_GENERATION), "task_body": form_data},
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"task": str(TASKS.EMOJI_GENERATION),
"task_body": form_data,
},
}
try:
@@ -467,7 +598,13 @@ async def generate_moa_response(
request: Request, form_data: dict, user=Depends(get_verified_user)
):
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
@@ -499,8 +636,9 @@ async def generate_moa_response(
"model": task_model_id,
"messages": [{"role": "user", "content": content}],
"stream": form_data.get("stream", False),
"chat_id": form_data.get("chat_id", None),
"metadata": {
**(request.state.metadata if hasattr(request.state, "metadata") else {}),
"chat_id": form_data.get("chat_id", None),
"task": str(TASKS.MOA_RESPONSE_GENERATION),
"task_body": form_data,
},
+23 -3
View File
@@ -70,7 +70,7 @@ async def create_new_tools(
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS
user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@@ -165,7 +165,12 @@ async def update_tools_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if tools.user_id != user.id and user.role != "admin":
# Is the user the original creator, in a group with write access, or an admin
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
@@ -222,7 +227,11 @@ async def delete_tools_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if tools.user_id != user.id and user.role != "admin":
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
@@ -304,6 +313,17 @@ async def update_tools_valves_by_id(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if (
tools.user_id != user.id
and not has_access(user.id, "write", tools.access_control)
and user.role != "admin"
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
if id in request.app.state.TOOLS:
tools_module = request.app.state.TOOLS[id]
else:
+29 -11
View File
@@ -62,27 +62,45 @@ async def get_user_permissisions(user=Depends(get_verified_user)):
# User Default Permissions
############################
class WorkspacePermissions(BaseModel):
models: bool
knowledge: bool
prompts: bool
tools: bool
models: bool = False
knowledge: bool = False
prompts: bool = False
tools: bool = False
class ChatPermissions(BaseModel):
file_upload: bool
delete: bool
edit: bool
temporary: bool
controls: bool = True
file_upload: bool = True
delete: bool = True
edit: bool = True
temporary: bool = True
class FeaturesPermissions(BaseModel):
web_search: bool = True
image_generation: bool = True
code_interpreter: bool = True
class UserPermissions(BaseModel):
workspace: WorkspacePermissions
chat: ChatPermissions
features: FeaturesPermissions
@router.get("/default/permissions")
@router.get("/default/permissions", response_model=UserPermissions)
async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
return request.app.state.config.USER_PERMISSIONS
return {
"workspace": WorkspacePermissions(
**request.app.state.config.USER_PERMISSIONS.get("workspace", {})
),
"chat": ChatPermissions(
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
),
"features": FeaturesPermissions(
**request.app.state.config.USER_PERMISSIONS.get("features", {})
),
}
@router.post("/default/permissions")
@@ -135,7 +153,7 @@ async def get_user_settings_by_session_user(user=Depends(get_verified_user)):
async def update_user_settings_by_session_user(
form_data: UserSettings, user=Depends(get_verified_user)
):
user = Users.update_user_by_id(user.id, {"settings": form_data.model_dump()})
user = Users.update_user_settings_by_id(user.id, form_data.model_dump())
if user:
return user.settings
else:
+41 -11
View File
@@ -4,45 +4,75 @@ import markdown
from open_webui.models.chats import ChatTitleMessagesForm
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from pydantic import BaseModel
from starlette.responses import FileResponse
from open_webui.utils.misc import get_gravatar_url
from open_webui.utils.pdf_generator import PDFGenerator
from open_webui.utils.auth import get_admin_user
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.code_interpreter import execute_code_jupyter
router = APIRouter()
@router.get("/gravatar")
async def get_gravatar(
email: str,
):
async def get_gravatar(email: str, user=Depends(get_verified_user)):
return get_gravatar_url(email)
class CodeFormatRequest(BaseModel):
class CodeForm(BaseModel):
code: str
@router.post("/code/format")
async def format_code(request: CodeFormatRequest):
async def format_code(form_data: CodeForm, user=Depends(get_verified_user)):
try:
formatted_code = black.format_str(request.code, mode=black.Mode())
formatted_code = black.format_str(form_data.code, mode=black.Mode())
return {"code": formatted_code}
except black.NothingChanged:
return {"code": request.code}
return {"code": form_data.code}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/code/execute")
async def execute_code(
request: Request, form_data: CodeForm, user=Depends(get_verified_user)
):
if request.app.state.config.CODE_EXECUTION_ENGINE == "jupyter":
output = await execute_code_jupyter(
request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
form_data.code,
(
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "token"
else None
),
(
request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD
if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "password"
else None
),
)
return output
else:
raise HTTPException(
status_code=400,
detail="Code execution engine not supported",
)
class MarkdownForm(BaseModel):
md: str
@router.post("/markdown")
async def get_html_from_markdown(
form_data: MarkdownForm,
form_data: MarkdownForm, user=Depends(get_verified_user)
):
return {"html": markdown.markdown(form_data.md)}
@@ -54,7 +84,7 @@ class ChatForm(BaseModel):
@router.post("/pdf")
async def download_chat_as_pdf(
form_data: ChatTitleMessagesForm,
form_data: ChatTitleMessagesForm, user=Depends(get_verified_user)
):
try:
pdf_bytes = PDFGenerator(form_data).generate_chat_pdf()
+38 -6
View File
@@ -237,6 +237,7 @@ async def channel_events(sid, data):
"channel-events",
{
"channel_id": data["channel_id"],
"message_id": data.get("message_id", None),
"data": event_data,
"user": UserNameResponse(**SESSION_POOL[sid]).model_dump(),
},
@@ -278,8 +279,8 @@ def get_event_emitter(request_info):
await sio.emit(
"chat-events",
{
"chat_id": request_info["chat_id"],
"message_id": request_info["message_id"],
"chat_id": request_info.get("chat_id", None),
"message_id": request_info.get("message_id", None),
"data": event_data,
},
to=session_id,
@@ -292,23 +293,54 @@ def get_event_emitter(request_info):
event_data.get("data", {}),
)
if "type" in event_data and event_data["type"] == "message":
message = Chats.get_message_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
)
content = message.get("content", "")
content += event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
if "type" in event_data and event_data["type"] == "replace":
content = event_data.get("data", {}).get("content", "")
Chats.upsert_message_to_chat_by_id_and_message_id(
request_info["chat_id"],
request_info["message_id"],
{
"content": content,
},
)
return __event_emitter__
def get_event_call(request_info):
async def __event_call__(event_data):
async def __event_caller__(event_data):
response = await sio.call(
"chat-events",
{
"chat_id": request_info["chat_id"],
"message_id": request_info["message_id"],
"chat_id": request_info.get("chat_id", None),
"message_id": request_info.get("message_id", None),
"data": event_data,
},
to=request_info["session_id"],
)
return response
return __event_call__
return __event_caller__
get_event_caller = get_event_call
def get_user_id_from_session_pool(sid):
+1 -1
View File
@@ -26,7 +26,7 @@ class RedisLock:
def release_lock(self):
lock_value = self.redis.get(self.lock_name)
if lock_value and lock_value.decode("utf-8") == self.lock_id:
if lock_value and lock_value == self.lock_id:
self.redis.delete(self.lock_name)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -9308,5 +9308,3 @@
.json-schema-2020-12__title:first-of-type {
font-size: 16px;
}
/*# sourceMappingURL=swagger-ui.css.map*/
+182 -113
View File
@@ -1,121 +1,74 @@
import os
import shutil
import json
from abc import ABC, abstractmethod
from typing import BinaryIO, Tuple
import boto3
from botocore.exceptions import ClientError
import shutil
from typing import BinaryIO, Tuple, Optional, Union
from open_webui.constants import ERROR_MESSAGES
from open_webui.config import (
STORAGE_PROVIDER,
S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY,
S3_BUCKET_NAME,
S3_REGION_NAME,
S3_ENDPOINT_URL,
S3_KEY_PREFIX,
S3_REGION_NAME,
S3_SECRET_ACCESS_KEY,
GCS_BUCKET_NAME,
GOOGLE_APPLICATION_CREDENTIALS_JSON,
STORAGE_PROVIDER,
UPLOAD_DIR,
)
from google.cloud import storage
from google.cloud.exceptions import GoogleCloudError, NotFound
from open_webui.constants import ERROR_MESSAGES
import boto3
from botocore.exceptions import ClientError
from typing import BinaryIO, Tuple, Optional
class StorageProvider(ABC):
@abstractmethod
def get_file(self, file_path: str) -> str:
pass
@abstractmethod
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
pass
@abstractmethod
def delete_all_files(self) -> None:
pass
@abstractmethod
def delete_file(self, file_path: str) -> None:
pass
class StorageProvider:
def __init__(self, provider: Optional[str] = None):
self.storage_provider: str = provider or STORAGE_PROVIDER
self.s3_client = None
self.s3_bucket_name: Optional[str] = None
if self.storage_provider == "s3":
self._initialize_s3()
def _initialize_s3(self) -> None:
"""Initializes the S3 client and bucket name if using S3 storage."""
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
)
self.bucket_name = S3_BUCKET_NAME
def _upload_to_s3(self, file_path: str, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
self.s3_client.upload_file(file_path, self.bucket_name, filename)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + filename,
)
except ClientError as e:
raise RuntimeError(f"Error uploading file to S3: {e}")
def _upload_to_local(self, contents: bytes, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to local storage."""
class LocalStorageProvider(StorageProvider):
@staticmethod
def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]:
contents = file.read()
if not contents:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
file_path = f"{UPLOAD_DIR}/{filename}"
with open(file_path, "wb") as f:
f.write(contents)
return contents, file_path
def _get_file_from_s3(self, file_path: str) -> str:
"""Handles downloading of the file from S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
bucket_name, key = file_path.split("//")[1].split("/")
local_file_path = f"{UPLOAD_DIR}/{key}"
self.s3_client.download_file(bucket_name, key, local_file_path)
return local_file_path
except ClientError as e:
raise RuntimeError(f"Error downloading file from S3: {e}")
def _get_file_from_local(self, file_path: str) -> str:
@staticmethod
def get_file(file_path: str) -> str:
"""Handles downloading of the file from local storage."""
return file_path
def _delete_from_s3(self, filename: str) -> None:
"""Handles deletion of the file from S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
except ClientError as e:
raise RuntimeError(f"Error deleting file from S3: {e}")
def _delete_from_local(self, filename: str) -> None:
@staticmethod
def delete_file(file_path: str) -> None:
"""Handles deletion of the file from local storage."""
filename = file_path.split("/")[-1]
file_path = f"{UPLOAD_DIR}/{filename}"
if os.path.isfile(file_path):
os.remove(file_path)
else:
print(f"File {file_path} not found in local storage.")
def _delete_all_from_s3(self) -> None:
"""Handles deletion of all files from S3 storage."""
if not self.s3_client:
raise RuntimeError("S3 Client is not initialized.")
try:
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
if "Contents" in response:
for content in response["Contents"]:
self.s3_client.delete_object(
Bucket=self.bucket_name, Key=content["Key"]
)
except ClientError as e:
raise RuntimeError(f"Error deleting all files from S3: {e}")
def _delete_all_from_local(self) -> None:
@staticmethod
def delete_all_files() -> None:
"""Handles deletion of all files from local storage."""
if os.path.exists(UPLOAD_DIR):
for filename in os.listdir(UPLOAD_DIR):
@@ -130,38 +83,154 @@ class StorageProvider:
else:
print(f"Directory {UPLOAD_DIR} not found in local storage.")
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Uploads a file either to S3 or the local file system."""
contents = file.read()
if not contents:
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
contents, file_path = self._upload_to_local(contents, filename)
if self.storage_provider == "s3":
return self._upload_to_s3(file_path, filename)
return contents, file_path
class S3StorageProvider(StorageProvider):
def __init__(self):
self.s3_client = boto3.client(
"s3",
region_name=S3_REGION_NAME,
endpoint_url=S3_ENDPOINT_URL,
aws_access_key_id=S3_ACCESS_KEY_ID,
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
)
self.bucket_name = S3_BUCKET_NAME
self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to S3 storage."""
_, file_path = LocalStorageProvider.upload_file(file, filename)
try:
s3_key = os.path.join(self.key_prefix, filename)
self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
return (
open(file_path, "rb").read(),
"s3://" + self.bucket_name + "/" + s3_key,
)
except ClientError as e:
raise RuntimeError(f"Error uploading file to S3: {e}")
def get_file(self, file_path: str) -> str:
"""Downloads a file either from S3 or the local file system and returns the file path."""
if self.storage_provider == "s3":
return self._get_file_from_s3(file_path)
return self._get_file_from_local(file_path)
"""Handles downloading of the file from S3 storage."""
try:
s3_key = self._extract_s3_key(file_path)
local_file_path = self._get_local_file_path(s3_key)
self.s3_client.download_file(self.bucket_name, s3_key, local_file_path)
return local_file_path
except ClientError as e:
raise RuntimeError(f"Error downloading file from S3: {e}")
def delete_file(self, filename: str) -> None:
"""Deletes a file either from S3 or the local file system."""
if self.storage_provider == "s3":
self._delete_from_s3(filename)
def delete_file(self, file_path: str) -> None:
"""Handles deletion of the file from S3 storage."""
try:
s3_key = self._extract_s3_key(file_path)
self.s3_client.delete_object(Bucket=self.bucket_name, Key=s3_key)
except ClientError as e:
raise RuntimeError(f"Error deleting file from S3: {e}")
# Always delete from local storage
self._delete_from_local(filename)
LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None:
"""Deletes all files from the storage."""
if self.storage_provider == "s3":
self._delete_all_from_s3()
"""Handles deletion of all files from S3 storage."""
try:
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
if "Contents" in response:
for content in response["Contents"]:
# Skip objects that were not uploaded from open-webui in the first place
if not content["Key"].startswith(self.key_prefix):
continue
self.s3_client.delete_object(
Bucket=self.bucket_name, Key=content["Key"]
)
except ClientError as e:
raise RuntimeError(f"Error deleting all files from S3: {e}")
# Always delete from local storage
self._delete_all_from_local()
LocalStorageProvider.delete_all_files()
# The s3 key is the name assigned to an object. It excludes the bucket name, but includes the internal path and the file name.
def _extract_s3_key(self, full_file_path: str) -> str:
return "/".join(full_file_path.split("//")[1].split("/")[1:])
def _get_local_file_path(self, s3_key: str) -> str:
return f"{UPLOAD_DIR}/{s3_key.split('/')[-1]}"
Storage = StorageProvider(provider=STORAGE_PROVIDER)
class GCSStorageProvider(StorageProvider):
def __init__(self):
self.bucket_name = GCS_BUCKET_NAME
if GOOGLE_APPLICATION_CREDENTIALS_JSON:
self.gcs_client = storage.Client.from_service_account_info(
info=json.loads(GOOGLE_APPLICATION_CREDENTIALS_JSON)
)
else:
# if no credentials json is provided, credentials will be picked up from the environment
# if running on local environment, credentials would be user credentials
# if running on a Compute Engine instance, credentials would be from Google Metadata server
self.gcs_client = storage.Client()
self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME)
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
"""Handles uploading of the file to GCS storage."""
contents, file_path = LocalStorageProvider.upload_file(file, filename)
try:
blob = self.bucket.blob(filename)
blob.upload_from_filename(file_path)
return contents, "gs://" + self.bucket_name + "/" + filename
except GoogleCloudError as e:
raise RuntimeError(f"Error uploading file to GCS: {e}")
def get_file(self, file_path: str) -> str:
"""Handles downloading of the file from GCS storage."""
try:
filename = file_path.removeprefix("gs://").split("/")[1]
local_file_path = f"{UPLOAD_DIR}/{filename}"
blob = self.bucket.get_blob(filename)
blob.download_to_filename(local_file_path)
return local_file_path
except NotFound as e:
raise RuntimeError(f"Error downloading file from GCS: {e}")
def delete_file(self, file_path: str) -> None:
"""Handles deletion of the file from GCS storage."""
try:
filename = file_path.removeprefix("gs://").split("/")[1]
blob = self.bucket.get_blob(filename)
blob.delete()
except NotFound as e:
raise RuntimeError(f"Error deleting file from GCS: {e}")
# Always delete from local storage
LocalStorageProvider.delete_file(file_path)
def delete_all_files(self) -> None:
"""Handles deletion of all files from GCS storage."""
try:
blobs = self.bucket.list_blobs()
for blob in blobs:
blob.delete()
except NotFound as e:
raise RuntimeError(f"Error deleting all files from GCS: {e}")
# Always delete from local storage
LocalStorageProvider.delete_all_files()
def get_storage_provider(storage_provider: str):
if storage_provider == "local":
Storage = LocalStorageProvider()
elif storage_provider == "s3":
Storage = S3StorageProvider()
elif storage_provider == "gcs":
Storage = GCSStorageProvider()
else:
raise RuntimeError(f"Unsupported storage provider: {storage_provider}")
return Storage
Storage = get_storage_provider(STORAGE_PROVIDER)
@@ -0,0 +1,274 @@
import io
import os
import boto3
import pytest
from botocore.exceptions import ClientError
from moto import mock_aws
from open_webui.storage import provider
from gcp_storage_emulator.server import create_server
from google.cloud import storage
def mock_upload_dir(monkeypatch, tmp_path):
"""Fixture to monkey-patch the UPLOAD_DIR and create a temporary directory."""
directory = tmp_path / "uploads"
directory.mkdir()
monkeypatch.setattr(provider, "UPLOAD_DIR", str(directory))
return directory
def test_imports():
provider.StorageProvider
provider.LocalStorageProvider
provider.S3StorageProvider
provider.GCSStorageProvider
provider.Storage
def test_get_storage_provider():
Storage = provider.get_storage_provider("local")
assert isinstance(Storage, provider.LocalStorageProvider)
Storage = provider.get_storage_provider("s3")
assert isinstance(Storage, provider.S3StorageProvider)
Storage = provider.get_storage_provider("gcs")
assert isinstance(Storage, provider.GCSStorageProvider)
with pytest.raises(RuntimeError):
provider.get_storage_provider("invalid")
def test_class_instantiation():
with pytest.raises(TypeError):
provider.StorageProvider()
with pytest.raises(TypeError):
class Test(provider.StorageProvider):
pass
Test()
provider.LocalStorageProvider()
provider.S3StorageProvider()
provider.GCSStorageProvider()
class TestLocalStorageProvider:
Storage = provider.LocalStorageProvider()
file_content = b"test content"
file_bytesio = io.BytesIO(file_content)
filename = "test.txt"
filename_extra = "test_exyta.txt"
file_bytesio_empty = io.BytesIO()
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, file_path = self.Storage.upload_file(self.file_bytesio, self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert file_path == str(upload_dir / self.filename)
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
file_path = str(upload_dir / self.filename)
file_path_return = self.Storage.get_file(file_path)
assert file_path == file_path_return
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
(upload_dir / self.filename).write_bytes(self.file_content)
assert (upload_dir / self.filename).exists()
file_path = str(upload_dir / self.filename)
self.Storage.delete_file(file_path)
assert not (upload_dir / self.filename).exists()
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
(upload_dir / self.filename).write_bytes(self.file_content)
(upload_dir / self.filename_extra).write_bytes(self.file_content)
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
@mock_aws
class TestS3StorageProvider:
def __init__(self):
self.Storage = provider.S3StorageProvider()
self.Storage.bucket_name = "my-bucket"
self.s3_client = boto3.resource("s3", region_name="us-east-1")
self.file_content = b"test content"
self.filename = "test.txt"
self.filename_extra = "test_exyta.txt"
self.file_bytesio_empty = io.BytesIO()
super().__init__()
def test_upload_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# S3 checks
with pytest.raises(Exception):
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
assert self.file_content == object.get()["Body"].read()
# local checks
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert s3_file_path == "s3://" + self.Storage.bucket_name + "/" + self.filename
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
file_path = self.Storage.get_file(s3_file_path)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
def test_delete_file(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
contents, s3_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
assert (upload_dir / self.filename).exists()
self.Storage.delete_file(s3_file_path)
assert not (upload_dir / self.filename).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
def test_delete_all_files(self, monkeypatch, tmp_path):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# create 2 files
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
assert self.file_content == object.get()["Body"].read()
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
object = self.s3_client.Object(self.Storage.bucket_name, self.filename_extra)
assert self.file_content == object.get()["Body"].read()
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
assert not (upload_dir / self.filename_extra).exists()
with pytest.raises(ClientError) as exc:
self.s3_client.Object(self.Storage.bucket_name, self.filename_extra).load()
error = exc.value.response["Error"]
assert error["Code"] == "404"
assert error["Message"] == "Not Found"
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
class TestGCSStorageProvider:
Storage = provider.GCSStorageProvider()
Storage.bucket_name = "my-bucket"
file_content = b"test content"
filename = "test.txt"
filename_extra = "test_exyta.txt"
file_bytesio_empty = io.BytesIO()
@pytest.fixture(scope="class")
def setup(self):
host, port = "localhost", 9023
server = create_server(host, port, in_memory=True)
server.start()
os.environ["STORAGE_EMULATOR_HOST"] = f"http://{host}:{port}"
gcs_client = storage.Client()
bucket = gcs_client.bucket(self.Storage.bucket_name)
bucket.create()
self.Storage.gcs_client, self.Storage.bucket = gcs_client, bucket
yield
bucket.delete(force=True)
server.stop()
def test_upload_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# catch error if bucket does not exist
with pytest.raises(Exception):
self.Storage.bucket = monkeypatch(self.Storage, "bucket", None)
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
object = self.Storage.bucket.get_blob(self.filename)
assert self.file_content == object.download_as_bytes()
# local checks
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert contents == self.file_content
assert gcs_file_path == "gs://" + self.Storage.bucket_name + "/" + self.filename
# test error if file is empty
with pytest.raises(ValueError):
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
def test_get_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
file_path = self.Storage.get_file(gcs_file_path)
assert file_path == str(upload_dir / self.filename)
assert (upload_dir / self.filename).exists()
def test_delete_file(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
contents, gcs_file_path = self.Storage.upload_file(
io.BytesIO(self.file_content), self.filename
)
# ensure that local directory has the uploaded file as well
assert (upload_dir / self.filename).exists()
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
self.Storage.delete_file(gcs_file_path)
# check that deleting file from gcs will delete the local file as well
assert not (upload_dir / self.filename).exists()
assert self.Storage.bucket.get_blob(self.filename) == None
def test_delete_all_files(self, monkeypatch, tmp_path, setup):
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
# create 2 files
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
object = self.Storage.bucket.get_blob(self.filename)
assert (upload_dir / self.filename).exists()
assert (upload_dir / self.filename).read_bytes() == self.file_content
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
assert self.file_content == object.download_as_bytes()
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
object = self.Storage.bucket.get_blob(self.filename_extra)
assert (upload_dir / self.filename_extra).exists()
assert (upload_dir / self.filename_extra).read_bytes() == self.file_content
assert (
self.Storage.bucket.get_blob(self.filename_extra).name
== self.filename_extra
)
assert self.file_content == object.download_as_bytes()
self.Storage.delete_all_files()
assert not (upload_dir / self.filename).exists()
assert not (upload_dir / self.filename_extra).exists()
assert self.Storage.bucket.get_blob(self.filename) == None
assert self.Storage.bucket.get_blob(self.filename_extra) == None
+37 -7
View File
@@ -1,9 +1,30 @@
from typing import Optional, Union, List, Dict, Any
from open_webui.models.users import Users, UserModel
from open_webui.models.groups import Groups
from open_webui.config import DEFAULT_USER_PERMISSIONS
import json
def fill_missing_permissions(
permissions: Dict[str, Any], default_permissions: Dict[str, Any]
) -> Dict[str, Any]:
"""
Recursively fills in missing properties in the permissions dictionary
using the default permissions as a template.
"""
for key, value in default_permissions.items():
if key not in permissions:
permissions[key] = value
elif isinstance(value, dict) and isinstance(
permissions[key], dict
): # Both are nested dictionaries
permissions[key] = fill_missing_permissions(permissions[key], value)
return permissions
def get_permissions(
user_id: str,
default_permissions: Dict[str, Any],
@@ -27,39 +48,45 @@ def get_permissions(
if key not in permissions:
permissions[key] = value
else:
permissions[key] = permissions[key] or value
permissions[key] = (
permissions[key] or value
) # Use the most permissive value (True > False)
return permissions
user_groups = Groups.get_groups_by_member_id(user_id)
# deep copy default permissions to avoid modifying the original dict
# Deep copy default permissions to avoid modifying the original dict
permissions = json.loads(json.dumps(default_permissions))
# Combine permissions from all user groups
for group in user_groups:
group_permissions = group.permissions
permissions = combine_permissions(permissions, group_permissions)
# Ensure all fields from default_permissions are present and filled in
permissions = fill_missing_permissions(permissions, default_permissions)
return permissions
def has_permission(
user_id: str,
permission_key: str,
default_permissions: Dict[str, bool] = {},
default_permissions: Dict[str, Any] = {},
) -> bool:
"""
Check if a user has a specific permission by checking the group permissions
and falls back to default permissions if not found in any group.
and fall back to default permissions if not found in any group.
Permission keys can be hierarchical and separated by dots ('.').
"""
def get_permission(permissions: Dict[str, bool], keys: List[str]) -> bool:
def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool:
"""Traverse permissions dict using a list of keys (from dot-split permission_key)."""
for key in keys:
if key not in permissions:
return False # If any part of the hierarchy is missing, deny access
permissions = permissions[key] # Go one level deeper
permissions = permissions[key] # Traverse one level deeper
return bool(permissions) # Return the boolean at the final level
@@ -73,7 +100,10 @@ def has_permission(
if get_permission(group_permissions, permission_hierarchy):
return True
# Check default permissions afterwards if the group permissions don't allow it
# Check default permissions afterward if the group permissions don't allow it
default_permissions = fill_missing_permissions(
default_permissions, DEFAULT_USER_PERMISSIONS
)
return get_permission(default_permissions, permission_hierarchy)
+58 -3
View File
@@ -1,6 +1,11 @@
import logging
import uuid
import jwt
import base64
import hmac
import hashlib
import requests
from datetime import UTC, datetime, timedelta
from typing import Optional, Union, List, Dict
@@ -8,7 +13,8 @@ from typing import Optional, Union, List, Dict
from open_webui.models.users import Users
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import WEBUI_SECRET_KEY
from open_webui.config import override_static
from open_webui.env import WEBUI_SECRET_KEY, TRUSTED_SIGNATURE_KEY
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -24,6 +30,53 @@ ALGORITHM = "HS256"
# Auth Utils
##############
def verify_signature(payload: str, signature: str) -> bool:
"""
Verifies the HMAC signature of the received payload.
"""
try:
expected_signature = base64.b64encode(
hmac.new(TRUSTED_SIGNATURE_KEY, payload.encode(), hashlib.sha256).digest()
).decode()
# Compare securely to prevent timing attacks
return hmac.compare_digest(expected_signature, signature)
except Exception:
return False
def get_license_data(app, key):
if key:
try:
res = requests.post(
"https://api.openwebui.com/api/v1/license",
json={"key": key, "version": "1"},
timeout=5,
)
if getattr(res, "ok", False):
payload = getattr(res, "json", lambda: {})()
for k, v in payload.items():
if k == "resources":
for p, c in v.items():
globals().get("override_static", lambda a, b: None)(p, c)
elif k == "user_count":
setattr(app.state, "USER_COUNT", v)
elif k == "webui_name":
setattr(app.state, "WEBUI_NAME", v)
return True
else:
print(
f"License: retrieval issue: {getattr(res, 'text', 'unknown error')}"
)
except Exception as ex:
print(f"License: Uncaught Exception: {ex}")
return False
bearer_security = HTTPBearer(auto_error=False)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -96,10 +149,12 @@ def get_current_user(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
)
if request.app.state.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS:
if request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS:
allowed_paths = [
path.strip()
for path in str(request.app.state.API_KEY_ALLOWED_PATHS).split(",")
for path in str(
request.app.state.config.API_KEY_ALLOWED_ENDPOINTS
).split(",")
]
if request.url.path not in allowed_paths:
+258 -194
View File
@@ -7,14 +7,17 @@ from typing import Any, Optional
import random
import json
import inspect
import uuid
import asyncio
from fastapi import Request
from starlette.responses import Response, StreamingResponse
from fastapi import Request, status
from starlette.responses import Response, StreamingResponse, JSONResponse
from open_webui.models.users import UserModel
from open_webui.socket.main import (
sio,
get_event_call,
get_event_emitter,
)
@@ -44,6 +47,10 @@ from open_webui.utils.response import (
convert_response_ollama_to_openai,
convert_streaming_response_ollama_to_openai,
)
from open_webui.utils.filter import (
get_sorted_filter_ids,
process_filter_functions,
)
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
@@ -53,114 +60,239 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
async def generate_direct_chat_completion(
request: Request,
form_data: dict,
user: Any,
models: dict,
):
print("generate_direct_chat_completion")
metadata = form_data.pop("metadata", {})
user_id = metadata.get("user_id")
session_id = metadata.get("session_id")
request_id = str(uuid.uuid4()) # Generate a unique request ID
event_caller = get_event_call(metadata)
channel = f"{user_id}:{session_id}:{request_id}"
if form_data.get("stream"):
q = asyncio.Queue()
async def message_listener(sid, data):
"""
Handle received socket messages and push them into the queue.
"""
await q.put(data)
# Register the listener
sio.on(channel, message_listener)
# Start processing chat completion in background
res = await event_caller(
{
"type": "request:chat:completion",
"data": {
"form_data": form_data,
"model": models[form_data["model"]],
"channel": channel,
"session_id": session_id,
},
}
)
print("res", res)
if res.get("status", False):
# Define a generator to stream responses
async def event_generator():
nonlocal q
try:
while True:
data = await q.get() # Wait for new messages
if isinstance(data, dict):
if "done" in data and data["done"]:
break # Stop streaming when 'done' is received
yield f"data: {json.dumps(data)}\n\n"
elif isinstance(data, str):
yield data
except Exception as e:
log.debug(f"Error in event generator: {e}")
pass
# Define a background task to run the event generator
async def background():
try:
del sio.handlers["/"][channel]
except Exception as e:
pass
# Return the streaming response
return StreamingResponse(
event_generator(), media_type="text/event-stream", background=background
)
else:
raise Exception(str(res))
else:
res = await event_caller(
{
"type": "request:chat:completion",
"data": {
"form_data": form_data,
"model": models[form_data["model"]],
"channel": channel,
"session_id": session_id,
},
}
)
if "error" in res:
raise Exception(res["error"])
return res
async def generate_chat_completion(
request: Request,
form_data: dict,
user: Any,
bypass_filter: bool = False,
):
log.debug(f"generate_chat_completion: {form_data}")
if BYPASS_MODEL_ACCESS_CONTROL:
bypass_filter = True
models = request.app.state.MODELS
if hasattr(request.state, "metadata"):
if "metadata" not in form_data:
form_data["metadata"] = request.state.metadata
else:
form_data["metadata"] = {
**form_data["metadata"],
**request.state.metadata,
}
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
log.debug(f"direct connection to model: {models}")
else:
models = request.app.state.MODELS
model_id = form_data["model"]
if model_id not in models:
raise Exception("Model not found")
# Process the form_data through the pipeline
try:
form_data = process_pipeline_inlet_filter(request, form_data, user, models)
except Exception as e:
raise e
model = models[model_id]
# Check if user has access to the model
if not bypass_filter and user.role == "user":
try:
check_model_access(user, model)
except Exception as e:
raise e
if model["owned_by"] == "arena":
model_ids = model.get("info", {}).get("meta", {}).get("model_ids")
filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode")
if model_ids and filter_mode == "exclude":
model_ids = [
model["id"]
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena" and model["id"] not in model_ids
]
selected_model_id = None
if isinstance(model_ids, list) and model_ids:
selected_model_id = random.choice(model_ids)
else:
model_ids = [
model["id"]
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena"
]
selected_model_id = random.choice(model_ids)
form_data["model"] = selected_model_id
if form_data.get("stream") == True:
async def stream_wrapper(stream):
yield f"data: {json.dumps({'selected_model_id': selected_model_id})}\n\n"
async for chunk in stream:
yield chunk
response = await generate_chat_completion(
request, form_data, user, bypass_filter=True
)
return StreamingResponse(
stream_wrapper(response.body_iterator),
media_type="text/event-stream",
background=response.background,
)
else:
return {
**(
await generate_chat_completion(
request, form_data, user, bypass_filter=True
)
),
"selected_model_id": selected_model_id,
}
if model.get("pipe"):
# Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter
return await generate_function_chat_completion(
if getattr(request.state, "direct", False):
return await generate_direct_chat_completion(
request, form_data, user=user, models=models
)
if model["owned_by"] == "ollama":
# Using /ollama/api/chat endpoint
form_data = convert_payload_openai_to_ollama(form_data)
response = await generate_ollama_chat_completion(
request=request, form_data=form_data, user=user, bypass_filter=bypass_filter
)
if form_data.get("stream"):
response.headers["content-type"] = "text/event-stream"
return StreamingResponse(
convert_streaming_response_ollama_to_openai(response),
headers=dict(response.headers),
background=response.background,
)
else:
return convert_response_ollama_to_openai(response)
else:
return await generate_openai_chat_completion(
request=request, form_data=form_data, user=user, bypass_filter=bypass_filter
)
# Check if user has access to the model
if not bypass_filter and user.role == "user":
try:
check_model_access(user, model)
except Exception as e:
raise e
if model.get("owned_by") == "arena":
model_ids = model.get("info", {}).get("meta", {}).get("model_ids")
filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode")
if model_ids and filter_mode == "exclude":
model_ids = [
model["id"]
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena" and model["id"] not in model_ids
]
selected_model_id = None
if isinstance(model_ids, list) and model_ids:
selected_model_id = random.choice(model_ids)
else:
model_ids = [
model["id"]
for model in list(request.app.state.MODELS.values())
if model.get("owned_by") != "arena"
]
selected_model_id = random.choice(model_ids)
form_data["model"] = selected_model_id
if form_data.get("stream") == True:
async def stream_wrapper(stream):
yield f"data: {json.dumps({'selected_model_id': selected_model_id})}\n\n"
async for chunk in stream:
yield chunk
response = await generate_chat_completion(
request, form_data, user, bypass_filter=True
)
return StreamingResponse(
stream_wrapper(response.body_iterator),
media_type="text/event-stream",
background=response.background,
)
else:
return {
**(
await generate_chat_completion(
request, form_data, user, bypass_filter=True
)
),
"selected_model_id": selected_model_id,
}
if model.get("pipe"):
# Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter
return await generate_function_chat_completion(
request, form_data, user=user, models=models
)
if model.get("owned_by") == "ollama":
# Using /ollama/api/chat endpoint
form_data = convert_payload_openai_to_ollama(form_data)
response = await generate_ollama_chat_completion(
request=request,
form_data=form_data,
user=user,
bypass_filter=bypass_filter,
)
if form_data.get("stream"):
response.headers["content-type"] = "text/event-stream"
return StreamingResponse(
convert_streaming_response_ollama_to_openai(response),
headers=dict(response.headers),
background=response.background,
)
else:
return convert_response_ollama_to_openai(response)
else:
return await generate_openai_chat_completion(
request=request,
form_data=form_data,
user=user,
bypass_filter=bypass_filter,
)
chat_completion = generate_chat_completion
async def chat_completed(request: Request, form_data: dict, user: Any):
if not request.app.state.MODELS:
await get_all_models(request)
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
data = form_data
model_id = data["model"]
@@ -170,118 +302,42 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
model = models[model_id]
try:
data = process_pipeline_outlet_filter(request, data, user, models)
data = await process_pipeline_outlet_filter(request, data, user, models)
except Exception as e:
return Exception(f"Error: {e}")
__event_emitter__ = get_event_emitter(
{
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
}
)
metadata = {
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
__event_call__ = get_event_call(
{
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
}
)
extra_params = {
"__event_emitter__": get_event_emitter(metadata),
"__event_call__": get_event_call(metadata),
"__user__": {
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
},
"__metadata__": metadata,
"__request__": request,
"__model__": model,
}
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
if function is not None and hasattr(function, "valves"):
# TODO: Fix FunctionModel to include vavles
return (function.valves if function.valves else {}).get("priority", 0)
return 0
filter_ids = [function.id for function in Functions.get_global_filter_functions()]
if "info" in model and "meta" in model["info"]:
filter_ids.extend(model["info"]["meta"].get("filterIds", []))
filter_ids = list(set(filter_ids))
enabled_filter_ids = [
function.id
for function in Functions.get_functions_by_type("filter", active_only=True)
]
filter_ids = [
filter_id for filter_id in filter_ids if filter_id in enabled_filter_ids
]
# Sort filter_ids by priority, using the get_priority function
filter_ids.sort(key=get_priority)
for filter_id in filter_ids:
filter = Functions.get_function_by_id(filter_id)
if not filter:
continue
if filter_id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[filter_id]
else:
function_module, _, _ = load_function_module_by_id(filter_id)
request.app.state.FUNCTIONS[filter_id] = function_module
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
valves = Functions.get_function_valves_by_id(filter_id)
function_module.valves = function_module.Valves(
**(valves if valves else {})
)
if not hasattr(function_module, "outlet"):
continue
try:
outlet = function_module.outlet
# Get the signature of the function
sig = inspect.signature(outlet)
params = {"body": data}
# Extra parameters to be passed to the function
extra_params = {
"__model__": model,
"__id__": filter_id,
"__event_emitter__": __event_emitter__,
"__event_call__": __event_call__,
"__request__": request,
}
# Add extra params in contained in function signature
for key, value in extra_params.items():
if key in sig.parameters:
params[key] = value
if "__user__" in sig.parameters:
__user__ = {
"id": user.id,
"email": user.email,
"name": user.name,
"role": user.role,
}
try:
if hasattr(function_module, "UserValves"):
__user__["valves"] = function_module.UserValves(
**Functions.get_user_valves_by_id_and_user_id(
filter_id, user.id
)
)
except Exception as e:
print(e)
params = {**params, "__user__": __user__}
if inspect.iscoroutinefunction(outlet):
data = await outlet(**params)
else:
data = outlet(**params)
except Exception as e:
return Exception(f"Error: {e}")
return data
try:
result, _ = await process_filter_functions(
request=request,
filter_ids=get_sorted_filter_ids(model),
filter_type="outlet",
form_data=data,
extra_params=extra_params,
)
return result
except Exception as e:
return Exception(f"Error: {e}")
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
@@ -296,7 +352,13 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
if not request.app.state.MODELS:
await get_all_models(request)
models = request.app.state.MODELS
if getattr(request.state, "direct", False) and hasattr(request.state, "model"):
models = {
request.state.model["id"]: request.state.model,
}
else:
models = request.app.state.MODELS
data = form_data
model_id = data["model"]
@@ -310,6 +372,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
__event_call__ = get_event_call(
@@ -317,6 +380,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
"chat_id": data["chat_id"],
"message_id": data["id"],
"session_id": data["session_id"],
"user_id": user.id,
}
)
@@ -0,0 +1,148 @@
import asyncio
import json
import uuid
import websockets
import requests
from urllib.parse import urljoin
async def execute_code_jupyter(
jupyter_url, code, token=None, password=None, timeout=10
):
"""
Executes Python code in a Jupyter kernel.
Supports authentication with a token or password.
:param jupyter_url: Jupyter server URL (e.g., "http://localhost:8888")
:param code: Code to execute
:param token: Jupyter authentication token (optional)
:param password: Jupyter password (optional)
:param timeout: WebSocket timeout in seconds (default: 10s)
:return: Dictionary with stdout, stderr, and result
- Images are prefixed with "base64:image/png," and separated by newlines if multiple.
"""
session = requests.Session() # Maintain cookies
headers = {} # Headers for requests
# Authenticate using password
if password and not token:
try:
login_url = urljoin(jupyter_url, "/login")
response = session.get(login_url)
response.raise_for_status()
xsrf_token = session.cookies.get("_xsrf")
if not xsrf_token:
raise ValueError("Failed to fetch _xsrf token")
login_data = {"_xsrf": xsrf_token, "password": password}
login_response = session.post(
login_url, data=login_data, cookies=session.cookies
)
login_response.raise_for_status()
headers["X-XSRFToken"] = xsrf_token
except Exception as e:
return {
"stdout": "",
"stderr": f"Authentication Error: {str(e)}",
"result": "",
}
# Construct API URLs with authentication token if provided
params = f"?token={token}" if token else ""
kernel_url = urljoin(jupyter_url, f"/api/kernels{params}")
try:
response = session.post(kernel_url, headers=headers, cookies=session.cookies)
response.raise_for_status()
kernel_id = response.json()["id"]
websocket_url = urljoin(
jupyter_url.replace("http", "ws"),
f"/api/kernels/{kernel_id}/channels{params}",
)
ws_headers = {}
if password and not token:
ws_headers["X-XSRFToken"] = session.cookies.get("_xsrf")
cookies = {name: value for name, value in session.cookies.items()}
ws_headers["Cookie"] = "; ".join(
[f"{name}={value}" for name, value in cookies.items()]
)
async with websockets.connect(
websocket_url, additional_headers=ws_headers
) as ws:
msg_id = str(uuid.uuid4())
execute_request = {
"header": {
"msg_id": msg_id,
"msg_type": "execute_request",
"username": "user",
"session": str(uuid.uuid4()),
"date": "",
"version": "5.3",
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": False,
"store_history": True,
"user_expressions": {},
"allow_stdin": False,
"stop_on_error": True,
},
"channel": "shell",
}
await ws.send(json.dumps(execute_request))
stdout, stderr, result = "", "", []
while True:
try:
message = await asyncio.wait_for(ws.recv(), timeout)
message_data = json.loads(message)
if message_data.get("parent_header", {}).get("msg_id") == msg_id:
msg_type = message_data.get("msg_type")
if msg_type == "stream":
if message_data["content"]["name"] == "stdout":
stdout += message_data["content"]["text"]
elif message_data["content"]["name"] == "stderr":
stderr += message_data["content"]["text"]
elif msg_type in ("execute_result", "display_data"):
data = message_data["content"]["data"]
if "image/png" in data:
result.append(
f"data:image/png;base64,{data['image/png']}"
)
elif "text/plain" in data:
result.append(data["text/plain"])
elif msg_type == "error":
stderr += "\n".join(message_data["content"]["traceback"])
elif (
msg_type == "status"
and message_data["content"]["execution_state"] == "idle"
):
break
except asyncio.TimeoutError:
stderr += "\nExecution timed out."
break
except Exception as e:
return {"stdout": "", "stderr": f"Error: {str(e)}", "result": ""}
finally:
if kernel_id:
requests.delete(
f"{kernel_url}/{kernel_id}", headers=headers, cookies=session.cookies
)
return {
"stdout": stdout.strip(),
"stderr": stderr.strip(),
"result": "\n".join(result).strip() if result else "",
}
+99
View File
@@ -0,0 +1,99 @@
import inspect
from open_webui.utils.plugin import load_function_module_by_id
from open_webui.models.functions import Functions
def get_sorted_filter_ids(model):
def get_priority(function_id):
function = Functions.get_function_by_id(function_id)
if function is not None and hasattr(function, "valves"):
# TODO: Fix FunctionModel to include vavles
return (function.valves if function.valves else {}).get("priority", 0)
return 0
filter_ids = [function.id for function in Functions.get_global_filter_functions()]
if "info" in model and "meta" in model["info"]:
filter_ids.extend(model["info"]["meta"].get("filterIds", []))
filter_ids = list(set(filter_ids))
enabled_filter_ids = [
function.id
for function in Functions.get_functions_by_type("filter", active_only=True)
]
filter_ids = [fid for fid in filter_ids if fid in enabled_filter_ids]
filter_ids.sort(key=get_priority)
return filter_ids
async def process_filter_functions(
request, filter_ids, filter_type, form_data, extra_params
):
skip_files = None
for filter_id in filter_ids:
filter = Functions.get_function_by_id(filter_id)
if not filter:
continue
if filter_id in request.app.state.FUNCTIONS:
function_module = request.app.state.FUNCTIONS[filter_id]
else:
function_module, _, _ = load_function_module_by_id(filter_id)
request.app.state.FUNCTIONS[filter_id] = function_module
# Check if the function has a file_handler variable
if filter_type == "inlet" and hasattr(function_module, "file_handler"):
skip_files = function_module.file_handler
# Apply valves to the function
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
valves = Functions.get_function_valves_by_id(filter_id)
function_module.valves = function_module.Valves(
**(valves if valves else {})
)
# Prepare handler function
handler = getattr(function_module, filter_type, None)
if not handler:
continue
try:
# Prepare parameters
sig = inspect.signature(handler)
params = {"body": form_data} | {
k: v
for k, v in {
**extra_params,
"__id__": filter_id,
}.items()
if k in sig.parameters
}
# Handle user parameters
if "__user__" in sig.parameters:
if hasattr(function_module, "UserValves"):
try:
params["__user__"]["valves"] = function_module.UserValves(
**Functions.get_user_valves_by_id_and_user_id(
filter_id, params["__user__"]["id"]
)
)
except Exception as e:
print(e)
# Execute handler
if inspect.iscoroutinefunction(handler):
form_data = await handler(**params)
else:
form_data = handler(**params)
except Exception as e:
print(f"Error in {filter_type} handler {filter_id}: {e}")
raise e
# Handle file cleanup for inlet
if skip_files and "files" in form_data.get("metadata", {}):
del form_data["metadata"]["files"]
return form_data, {}
+1 -1
View File
@@ -161,7 +161,7 @@ async def comfyui_generate_image(
seed = (
payload.seed
if payload.seed
else random.randint(0, 18446744073709551614)
else random.randint(0, 1125899906842624)
)
for node_id in node.node_ids:
workflow[node_id]["inputs"][node.key] = seed
File diff suppressed because it is too large Load Diff
+81 -11
View File
@@ -7,6 +7,18 @@ from pathlib import Path
from typing import Callable, Optional
import collections.abc
def deep_update(d, u):
for k, v in u.items():
if isinstance(v, collections.abc.Mapping):
d[k] = deep_update(d.get(k, {}), v)
else:
d[k] = v
return d
def get_message_list(messages, message_id):
"""
Reconstructs a list of messages in order up to the specified message_id.
@@ -20,7 +32,7 @@ def get_message_list(messages, message_id):
current_message = messages.get(message_id)
if not current_message:
return f"Message ID {message_id} not found in the history."
return None
# Reconstruct the chain by following the parentId links
message_list = []
@@ -131,6 +143,44 @@ def add_or_update_system_message(content: str, messages: list[dict]):
return messages
def add_or_update_user_message(content: str, messages: list[dict]):
"""
Adds a new user message at the end of the messages list
or updates the existing user message at the end.
:param msg: The message to be added or appended.
:param messages: The list of message dictionaries.
:return: The updated list of message dictionaries.
"""
if messages and messages[-1].get("role") == "user":
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
else:
# Insert at the end
messages.append({"role": "user", "content": content})
return messages
def append_or_update_assistant_message(content: str, messages: list[dict]):
"""
Adds a new assistant message at the end of the messages list
or updates the existing assistant message at the end.
:param msg: The message to be added or appended.
:param messages: The list of message dictionaries.
:return: The updated list of message dictionaries.
"""
if messages and messages[-1].get("role") == "assistant":
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}"
else:
# Insert at the end
messages.append({"role": "assistant", "content": content})
return messages
def openai_chat_message_template(model: str):
return {
"id": f"{model}-{str(uuid.uuid4())}",
@@ -141,13 +191,24 @@ def openai_chat_message_template(model: str):
def openai_chat_chunk_message_template(
model: str, message: Optional[str] = None, usage: Optional[dict] = None
model: str,
content: Optional[str] = None,
tool_calls: Optional[list[dict]] = None,
usage: Optional[dict] = None,
) -> dict:
template = openai_chat_message_template(model)
template["object"] = "chat.completion.chunk"
if message:
template["choices"][0]["delta"] = {"content": message}
else:
template["choices"][0]["index"] = 0
template["choices"][0]["delta"] = {}
if content:
template["choices"][0]["delta"]["content"] = content
if tool_calls:
template["choices"][0]["delta"]["tool_calls"] = tool_calls
if not content and not tool_calls:
template["choices"][0]["finish_reason"] = "stop"
if usage:
@@ -156,12 +217,20 @@ def openai_chat_chunk_message_template(
def openai_chat_completion_message_template(
model: str, message: Optional[str] = None, usage: Optional[dict] = None
model: str,
message: Optional[str] = None,
tool_calls: Optional[list[dict]] = None,
usage: Optional[dict] = None,
) -> dict:
template = openai_chat_message_template(model)
template["object"] = "chat.completion"
if message is not None:
template["choices"][0]["message"] = {"content": message, "role": "assistant"}
template["choices"][0]["message"] = {
"content": message,
"role": "assistant",
**({"tool_calls": tool_calls} if tool_calls else {}),
}
template["choices"][0]["finish_reason"] = "stop"
if usage:
@@ -183,11 +252,12 @@ def get_gravatar_url(email):
return f"https://www.gravatar.com/avatar/{hash_hex}?d=mp"
def calculate_sha256(file):
def calculate_sha256(file_path, chunk_size):
# Compute SHA-256 hash of a file efficiently in chunks
sha256 = hashlib.sha256()
# Read the file in chunks to efficiently handle large files
for chunk in iter(lambda: file.read(8192), b""):
sha256.update(chunk)
with open(file_path, "rb") as f:
while chunk := f.read(chunk_size):
sha256.update(chunk)
return sha256.hexdigest()
+1 -1
View File
@@ -142,7 +142,7 @@ async def get_all_models(request):
custom_model.base_model_id == model["id"]
or custom_model.base_model_id == model["id"].split(":")[0]
):
owned_by = model["owned_by"]
owned_by = model.get("owned_by", "unknown owner")
if "pipe" in model:
pipe = model["pipe"]
break
+97 -45
View File
@@ -1,6 +1,7 @@
import base64
import logging
import mimetypes
import sys
import uuid
import aiohttp
@@ -34,13 +35,21 @@ from open_webui.config import (
JWT_EXPIRES_IN,
AppConfig,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
from open_webui.env import (
WEBUI_NAME,
WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE,
)
from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token
from open_webui.utils.webhook import post_webhook
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["OAUTH"])
auth_manager_config = AppConfig()
auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
@@ -61,19 +70,11 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
class OAuthManager:
def __init__(self):
def __init__(self, app):
self.oauth = OAuth()
for provider_name, provider_config in OAUTH_PROVIDERS.items():
self.oauth.register(
name=provider_name,
client_id=provider_config["client_id"],
client_secret=provider_config["client_secret"],
server_metadata_url=provider_config["server_metadata_url"],
client_kwargs={
"scope": provider_config["scope"],
},
redirect_uri=provider_config["redirect_uri"],
)
self.app = app
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)
@@ -81,17 +82,21 @@ class OAuthManager:
def get_user_role(self, user, user_data):
if user and Users.get_num_users() == 1:
# If the user is the only user, assign the role "admin" - actually repairs role for single user on login
log.debug("Assigning the only user the admin role")
return "admin"
if not user and Users.get_num_users() == 0:
# If there are no users, assign the role "admin", as the first user will be an admin
log.debug("Assigning the first user the admin role")
return "admin"
if auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT:
log.debug("Running OAUTH Role management")
oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
oauth_roles = None
role = "pending" # Default/fallback role if no matching roles are found
# Default/fallback role if no matching roles are found
role = auth_manager_config.DEFAULT_USER_ROLE
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
@@ -101,17 +106,24 @@ class OAuthManager:
claim_data = claim_data.get(nested_claim, {})
oauth_roles = claim_data if isinstance(claim_data, list) else None
log.debug(f"Oauth Roles claim: {oauth_claim}")
log.debug(f"User roles from oauth: {oauth_roles}")
log.debug(f"Accepted user roles: {oauth_allowed_roles}")
log.debug(f"Accepted admin roles: {oauth_admin_roles}")
# If any roles are found, check if they match the allowed or admin roles
if oauth_roles:
# If role management is enabled, and matching roles are provided, use the roles
for allowed_role in oauth_allowed_roles:
# If the user has any of the allowed roles, assign the role "user"
if allowed_role in oauth_roles:
log.debug("Assigned user the user role")
role = "user"
break
for admin_role in oauth_admin_roles:
# If the user has any of the admin roles, assign the role "admin"
if admin_role in oauth_roles:
log.debug("Assigned user the admin role")
role = "admin"
break
else:
@@ -125,16 +137,27 @@ class OAuthManager:
return role
def update_user_groups(self, user, user_data, default_permissions):
log.debug("Running OAUTH Group management")
oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
user_oauth_groups: list[str] = user_data.get(oauth_claim, list())
user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
all_available_groups: list[GroupModel] = Groups.get_groups()
log.debug(f"Oauth Groups claim: {oauth_claim}")
log.debug(f"User oauth groups: {user_oauth_groups}")
log.debug(f"User's current groups: {[g.name for g in user_current_groups]}")
log.debug(
f"All groups available in OpenWebUI: {[g.name for g in all_available_groups]}"
)
# Remove groups that user is no longer a part of
for group_model in user_current_groups:
if group_model.name not in user_oauth_groups:
# Remove group from user
log.debug(
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
)
user_ids = group_model.user_ids
user_ids = [i for i in user_ids if i != user.id]
@@ -160,6 +183,9 @@ class OAuthManager:
gm.name == group_model.name for gm in user_current_groups
):
# Add user to group
log.debug(
f"Adding user to group {group_model.name} as it was found in their oauth groups"
)
user_ids = group_model.user_ids
user_ids.append(user.id)
@@ -179,7 +205,7 @@ class OAuthManager:
id=group_model.id, form_data=update_form, overwrite=False
)
async def handle_login(self, provider, request):
async def handle_login(self, request, provider):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
@@ -191,7 +217,7 @@ class OAuthManager:
raise HTTPException(404)
return await client.authorize_redirect(request, redirect_uri)
async def handle_callback(self, provider, request, response):
async def handle_callback(self, request, provider, response):
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
client = self.get_client(provider)
@@ -200,14 +226,14 @@ class OAuthManager:
except Exception as e:
log.warning(f"OAuth callback error: {e}")
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
user_data: UserInfo = token["userinfo"]
if not user_data:
user_data: UserInfo = token.get("userinfo")
if not user_data or "email" 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)
sub = user_data.get("sub")
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)
@@ -245,6 +271,17 @@ class OAuthManager:
Users.update_user_role_by_id(user.id, determined_role)
if not user:
user_count = Users.get_num_users()
if (
request.app.state.USER_COUNT
and user_count >= request.app.state.USER_COUNT
):
raise HTTPException(
403,
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
@@ -255,30 +292,49 @@ class OAuthManager:
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
picture_url = user_data.get(picture_claim, "")
picture_url = user_data.get(
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
)
if picture_url:
# Download the profile image into a base64 string
try:
access_token = token.get("access_token")
get_kwargs = {}
if access_token:
get_kwargs["headers"] = {
"Authorization": f"Bearer {access_token}",
}
async with aiohttp.ClientSession() as session:
async with session.get(picture_url) as resp:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(
picture
).decode("utf-8")
guessed_mime_type = mimetypes.guess_type(picture_url)[0]
if guessed_mime_type is None:
# assume JPG, browsers are tolerant enough of image formats
guessed_mime_type = "image/jpeg"
picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
async with session.get(picture_url, **get_kwargs) as resp:
if resp.ok:
picture = await resp.read()
base64_encoded_picture = base64.b64encode(
picture
).decode("utf-8")
guessed_mime_type = mimetypes.guess_type(
picture_url
)[0]
if guessed_mime_type is None:
# assume JPG, browsers are tolerant enough of image formats
guessed_mime_type = "image/jpeg"
picture_url = f"data:{guessed_mime_type};base64,{base64_encoded_picture}"
else:
picture_url = "/user.png"
except Exception as e:
log.error(
f"Error downloading profile image '{picture_url}': {e}"
)
picture_url = ""
picture_url = "/user.png"
if not picture_url:
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
role = self.get_user_role(None, user_data)
user = Auths.insert_new_auth(
@@ -286,7 +342,7 @@ class OAuthManager:
password=get_password_hash(
str(uuid.uuid4())
), # Random password, not used
name=user_data.get(username_claim, "User"),
name=name,
profile_image_url=picture_url,
role=role,
oauth_sub=provider_sub,
@@ -294,13 +350,12 @@ class OAuthManager:
if auth_manager_config.WEBHOOK_URL:
post_webhook(
WEBUI_NAME,
auth_manager_config.WEBHOOK_URL,
auth_manager_config.WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
{
"action": "signup",
"message": auth_manager_config.WEBHOOK_MESSAGES.USER_SIGNUP(
user.name
),
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
"user": user.model_dump_json(exclude_none=True),
},
)
@@ -314,7 +369,7 @@ class OAuthManager:
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
)
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
self.update_user_groups(
user=user,
user_data=user_data,
@@ -326,8 +381,8 @@ class OAuthManager:
key="token",
value=jwt_token,
httponly=True, # Ensures the cookie is not accessible via JavaScript
samesite=WEBUI_SESSION_COOKIE_SAME_SITE,
secure=WEBUI_SESSION_COOKIE_SECURE,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
if ENABLE_OAUTH_SIGNUP.value:
@@ -336,12 +391,9 @@ class OAuthManager:
key="oauth_id_token",
value=oauth_id_token,
httponly=True,
samesite=WEBUI_SESSION_COOKIE_SAME_SITE,
secure=WEBUI_SESSION_COOKIE_SECURE,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
)
# Redirect back to the frontend with the JWT token
redirect_url = f"{request.base_url}auth#token={jwt_token}"
return RedirectResponse(url=redirect_url, headers=response.headers)
oauth_manager = OAuthManager()
+24 -2
View File
@@ -1,4 +1,4 @@
from open_webui.utils.task import prompt_template
from open_webui.utils.task import prompt_template, prompt_variables_template
from open_webui.utils.misc import (
add_or_update_system_message,
)
@@ -7,11 +7,20 @@ from typing import Callable, Optional
# inplace function: form_data is modified
def apply_model_system_prompt_to_body(params: dict, form_data: dict, user) -> dict:
def apply_model_system_prompt_to_body(
params: dict, form_data: dict, metadata: Optional[dict] = None, user=None
) -> dict:
system = params.get("system", None)
if not system:
return form_data
# Metadata (WebUI Usage)
if metadata:
variables = metadata.get("variables", {})
if variables:
system = prompt_variables_template(system, variables)
# Legacy (API Usage)
if user:
template_params = {
"user_name": user.name,
@@ -19,7 +28,9 @@ def apply_model_system_prompt_to_body(params: dict, form_data: dict, user) -> di
}
else:
template_params = {}
system = prompt_template(system, **template_params)
form_data["messages"] = add_or_update_system_message(
system, form_data.get("messages", [])
)
@@ -47,6 +58,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict:
"top_p": float,
"max_tokens": int,
"frequency_penalty": float,
"reasoning_effort": str,
"seed": lambda x: x,
"stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x],
}
@@ -154,12 +166,19 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
)
ollama_payload["stream"] = openai_payload.get("stream", False)
if "tools" in openai_payload:
ollama_payload["tools"] = openai_payload["tools"]
if "format" in openai_payload:
ollama_payload["format"] = openai_payload["format"]
# If there are advanced parameters in the payload, format them in Ollama's options field
ollama_options = {}
if openai_payload.get("options"):
ollama_payload["options"] = openai_payload["options"]
ollama_options = openai_payload["options"]
# Handle parameters which map directly
for param in ["temperature", "top_p", "seed"]:
if param in openai_payload:
@@ -183,4 +202,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
if ollama_options:
ollama_payload["options"] = ollama_options
if "metadata" in openai_payload:
ollama_payload["metadata"] = openai_payload["metadata"]
return ollama_payload
+8 -5
View File
@@ -2,6 +2,7 @@ from datetime import datetime
from io import BytesIO
from pathlib import Path
from typing import Dict, Any, List
from html import escape
from markdown import markdown
@@ -41,18 +42,19 @@ class PDFGenerator:
def _build_html_message(self, message: Dict[str, Any]) -> str:
"""Build HTML for a single message."""
role = message.get("role", "user")
content = message.get("content", "")
role = escape(message.get("role", "user"))
content = escape(message.get("content", ""))
timestamp = message.get("timestamp")
model = message.get("model") if role == "assistant" else ""
model = escape(message.get("model") if role == "assistant" else "")
date_str = self.format_timestamp(timestamp) if timestamp else ""
date_str = escape(self.format_timestamp(timestamp) if timestamp else "")
# extends pymdownx extension to convert markdown to html.
# - https://facelessuser.github.io/pymdown-extensions/usage_notes/
# html_content = markdown(content, extensions=["pymdownx.extra"])
content = content.replace("\n", "<br/>")
html_message = f"""
<div>
<div>
@@ -75,6 +77,7 @@ class PDFGenerator:
def _generate_html_body(self) -> str:
"""Generate the full HTML body for the PDF."""
escaped_title = escape(self.form_data.title)
return f"""
<html>
<head>
@@ -83,7 +86,7 @@ class PDFGenerator:
<body>
<div>
<div>
<h2>{self.form_data.title}</h2>
<h2>{escaped_title}</h2>
{self.messages_html}
</div>
</div>
+9 -4
View File
@@ -167,9 +167,14 @@ def load_function_module_by_id(function_id, content=None):
def install_frontmatter_requirements(requirements):
if requirements:
req_list = [req.strip() for req in requirements.split(",")]
for req in req_list:
log.info(f"Installing requirement: {req}")
subprocess.check_call([sys.executable, "-m", "pip", "install", req])
try:
req_list = [req.strip() for req in requirements.split(",")]
for req in req_list:
log.info(f"Installing requirement: {req}")
subprocess.check_call([sys.executable, "-m", "pip", "install", req])
except Exception as e:
log.error(f"Error installing package: {req}")
raise e
else:
log.info("No requirements found in frontmatter.")
+77 -4
View File
@@ -1,15 +1,82 @@
import json
from uuid import uuid4
from open_webui.utils.misc import (
openai_chat_chunk_message_template,
openai_chat_completion_message_template,
)
def convert_ollama_tool_call_to_openai(tool_calls: dict) -> dict:
openai_tool_calls = []
for tool_call in tool_calls:
openai_tool_call = {
"index": tool_call.get("index", 0),
"id": tool_call.get("id", f"call_{str(uuid4())}"),
"type": "function",
"function": {
"name": tool_call.get("function", {}).get("name", ""),
"arguments": json.dumps(
tool_call.get("function", {}).get("arguments", {})
),
},
}
openai_tool_calls.append(openai_tool_call)
return openai_tool_calls
def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
model = ollama_response.get("model", "ollama")
message_content = ollama_response.get("message", {}).get("content", "")
tool_calls = ollama_response.get("message", {}).get("tool_calls", None)
openai_tool_calls = None
response = openai_chat_completion_message_template(model, message_content)
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
data = ollama_response
usage = {
"response_token/s": (
round(
(
(
data.get("eval_count", 0)
/ ((data.get("eval_duration", 0) / 10_000_000))
)
* 100
),
2,
)
if data.get("eval_duration", 0) > 0
else "N/A"
),
"prompt_token/s": (
round(
(
(
data.get("prompt_eval_count", 0)
/ ((data.get("prompt_eval_duration", 0) / 10_000_000))
)
* 100
),
2,
)
if data.get("prompt_eval_duration", 0) > 0
else "N/A"
),
"total_duration": data.get("total_duration", 0),
"load_duration": data.get("load_duration", 0),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"prompt_eval_duration": data.get("prompt_eval_duration", 0),
"eval_count": data.get("eval_count", 0),
"eval_duration": data.get("eval_duration", 0),
"approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")(
(data.get("total_duration", 0) or 0) // 1_000_000_000
),
}
response = openai_chat_completion_message_template(
model, message_content, openai_tool_calls, usage
)
return response
@@ -19,6 +86,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
model = data.get("model", "ollama")
message_content = data.get("message", {}).get("content", "")
tool_calls = data.get("message", {}).get("tool_calls", None)
openai_tool_calls = None
if tool_calls:
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
done = data.get("done", False)
usage = None
@@ -29,7 +102,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
(
(
data.get("eval_count", 0)
/ ((data.get("eval_duration", 0) / 1_000_000))
/ ((data.get("eval_duration", 0) / 10_000_000))
)
* 100
),
@@ -43,7 +116,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
(
(
data.get("prompt_eval_count", 0)
/ ((data.get("prompt_eval_duration", 0) / 1_000_000))
/ ((data.get("prompt_eval_duration", 0) / 10_000_000))
)
* 100
),
@@ -64,7 +137,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
}
data = openai_chat_chunk_message_template(
model, message_content if not done else None, usage
model, message_content if not done else None, openai_tool_calls, usage
)
line = f"data: {json.dumps(data)}\n\n"
+25 -1
View File
@@ -22,7 +22,7 @@ def get_task_model_id(
# Set the task model
task_model_id = default_model_id
# Check if the user has a custom task model and use that model
if models[task_model_id]["owned_by"] == "ollama":
if models[task_model_id].get("owned_by") == "ollama":
if task_model and task_model in models:
task_model_id = task_model
else:
@@ -32,6 +32,12 @@ def get_task_model_id(
return task_model_id
def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
for variable, value in variables.items():
template = template.replace(variable, value)
return template
def prompt_template(
template: str, user_name: Optional[str] = None, user_location: Optional[str] = None
) -> str:
@@ -217,6 +223,24 @@ def tags_generation_template(
return template
def image_prompt_generation_template(
template: str, messages: list[dict], user: Optional[dict] = None
) -> str:
prompt = get_last_user_message(messages)
template = replace_prompt_variable(template, prompt)
template = replace_messages_variable(template, messages)
template = prompt_template(
template,
**(
{"user_name": user.get("name"), "user_location": user.get("location")}
if user
else {}
),
)
return template
def emoji_generation_template(
template: str, prompt: str, user: Optional[dict] = None
) -> str:
+13
View File
@@ -61,6 +61,12 @@ def get_tools(
)
for spec in tools.specs:
# TODO: Fix hack for OpenAI API
# Some times breaks OpenAI but others don't. Leaving the comment
for val in spec.get("parameters", {}).get("properties", {}).values():
if val["type"] == "str":
val["type"] = "string"
# Remove internal parameters
spec["parameters"]["properties"] = {
key: val
@@ -73,6 +79,13 @@ def get_tools(
# convert to function that takes only model params and inserts custom params
original_func = getattr(module, function_name)
callable = apply_extra_params_to_tool_function(original_func, extra_params)
if callable.__doc__ and callable.__doc__.strip() != "":
s = re.split(":(param|return)", callable.__doc__, 1)
spec["description"] = s[0]
else:
spec["description"] = function_name
# TODO: This needs to be a pydantic model
tool_dict = {
"toolkit_id": tool_id,
+3 -3
View File
@@ -2,14 +2,14 @@ import json
import logging
import requests
from open_webui.config import WEBUI_FAVICON_URL, WEBUI_NAME
from open_webui.config import WEBUI_FAVICON_URL
from open_webui.env import SRC_LOG_LEVELS, VERSION
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["WEBHOOK"])
def post_webhook(url: str, message: str, event_data: dict) -> bool:
def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
try:
log.debug(f"post_webhook: {url}, {message}, {event_data}")
payload = {}
@@ -39,7 +39,7 @@ def post_webhook(url: str, message: str, event_data: dict) -> bool:
"sections": [
{
"activityTitle": message,
"activitySubtitle": f"{WEBUI_NAME} ({VERSION}) - {action}",
"activitySubtitle": f"{name} ({VERSION}) - {action}",
"activityImage": WEBUI_FAVICON_URL,
"facts": facts,
"markdown": True,
+20 -19
View File
@@ -1,24 +1,21 @@
fastapi==0.111.0
fastapi==0.115.7
uvicorn[standard]==0.30.6
pydantic==2.9.2
python-multipart==0.0.18
Flask==3.0.3
Flask-Cors==5.0.0
python-socketio==5.11.3
python-jose==3.3.0
passlib[bcrypt]==1.7.4
requests==2.32.3
aiohttp==3.11.8
aiohttp==3.11.11
async-timeout
aiocache
aiofiles
sqlalchemy==2.0.32
alembic==1.14.0
peewee==3.17.6
peewee==3.17.8
peewee-migrate==1.12.2
psycopg2-binary==2.9.9
pgvector==0.3.5
@@ -32,6 +29,8 @@ boto3==1.35.53
argon2-cffi==23.1.0
APScheduler==3.10.4
RestrictedPython==8.0
# AI libraries
openai
anthropic
@@ -40,14 +39,15 @@ tiktoken
langchain==0.3.7
langchain-community==0.3.7
langchain-chroma==0.1.4
fake-useragent==1.5.1
chromadb==0.5.15
chromadb==0.6.2
pymilvus==2.5.0
qdrant-client~=1.12.0
opensearch-py==2.7.1
opensearch-py==2.8.0
transformers
sentence-transformers==3.3.1
colbert-ai==0.2.21
einops==0.8.0
@@ -55,11 +55,11 @@ einops==0.8.0
ftfy==6.2.3
pypdf==4.3.1
fpdf2==2.7.9
pymdown-extensions==10.11.2
fpdf2==2.8.2
pymdown-extensions==10.14.2
docx2txt==0.8
python-pptx==1.0.0
unstructured==0.15.9
unstructured==0.16.11
nltk==3.9.1
Markdown==3.7
pypandoc==1.13
@@ -67,19 +67,19 @@ pandas==2.2.3
openpyxl==3.1.5
pyxlsb==1.0.10
xlrd==2.0.1
validators==0.33.0
validators==0.34.0
psutil
sentencepiece
soundfile==0.12.1
soundfile==0.13.1
opencv-python-headless==4.10.0.84
opencv-python-headless==4.11.0.86
rapidocr-onnxruntime==1.3.24
rank-bm25==0.2.2
faster-whisper==1.0.3
faster-whisper==1.1.1
PyJWT[crypto]==2.9.0
authlib==1.3.2
PyJWT[crypto]==2.10.1
authlib==1.4.1
black==24.8.0
langfuse==2.44.0
@@ -88,7 +88,7 @@ pytube==15.0.0
extract_msg
pydub
duckduckgo-search~=6.3.5
duckduckgo-search~=7.3.2
## Google Drive
google-api-python-client
@@ -101,6 +101,7 @@ pytest~=8.3.2
pytest-docker~=3.1.1
googleapis-common-protos==1.63.2
google-cloud-storage==2.19.0
## LDAP
ldap3==2.9.1
+6
View File
@@ -16,6 +16,9 @@ For the UI configuration, you can set up the Apache VirtualHost as follows:
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
</VirtualHost>
```
@@ -32,6 +35,9 @@ Enable the site first before you can request SSL:
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
SSLEngine on
SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert
+742 -666
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -1,9 +1,10 @@
{
"name": "open-webui",
"version": "0.5.2",
"version": "0.5.13",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
"build": "npm run pyodide:fetch && vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -24,10 +25,11 @@
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.20",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.13",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"autoprefixer": "^10.4.16",
"cypress": "^13.15.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
@@ -41,16 +43,15 @@
"svelte": "^4.2.18",
"svelte-check": "^3.8.5",
"svelte-confetti": "^1.3.2",
"tailwindcss": "^3.3.3",
"tailwindcss": "^4.0.0",
"tslib": "^2.4.1",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vitest": "^1.6.0"
"vite": "^5.4.14",
"vitest": "^1.6.1"
},
"type": "module",
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language-data": "^6.5.1",
"@codemirror/theme-one-dark": "^6.1.2",
@@ -58,6 +59,7 @@
"@mediapipe/tasks-vision": "^0.10.17",
"@pyscript/core": "^0.4.32",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/svelte-virtual-list": "^3.0.1",
"@tiptap/core": "^2.10.0",
"@tiptap/extension-code-block-lowlight": "^2.10.0",
"@tiptap/extension-highlight": "^2.10.0",
@@ -69,6 +71,7 @@
"async": "^3.2.5",
"bits-ui": "^0.19.7",
"codemirror": "^6.0.1",
"codemirror-lang-hcl": "^0.0.0-beta.2",
"crc-32": "^1.2.2",
"dayjs": "^1.11.10",
"dompurify": "^3.1.6",
@@ -81,7 +84,8 @@
"i18next-resources-to-backend": "^1.2.0",
"idb": "^7.1.1",
"js-sha256": "^0.10.1",
"katex": "^0.16.9",
"katex": "^0.16.21",
"kokoro-js": "^1.1.1",
"marked": "^9.1.0",
"mermaid": "^10.9.3",
"paneforge": "^0.0.6",
@@ -96,13 +100,14 @@
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-view": "^1.34.3",
"pyodide": "^0.26.1",
"pyodide": "^0.27.2",
"socket.io-client": "^4.2.0",
"sortablejs": "^1.15.2",
"svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7",
"turndown": "^7.2.0",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"vite-plugin-static-copy": "^2.2.0"
},
"engines": {
"node": ">=18.13.0 <=22.x.x",
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
'@tailwindcss/postcss': {}
}
};
+34 -20
View File
@@ -6,27 +6,24 @@ authors = [
]
license = { file = "LICENSE" }
dependencies = [
"fastapi==0.111.0",
"fastapi==0.115.7",
"uvicorn[standard]==0.30.6",
"pydantic==2.9.2",
"python-multipart==0.0.18",
"Flask==3.0.3",
"Flask-Cors==5.0.0",
"python-socketio==5.11.3",
"python-jose==3.3.0",
"passlib[bcrypt]==1.7.4",
"requests==2.32.3",
"aiohttp==3.11.8",
"aiohttp==3.11.11",
"async-timeout",
"aiocache",
"aiofiles",
"sqlalchemy==2.0.32",
"alembic==1.14.0",
"peewee==3.17.6",
"peewee==3.17.8",
"peewee-migrate==1.12.2",
"psycopg2-binary==2.9.9",
"pgvector==0.3.5",
@@ -40,6 +37,9 @@ dependencies = [
"argon2-cffi==23.1.0",
"APScheduler==3.10.4",
"RestrictedPython==8.0",
"openai",
"anthropic",
"google-generativeai==0.7.2",
@@ -47,25 +47,25 @@ dependencies = [
"langchain==0.3.7",
"langchain-community==0.3.7",
"langchain-chroma==0.1.4",
"fake-useragent==1.5.1",
"chromadb==0.5.15",
"chromadb==0.6.2",
"pymilvus==2.5.0",
"qdrant-client~=1.12.0",
"opensearch-py==2.7.1",
"opensearch-py==2.8.0",
"transformers",
"sentence-transformers==3.3.1",
"colbert-ai==0.2.21",
"einops==0.8.0",
"ftfy==6.2.3",
"pypdf==4.3.1",
"fpdf2==2.7.9",
"pymdown-extensions==10.11.2",
"fpdf2==2.8.2",
"pymdown-extensions==10.14.2",
"docx2txt==0.8",
"python-pptx==1.0.0",
"unstructured==0.15.9",
"unstructured==0.16.11",
"nltk==3.9.1",
"Markdown==3.7",
"pypandoc==1.13",
@@ -73,19 +73,19 @@ dependencies = [
"openpyxl==3.1.5",
"pyxlsb==1.0.10",
"xlrd==2.0.1",
"validators==0.33.0",
"validators==0.34.0",
"psutil",
"sentencepiece",
"soundfile==0.12.1",
"soundfile==0.13.1",
"opencv-python-headless==4.10.0.84",
"opencv-python-headless==4.11.0.86",
"rapidocr-onnxruntime==1.3.24",
"rank-bm25==0.2.2",
"faster-whisper==1.0.3",
"faster-whisper==1.1.1",
"PyJWT[crypto]==2.9.0",
"authlib==1.3.2",
"PyJWT[crypto]==2.10.1",
"authlib==1.4.1",
"black==24.8.0",
"langfuse==2.44.0",
@@ -94,15 +94,22 @@ dependencies = [
"extract_msg",
"pydub",
"duckduckgo-search~=6.3.5",
"duckduckgo-search~=7.3.2",
"google-api-python-client",
"google-auth-httplib2",
"google-auth-oauthlib",
"docker~=7.1.0",
"pytest~=8.3.2",
"pytest-docker~=3.1.1",
"moto[s3]>=5.0.26",
"googleapis-common-protos==1.63.2",
"google-cloud-storage==2.19.0",
"ldap3==2.9.1"
"ldap3==2.9.1",
"gcp-storage-emulator>=2024.8.3",
]
readme = "README.md"
requires-python = ">= 3.11, < 3.13.0a1"
@@ -151,3 +158,10 @@ exclude = [
"chroma.sqlite3",
]
force-include = { "CHANGELOG.md" = "open_webui/CHANGELOG.md", build = "open_webui/frontend" }
[tool.codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = '.git*,*.svg,package-lock.json,i18n,*.lock,*.css,*-bundle.js,locales,example-doc.txt,emoji-shortcodes.json'
check-hidden = true
# ignore-regex = ''
ignore-words-list = 'ans'
+4 -1
View File
@@ -9,7 +9,10 @@ const packages = [
'scikit-learn',
'scipy',
'regex',
'seaborn'
'sympy',
'tiktoken',
'seaborn',
'pytz'
];
import { loadPyodide } from 'pyodide';
+75 -6
View File
@@ -1,3 +1,5 @@
@reference "./tailwind.css";
@font-face {
font-family: 'Inter';
src: url('/assets/fonts/Inter-Variable.ttf');
@@ -53,11 +55,11 @@ math {
}
.markdown-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
}
.markdown a {
@@ -68,6 +70,19 @@ math {
font-family: 'Archivo', sans-serif;
}
.drag-region {
-webkit-app-region: drag;
}
.drag-region a,
.drag-region button {
-webkit-app-region: no-drag;
}
.no-drag-region {
-webkit-app-region: no-drag;
}
iframe {
@apply rounded-lg;
}
@@ -102,18 +117,62 @@ li p {
select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-position: right 0rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
/* padding-right: 2.5rem; */
/* for Firefox */
-moz-appearance: none;
/* for Chrome */
-webkit-appearance: none;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.shimmer {
background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #818286; /* Fallback color */
}
:global(.dark) .shimmer {
background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 4s linear infinite;
color: #a1a3a7; /* Darker fallback color for dark mode */
}
@keyframes smoothFadeIn {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.status-description {
animation: smoothFadeIn 0.2s forwards;
}
.katex-mathml {
display: none;
}
@@ -160,8 +219,18 @@ input[type='number'] {
width: 100%;
}
.cm-scroller {
@apply scrollbar-hidden;
.cm-scroller:active::-webkit-scrollbar-thumb,
.cm-scroller:focus::-webkit-scrollbar-thumb,
.cm-scroller:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.cm-scroller::-webkit-scrollbar-thumb {
visibility: hidden;
}
.cm-scroller::-webkit-scrollbar-corner {
display: none;
}
.cm-editor.cm-focused {
+1
View File
@@ -21,6 +21,7 @@
title="Open WebUI"
href="/opensearch.xml"
/>
<script src="/static/loader.js" defer></script>
<script>
function resizeIframe(obj) {
+122
View File
@@ -1,4 +1,5 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { t } from 'i18next';
type ChannelForm = {
name: string;
@@ -207,7 +208,48 @@ export const getChannelMessages = async (
return res;
};
export const getChannelThreadMessages = async (
token: string = '',
channel_id: string,
message_id: string,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type MessageForm = {
parent_id?: string;
content: string;
data?: object;
meta?: object;
@@ -285,6 +327,86 @@ export const updateMessage = async (
return res;
};
export const addReaction = async (
token: string = '',
channel_id: string,
message_id: string,
name: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/add`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const removeReaction = async (
token: string = '',
channel_id: string,
message_id: string,
name: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/remove`,
{
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ name })
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteMessage = async (token: string = '', channel_id: string, message_id: string) => {
let error = null;
+43 -2
View File
@@ -459,7 +459,7 @@ export const getChatById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
error = err;
error = err.detail;
console.log(err);
return null;
@@ -580,10 +580,51 @@ export const toggleChatPinnedStatusById = async (token: string, id: string) => {
return res;
};
export const cloneChatById = async (token: string, id: string) => {
export const cloneChatById = async (token: string, id: string, title?: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
...(title && { title: title })
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
if ('detail' in err) {
error = err.detail;
} else {
error = err;
}
console.log(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const cloneSharedChatById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone/shared`, {
method: 'POST',
headers: {
Accept: 'application/json',
+114
View File
@@ -58,6 +58,120 @@ export const exportConfig = async (token: string) => {
return res;
};
export const getDirectConnectionsConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setDirectConnectionsConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/direct_connections`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...config
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getCodeExecutionConfig = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const setCodeExecutionConfig = async (token: string, config: object) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/code_execution`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...config
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelsConfig = async (token: string) => {
let error = null;
+114 -3
View File
@@ -1,6 +1,11 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { getOpenAIModelsDirect } from './openai';
export const getModels = async (token: string = '', base: boolean = false) => {
export const getModels = async (
token: string = '',
connections: object | null = null,
base: boolean = false
) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/models${base ? '/base' : ''}`, {
method: 'GET',
@@ -25,6 +30,111 @@ export const getModels = async (token: string = '', base: boolean = false) => {
}
let models = res?.data ?? [];
if (connections && !base) {
let localModels = [];
if (connections) {
const OPENAI_API_BASE_URLS = connections.OPENAI_API_BASE_URLS;
const OPENAI_API_KEYS = connections.OPENAI_API_KEYS;
const OPENAI_API_CONFIGS = connections.OPENAI_API_CONFIGS;
const requests = [];
for (const idx in OPENAI_API_BASE_URLS) {
const url = OPENAI_API_BASE_URLS[idx];
if (idx.toString() in OPENAI_API_CONFIGS) {
const apiConfig = OPENAI_API_CONFIGS[idx.toString()] ?? {};
const enable = apiConfig?.enable ?? true;
const modelIds = apiConfig?.model_ids ?? [];
if (enable) {
if (modelIds.length > 0) {
const modelList = {
object: 'list',
data: modelIds.map((modelId) => ({
id: modelId,
name: modelId,
owned_by: 'openai',
openai: { id: modelId },
urlIdx: idx
}))
};
requests.push(
(async () => {
return modelList;
})()
);
} else {
requests.push(
(async () => {
return await getOpenAIModelsDirect(url, OPENAI_API_KEYS[idx])
.then((res) => {
return res;
})
.catch((err) => {
return {
object: 'list',
data: [],
urlIdx: idx
};
});
})()
);
}
} else {
requests.push(
(async () => {
return {
object: 'list',
data: [],
urlIdx: idx
};
})()
);
}
}
}
const responses = await Promise.all(requests);
for (const idx in responses) {
const response = responses[idx];
const apiConfig = OPENAI_API_CONFIGS[idx.toString()] ?? {};
let models = Array.isArray(response) ? response : (response?.data ?? []);
models = models.map((model) => ({ ...model, openai: { id: model.id }, urlIdx: idx }));
const prefixId = apiConfig.prefix_id;
if (prefixId) {
for (const model of models) {
model.id = `${prefixId}.${model.id}`;
}
}
localModels = localModels.concat(models);
}
}
models = models.concat(
localModels.map((model) => ({
...model,
name: model?.name ?? model?.id,
direct: true
}))
);
// Remove duplicates
const modelsMap = {};
for (const model of models) {
modelsMap[model.id] = model;
}
models = Object.values(modelsMap);
}
return models;
};
@@ -880,13 +990,14 @@ export const getChangelog = async () => {
return res;
};
export const getVersionUpdates = async () => {
export const getVersionUpdates = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/version/updates`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
+1 -1
View File
@@ -219,7 +219,7 @@ export const deleteModelById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
error = err;
error = err.detail;
console.log(err);
return null;
+2 -10
View File
@@ -360,12 +360,7 @@ export const generateChatCompletion = async (token: string = '', body: object) =
return [res, controller];
};
export const createModel = async (
token: string,
tagName: string,
content: string,
urlIdx: string | null = null
) => {
export const createModel = async (token: string, payload: object, urlIdx: string | null = null) => {
let error = null;
const res = await fetch(
@@ -377,10 +372,7 @@ export const createModel = async (
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
name: tagName,
modelfile: content
})
body: JSON.stringify(payload)
}
).catch((err) => {
error = err;
+79 -23
View File
@@ -208,6 +208,33 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
return res.OPENAI_API_KEYS;
};
export const getOpenAIModelsDirect = async (url: string, key: string) => {
let error = null;
const res = await fetch(`${url}/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(key && { authorization: `Bearer ${key}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
return res;
};
export const getOpenAIModels = async (token: string, urlIdx?: number) => {
let error = null;
@@ -241,33 +268,62 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => {
export const verifyOpenAIConnection = async (
token: string = '',
url: string = 'https://api.openai.com/v1',
key: string = ''
key: string = '',
direct: boolean = false
) => {
if (!url) {
throw 'OpenAI: URL is required';
}
let error = null;
let res = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/verify`, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
if (direct) {
res = await fetch(`${url}/models`, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json'
}
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
if (error) {
throw error;
}
} else {
res = await fetch(`${OPENAI_API_BASE_URL}/verify`, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
key
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = `OpenAI: ${err?.error?.message ?? 'Network Problem'}`;
return [];
});
if (error) {
throw error;
}
}
return res;
@@ -322,7 +378,7 @@ export const generateOpenAIChatCompletion = async (
return res.json();
})
.catch((err) => {
error = `${err?.detail ?? 'Network Problem'}`;
error = `${err?.detail ?? err}`;
return null;
});
+4 -2
View File
@@ -284,14 +284,16 @@ export const updateUserInfo = async (token: string, info: object) => {
export const getAndUpdateUserLocation = async (token: string) => {
const location = await getUserPosition().catch((err) => {
throw err;
console.log(err);
return null;
});
if (location) {
await updateUserInfo(token, { location: location });
return location;
} else {
throw new Error('Failed to get user location');
console.log('Failed to get user location');
return null;
}
};
+47 -9
View File
@@ -1,12 +1,13 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export const getGravatarUrl = async (email: string) => {
export const getGravatarUrl = async (token: string, email: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/gravatar?email=${email}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
@@ -22,13 +23,14 @@ export const getGravatarUrl = async (email: string) => {
return res;
};
export const formatPythonCode = async (code: string) => {
export const executeCode = async (token: string, code: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/execute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
code: code
@@ -55,13 +57,48 @@ export const formatPythonCode = async (code: string) => {
return res;
};
export const downloadChatAsPDF = async (title: string, messages: object[]) => {
export const formatPythonCode = async (token: string, code: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
code: code
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.log(err);
error = err;
if (err.detail) {
error = err.detail;
}
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadChatAsPDF = async (token: string, title: string, messages: object[]) => {
let error = null;
const blob = await fetch(`${WEBUI_API_BASE_URL}/utils/pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
title: title,
@@ -81,13 +118,14 @@ export const downloadChatAsPDF = async (title: string, messages: object[]) => {
return blob;
};
export const getHTMLFromMarkdown = async (md: string) => {
export const getHTMLFromMarkdown = async (token: string, md: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/utils/markdown`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
md: md
@@ -20,7 +20,9 @@
export let show = false;
export let edit = false;
export let ollama = false;
export let direct = false;
export let connection = null;
@@ -37,7 +39,7 @@
const verifyOllamaHandler = async () => {
const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => {
toast.error(error);
toast.error(`${error}`);
});
if (res) {
@@ -46,9 +48,11 @@
};
const verifyOpenAIHandler = async () => {
const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => {
toast.error(error);
});
const res = await verifyOpenAIConnection(localStorage.token, url, key, direct).catch(
(error) => {
toast.error(`${error}`);
}
);
if (res) {
toast.success($i18n.t('Server connection verified'));
@@ -165,7 +169,7 @@
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={url}
placeholder={$i18n.t('API Base URL')}
@@ -198,7 +202,7 @@
</button>
</Tooltip>
<div class="flex flex-col flex-shrink-0 self-end">
<div class="flex flex-col shrink-0 self-end">
<Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
<Switch bind:state={enable} />
</Tooltip>
@@ -211,7 +215,7 @@
<div class="flex-1">
<SensitiveInput
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
className="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={key}
placeholder={$i18n.t('API Key')}
required={!ollama}
@@ -229,7 +233,7 @@
)}
>
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
bind:value={prefixId}
placeholder={$i18n.t('Prefix ID')}
@@ -254,7 +258,7 @@
<div class=" text-sm flex-1 py-1 rounded-lg">
{modelId}
</div>
<div class="flex-shrink-0">
<div class="shrink-0">
<button
type="button"
on:click={() => {
@@ -288,7 +292,7 @@
<input
class="w-full py-1 text-sm rounded-lg bg-transparent {modelId
? ''
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
bind:value={modelId}
placeholder={$i18n.t('Add a model ID')}
/>
+1 -1
View File
@@ -68,7 +68,7 @@
v{version} - {changelog[version].date}
</div>
<hr class=" dark:border-gray-800 my-2" />
<hr class="border-gray-100 dark:border-gray-850 my-2" />
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="">

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