Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c159a97b7 | ||
|
|
124ad948fe | ||
|
|
947dcd34bd | ||
|
|
710b5270a1 | ||
|
|
2bff50f736 | ||
|
|
e24299e66d | ||
|
|
f047b6b3ae | ||
|
|
368912ca62 | ||
|
|
b1048fc9bc | ||
|
|
9bb226dc52 | ||
|
|
0948235c3b | ||
|
|
bd456ed10b | ||
|
|
223c14f48b | ||
|
|
d0c3180376 | ||
|
|
3ceaa107ab | ||
|
|
144d8b1bb7 | ||
|
|
989938856f | ||
|
|
8913f37c3d | ||
|
|
80b5896b70 | ||
|
|
967b1137dc | ||
|
|
8cd3bd7997 | ||
|
|
ce0ca894fe | ||
|
|
d1975b740b | ||
|
|
459a60a242 | ||
|
|
9a269ec8ab | ||
|
|
d7efdcce2b | ||
|
|
885c94bda8 | ||
|
|
2e1ef805ff | ||
|
|
95b65ff751 | ||
|
|
35bc831077 | ||
|
|
5d4505c685 | ||
|
|
b4f340806a | ||
|
|
7b2f597b30 | ||
|
|
e303c3da3b | ||
|
|
bc5d519c4f | ||
|
|
7cdff6b1e2 | ||
|
|
b04de83c20 | ||
|
|
dfa2511199 | ||
|
|
d4faa5a5ea | ||
|
|
2108f420ea | ||
|
|
42ecdb5407 | ||
|
|
626fcff417 | ||
|
|
e6b00a8905 | ||
|
|
03c6caac1f | ||
|
|
29160741a3 | ||
|
|
7820a311ba | ||
|
|
5eb9b58488 | ||
|
|
51a2d2b701 | ||
|
|
044fd1bd15 | ||
|
|
70a31a9a57 | ||
|
|
c7d1d1e390 | ||
|
|
2d0b94794f | ||
|
|
fbf315e624 | ||
|
|
b9c0a9c3bf | ||
|
|
6d9996e599 | ||
|
|
7806cd5aef | ||
|
|
b3622474d7 | ||
|
|
d8bb8c58d0 | ||
|
|
d93cb3658d | ||
|
|
200fb093b1 | ||
|
|
4ab831b259 | ||
|
|
576ee92438 | ||
|
|
af4500e504 | ||
|
|
016928722c | ||
|
|
73b69ae408 | ||
|
|
80376a3fdc | ||
|
|
305e591ec2 | ||
|
|
39deadcab1 | ||
|
|
2153c8ec9f | ||
|
|
a70c718a0d | ||
|
|
c73efab192 | ||
|
|
ce54b1df23 | ||
|
|
16701befe7 | ||
|
|
8a6af40d9f | ||
|
|
9cf6108527 | ||
|
|
1c1c1c3100 | ||
|
|
def954134c | ||
|
|
c85afce702 | ||
|
|
a25ecfa856 | ||
|
|
47b007ef19 | ||
|
|
04fae8b357 | ||
|
|
1850a985b5 | ||
|
|
339ed1d72e | ||
|
|
fa1ebfa4fd | ||
|
|
0820abbc64 | ||
|
|
b94e1c9458 | ||
|
|
fe58ef69d9 | ||
|
|
cc6b51e5ae | ||
|
|
cd2c315495 | ||
|
|
4b3ed3e802 | ||
|
|
8cd2157564 | ||
|
|
aaa49bdd6d | ||
|
|
8da02c669e | ||
|
|
828656b35f | ||
|
|
3b97c8d89b | ||
|
|
f5ea1ce250 | ||
|
|
a181b4a731 | ||
|
|
114f709337 | ||
|
|
a6fb5a0460 | ||
|
|
7ef181bc13 | ||
|
|
49a2e5bf57 | ||
|
|
4403c7b6c2 | ||
|
|
b081e33c0a | ||
|
|
f4c38e6001 | ||
|
|
c40f26946f | ||
|
|
627b063b88 | ||
|
|
f962bae983 | ||
|
|
e08341dab3 | ||
|
|
890949abe6 | ||
|
|
6e43861c0c | ||
|
|
ad275351b6 | ||
|
|
7d45459a47 | ||
|
|
5af24b3ebe | ||
|
|
a36692b4a2 | ||
|
|
ca2aaf0321 | ||
|
|
79f0437980 | ||
|
|
10daa64d5b | ||
|
|
e0d4c3ec92 | ||
|
|
65fbbf5e35 | ||
|
|
10baa6e781 | ||
|
|
3de14a53c2 | ||
|
|
fe5c02331b | ||
|
|
d040953c76 | ||
|
|
b5c3395f79 | ||
|
|
ed9ab65b5e | ||
|
|
1a2b360d3d | ||
|
|
4f6cb771f1 | ||
|
|
75683e5197 | ||
|
|
8ea35e3bb4 | ||
|
|
44349fb62b | ||
|
|
fe1941c13a | ||
|
|
933a3bbbd3 | ||
|
|
3909b62ffc | ||
|
|
bec227da30 | ||
|
|
11487d66fc | ||
|
|
395098c6f1 | ||
|
|
72951324df | ||
|
|
0c42cd2c01 | ||
|
|
c701ebe07b | ||
|
|
b338850cc1 | ||
|
|
64957db7b3 | ||
|
|
d7147d6cdd | ||
|
|
6137f7cb7e | ||
|
|
832d0181b6 | ||
|
|
d1dd449f63 | ||
|
|
2751a0f0b6 | ||
|
|
a9e9fe7899 | ||
|
|
702906aee7 | ||
|
|
fe837d80e7 | ||
|
|
860a0b414e | ||
|
|
9c9a18d6d4 | ||
|
|
67893b9a57 | ||
|
|
2e8c4da17b | ||
|
|
ff9f761d65 | ||
|
|
6863ca482c | ||
|
|
5645d5bccc | ||
|
|
201b93bfcc | ||
|
|
0c2e4270bc | ||
|
|
80ad5fd2d0 | ||
|
|
9904566513 | ||
|
|
2054ee0b73 | ||
|
|
93bab8d822 | ||
|
|
259d5ca596 | ||
|
|
597883a179 | ||
|
|
387225eb8b | ||
|
|
c83a42198d | ||
|
|
2cacc2e649 | ||
|
|
c9a78e5476 | ||
|
|
2cbba2a28a | ||
|
|
62ab30f593 | ||
|
|
0fff2fbcab | ||
|
|
fcff9c3afd | ||
|
|
d415edcfcd | ||
|
|
5f304e57d2 | ||
|
|
0b851cf55a | ||
|
|
ff86283be0 | ||
|
|
e9011113b4 | ||
|
|
1b89bee098 | ||
|
|
c436e0366c | ||
|
|
1db36b5eda | ||
|
|
3569280c0b | ||
|
|
a0d6c209c3 | ||
|
|
73617ec7fa | ||
|
|
391a4878e6 | ||
|
|
6d7f21b57b | ||
|
|
fe604a8a9b | ||
|
|
a9d8348cf9 | ||
|
|
c37c0e3490 | ||
|
|
ddedceb7ad | ||
|
|
18865a9fef | ||
|
|
769ef856bc | ||
|
|
ed1b959bc6 | ||
|
|
d2b38127d0 | ||
|
|
3d535db304 | ||
|
|
234306ff57 | ||
|
|
ae28e7d245 | ||
|
|
39b87d9683 | ||
|
|
e83f668107 | ||
|
|
7dda8025fc | ||
|
|
1357dc6737 | ||
|
|
43c30428a6 | ||
|
|
668bd44485 | ||
|
|
a3de0bcc58 | ||
|
|
aed2f69efe | ||
|
|
30ae519226 | ||
|
|
499ca282e5 | ||
|
|
40d90286b6 | ||
|
|
2d27ef4ece | ||
|
|
e9b5eb6ed3 | ||
|
|
6b462ff121 | ||
|
|
c3bac9aa62 | ||
|
|
f7226333c3 | ||
|
|
fc5f399573 | ||
|
|
ff8cf80fb5 | ||
|
|
54cefedf53 | ||
|
|
9440d09114 | ||
|
|
5bb1c42fa8 | ||
|
|
242b3f0c01 | ||
|
|
144c0f3d76 | ||
|
|
18401de254 | ||
|
|
c71beb0a7d | ||
|
|
f5bf2a2ed7 | ||
|
|
ab3f03bbd5 | ||
|
|
5ac502e93f | ||
|
|
c60b0fa0e3 | ||
|
|
9544a80aa0 | ||
|
|
83b17e2ac8 | ||
|
|
3a6c88ade9 | ||
|
|
3be06132db | ||
|
|
bbbcf27dd5 | ||
|
|
cfa16e1a37 | ||
|
|
f60d386b74 | ||
|
|
0324a1bbdd | ||
|
|
a677b212d9 | ||
|
|
179a4ad9ea | ||
|
|
2d82d260cc | ||
|
|
e7a9988893 | ||
|
|
6b01f96eac | ||
|
|
965f242d16 | ||
|
|
758d8fcf31 | ||
|
|
0f8b339f6d | ||
|
|
5d821d21f3 | ||
|
|
d6d9d1c535 | ||
|
|
44ab77b4f5 | ||
|
|
646b64a318 | ||
|
|
bbab64b53e | ||
|
|
4731ccb73c | ||
|
|
4737e1f118 | ||
|
|
7ea6afdf95 | ||
|
|
4654ecbf1b | ||
|
|
527d36e13a | ||
|
|
d7d05a4717 | ||
|
|
419ea1c346 | ||
|
|
59214538bb | ||
|
|
eca9b405eb | ||
|
|
58d685eea4 | ||
|
|
44ed941a5d | ||
|
|
46229a93ce | ||
|
|
50eff6a672 | ||
|
|
1cb74b0bf7 | ||
|
|
c303388296 | ||
|
|
5a08084899 | ||
|
|
b1f292965c | ||
|
|
819ea0d9be | ||
|
|
1f77691b01 | ||
|
|
50e6a19957 | ||
|
|
cb0165827f | ||
|
|
c5225039ab | ||
|
|
f2c3fff278 | ||
|
|
3271a5277c | ||
|
|
8b2160f2f7 | ||
|
|
bee13f72ad | ||
|
|
64ff15a536 | ||
|
|
345f3e3559 | ||
|
|
636ab99ad8 | ||
|
|
f0c71e5a6d | ||
|
|
87d33f6e18 | ||
|
|
fd91fa433a | ||
|
|
484ba91b07 | ||
|
|
acb2147024 | ||
|
|
ace69bba75 | ||
|
|
5beb37c57c | ||
|
|
50f95a4f1a | ||
|
|
39e3f8fb81 | ||
|
|
b2413f914a | ||
|
|
9dff497abf | ||
|
|
e3f21d6c3b | ||
|
|
184e921930 | ||
|
|
5ee5093259 | ||
|
|
81781e6495 | ||
|
|
82959cec88 | ||
|
|
9478c5e7ac | ||
|
|
62e7e0bc09 | ||
|
|
7a16e495dd | ||
|
|
958fbdd5c0 | ||
|
|
5c403fb829 | ||
|
|
538501c88d | ||
|
|
0b6c92baa7 | ||
|
|
64ec73635b | ||
|
|
b36e55cf1f | ||
|
|
2461121637 | ||
|
|
e6fe3ba8ef | ||
|
|
0b867590a8 | ||
|
|
3c8d658160 | ||
|
|
176f9a7816 | ||
|
|
3d99de6771 | ||
|
|
a52e6c2d57 | ||
|
|
1808d7fd2f | ||
|
|
f4a1d99f00 | ||
|
|
8f49725aa5 | ||
|
|
febc66ef2b | ||
|
|
3761b3ac28 | ||
|
|
140ab270af | ||
|
|
6ab452a452 | ||
|
|
8962afd586 | ||
|
|
22f074cf59 | ||
|
|
ec4fe4f390 | ||
|
|
32c68e000b | ||
|
|
1ac3dd4a89 | ||
|
|
55c489146c | ||
|
|
ffcf97e3e1 | ||
|
|
95bde946ba | ||
|
|
895c805e62 | ||
|
|
2ed3055c42 | ||
|
|
1792f668f2 | ||
|
|
1d3d3b2d94 | ||
|
|
9044abf3bb | ||
|
|
424dba443c | ||
|
|
aa649bec6b | ||
|
|
a8a3098782 | ||
|
|
238e9da209 | ||
|
|
49a1b37e5d | ||
|
|
c035ff7d14 | ||
|
|
2558fe1a3b | ||
|
|
e7848ec712 | ||
|
|
39e5422d93 | ||
|
|
f6bd54fb1f | ||
|
|
d9fd2a3f30 | ||
|
|
824eeba56c | ||
|
|
e61406c825 | ||
|
|
4853ededca | ||
|
|
8c127a4814 | ||
|
|
6eba27ee9c | ||
|
|
8f0658e64f | ||
|
|
4b3543d3c0 | ||
|
|
d1b39da911 | ||
|
|
053a33631f | ||
|
|
becac2b2b7 | ||
|
|
342aa84bbe | ||
|
|
9e81e1dda1 | ||
|
|
3ad2ea6f28 | ||
|
|
bab64c9d52 | ||
|
|
0185f3340d | ||
|
|
1cd26372fb | ||
|
|
0ca2e46ade | ||
|
|
30a13b9b2f | ||
|
|
f651809001 | ||
|
|
c341f97cfe | ||
|
|
32aabe6bae | ||
|
|
3c54863414 | ||
|
|
ad9fbfc1af | ||
|
|
29217cb430 | ||
|
|
c0096b2a53 | ||
|
|
5b9efeef4d | ||
|
|
e0087acfb4 | ||
|
|
1f474187a7 | ||
|
|
2beeeb90c2 | ||
|
|
d016cc5771 | ||
|
|
16e567df57 | ||
|
|
1542dad51a | ||
|
|
2ef55972ff | ||
|
|
75c5d9b179 | ||
|
|
713fe1afa7 | ||
|
|
f95cff0895 | ||
|
|
a0dbd41551 | ||
|
|
bf0fb1c449 | ||
|
|
7043751ca4 | ||
|
|
2d5ebf962a | ||
|
|
b48594a166 | ||
|
|
74e771fec6 | ||
|
|
08f1c823ad | ||
|
|
b559606387 | ||
|
|
914c7ba876 | ||
|
|
96ca47ac9f | ||
|
|
a0c82c8e4c | ||
|
|
631e30e22d | ||
|
|
c114fd6876 | ||
|
|
c2172e43eb | ||
|
|
1ad3656872 | ||
|
|
ff7f38d343 | ||
|
|
bc482b9cce | ||
|
|
4c94f5d434 | ||
|
|
4b9f821b58 | ||
|
|
35598b8017 | ||
|
|
45e23c3ad0 | ||
|
|
5759917f54 | ||
|
|
d247adb60c | ||
|
|
8c713a171d | ||
|
|
7e42d727e8 | ||
|
|
9f7dd31e12 | ||
|
|
5d4547f934 | ||
|
|
5522b91c32 | ||
|
|
3242dad8ae | ||
|
|
6d8a6e6d8b | ||
|
|
8265422ba0 | ||
|
|
10c13b686c | ||
|
|
b1dc58ddb7 | ||
|
|
a9312d2537 | ||
|
|
4228bf71c4 | ||
|
|
ac620118c1 | ||
|
|
d650c987ec | ||
|
|
092a358b3c | ||
|
|
ae05586fda | ||
|
|
f5e5632afc | ||
|
|
2a804541e0 | ||
|
|
8c485b260f | ||
|
|
d664922feb | ||
|
|
9950cc8c28 | ||
|
|
3db6d49e57 | ||
|
|
6d67ac371d | ||
|
|
326599b8db | ||
|
|
c5c31ab769 | ||
|
|
43eb2351d2 | ||
|
|
0a700aafe4 | ||
|
|
91a0301c9e | ||
|
|
6ac593209c | ||
|
|
12bea8cd88 | ||
|
|
1dfe546b6b | ||
|
|
ff837031e4 | ||
|
|
139f02a9d9 | ||
|
|
4bef69cc63 | ||
|
|
723185c22f | ||
|
|
35763a352c | ||
|
|
27c76c677a | ||
|
|
2f1344d619 | ||
|
|
8bfab327ec | ||
|
|
56246324b2 | ||
|
|
af5661c2c8 | ||
|
|
f872a178bc | ||
|
|
3dd44c4f19 | ||
|
|
094ed0b48c | ||
|
|
9b55343509 | ||
|
|
8a7f698e9d | ||
|
|
990c638f6c | ||
|
|
a0195cd5ae | ||
|
|
e9d852545c | ||
|
|
49c36238d0 | ||
|
|
74988189b8 | ||
|
|
b8112d72b9 | ||
|
|
71ccedd2bf | ||
|
|
4225791313 | ||
|
|
e5cd1b479b | ||
|
|
61d44aa773 | ||
|
|
ef036529b5 | ||
|
|
e0bdef85ab | ||
|
|
2ce935bdb1 | ||
|
|
23b1e2cca4 | ||
|
|
05b8768fb9 | ||
|
|
173d5631ca | ||
|
|
34cd3d79e8 | ||
|
|
aede1b7a08 | ||
|
|
15b893e651 | ||
|
|
4896d30281 | ||
|
|
9be45f49e4 | ||
|
|
f1053d94c7 | ||
|
|
10cfddccd7 | ||
|
|
e5e39be90f | ||
|
|
337109e99c | ||
|
|
8adf2b33b4 | ||
|
|
1984ce42aa | ||
|
|
656de56a3e | ||
|
|
0b2abe6cb8 | ||
|
|
f364b2d205 | ||
|
|
519ff40cb6 |
@@ -88,9 +88,11 @@ This is to ensure large feature PRs are discussed with the community first, befo
|
||||
🚨 DO NOT DELETE THE TEXT BELOW 🚨
|
||||
Keep the "Contributor License Agreement" confirmation text intact.
|
||||
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
|
||||
|
||||
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
|
||||
-->
|
||||
|
||||
By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
- [ ] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
|
||||
|
||||
> [!NOTE]
|
||||
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
|
||||
|
||||
@@ -27,28 +27,17 @@ jobs:
|
||||
echo "::set-output name=version::$VERSION"
|
||||
|
||||
- name: Extract latest CHANGELOG entry
|
||||
id: changelog
|
||||
run: |
|
||||
CHANGELOG_CONTENT=$(awk 'BEGIN {print_section=0;} /^## \[/ {if (print_section == 0) {print_section=1;} else {exit;}} print_section {print;}' CHANGELOG.md)
|
||||
CHANGELOG_ESCAPED=$(echo "$CHANGELOG_CONTENT" | sed ':a;N;$!ba;s/\n/%0A/g')
|
||||
echo "Extracted latest release notes from CHANGELOG.md:"
|
||||
echo -e "$CHANGELOG_CONTENT"
|
||||
echo "::set-output name=content::$CHANGELOG_ESCAPED"
|
||||
VERSION="${{ steps.get_version.outputs.version }}"
|
||||
awk "/^## \[${VERSION}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const changelog = `${{ steps.changelog.outputs.content }}`;
|
||||
const release = await github.rest.repos.createRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `v${{ steps.get_version.outputs.version }}`,
|
||||
name: `v${{ steps.get_version.outputs.version }}`,
|
||||
body: changelog,
|
||||
})
|
||||
console.log(`Created release ${release.data.html_url}`)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release create "v${{ steps.get_version.outputs.version }}" \
|
||||
--title "v${{ steps.get_version.outputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
|
||||
- name: Upload package to GitHub release
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Deploy to HuggingFace Spaces
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-secret:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
token-set: ${{ steps.check-key.outputs.defined }}
|
||||
steps:
|
||||
- id: check-key
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
if: "${{ env.HF_TOKEN != '' }}"
|
||||
run: echo "defined=true" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-secret]
|
||||
if: needs.check-secret.outputs.token-set == 'true'
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Remove git history
|
||||
run: rm -rf .git
|
||||
|
||||
- name: Prepend YAML front matter to README.md
|
||||
run: |
|
||||
echo "---" > temp_readme.md
|
||||
echo "title: Open WebUI" >> temp_readme.md
|
||||
echo "emoji: 🐳" >> temp_readme.md
|
||||
echo "colorFrom: purple" >> temp_readme.md
|
||||
echo "colorTo: gray" >> temp_readme.md
|
||||
echo "sdk: docker" >> temp_readme.md
|
||||
echo "app_port: 8080" >> temp_readme.md
|
||||
echo "---" >> temp_readme.md
|
||||
cat README.md >> temp_readme.md
|
||||
mv temp_readme.md README.md
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
- name: Set up Git and push to Space
|
||||
run: |
|
||||
git init --initial-branch=main
|
||||
git lfs install
|
||||
git lfs track "*.ttf"
|
||||
git lfs track "*.jpg"
|
||||
rm demo.png
|
||||
rm banner.png
|
||||
git add .
|
||||
git commit -m "GitHub deploy: ${{ github.sha }}"
|
||||
git push --force https://open-webui:${HF_TOKEN}@huggingface.co/spaces/open-webui/open-webui main
|
||||
@@ -95,6 +95,7 @@ jobs:
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
|
||||
@@ -199,6 +200,7 @@ jobs:
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
@@ -304,6 +306,7 @@ jobs:
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_CUDA=true
|
||||
@@ -407,6 +410,7 @@ jobs:
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_OLLAMA=true
|
||||
@@ -509,6 +513,7 @@ jobs:
|
||||
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
|
||||
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
|
||||
sbom: true
|
||||
build-args: |
|
||||
BUILD_HASH=${{ github.sha }}
|
||||
USE_SLIM=true
|
||||
@@ -804,3 +809,109 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Copy images from GHCR to Docker Hub (best-effort, won't block GHCR)
|
||||
copy-to-dockerhub:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [merge-main-images, merge-cuda-images, merge-cuda126-images, merge-ollama-images, merge-slim-images]
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: main
|
||||
suffix: ""
|
||||
- variant: cuda
|
||||
suffix: "-cuda"
|
||||
- variant: cuda126
|
||||
suffix: "-cuda126"
|
||||
- variant: ollama
|
||||
suffix: "-ollama"
|
||||
- variant: slim
|
||||
suffix: "-slim"
|
||||
steps:
|
||||
- name: Set repository and image name to lowercase
|
||||
run: |
|
||||
echo "IMAGE_NAME=${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
echo "FULL_IMAGE_NAME=ghcr.io/${IMAGE_NAME,,}" >>${GITHUB_ENV}
|
||||
env:
|
||||
IMAGE_NAME: '${{ github.repository }}'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine source and destination tags
|
||||
id: tags
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="openwebui/open-webui"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# For version tags: copy version tag and major.minor tag
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR="${VERSION%.*}"
|
||||
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "${VERSION}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${MAJOR_MINOR}${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# For main branch
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
echo "tags=latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# e.g. latest-cuda -> also tag as just "cuda"
|
||||
VARIANT_NAME="${SUFFIX#-}"
|
||||
echo "tags<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "latest${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "${VARIANT_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "dockerhub_image=${DOCKERHUB_IMAGE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Copy images from GHCR to Docker Hub
|
||||
run: |
|
||||
DOCKERHUB_IMAGE="${{ steps.tags.outputs.dockerhub_image }}"
|
||||
SUFFIX="${{ matrix.suffix }}"
|
||||
|
||||
# Determine the source tag on GHCR
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
SOURCE_TAG="${VERSION}${SUFFIX}"
|
||||
else
|
||||
if [ -z "$SUFFIX" ]; then
|
||||
SOURCE_TAG="latest"
|
||||
else
|
||||
SOURCE_TAG="latest${SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
SOURCE="${{ env.FULL_IMAGE_NAME }}:${SOURCE_TAG}"
|
||||
|
||||
echo "Copying from ${SOURCE} to Docker Hub..."
|
||||
|
||||
# Copy each destination tag
|
||||
while IFS= read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
DEST="${DOCKERHUB_IMAGE}:${TAG}"
|
||||
echo " -> ${DEST}"
|
||||
docker buildx imagetools create -t "${DEST}" "${SOURCE}"
|
||||
done <<< "${{ steps.tags.outputs.tags }}"
|
||||
|
||||
+300
@@ -5,6 +5,306 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.8.9] - 2026-03-07
|
||||
|
||||
### Added
|
||||
|
||||
- ▶️ **Open Terminal notebook cell execution.** Users can now run Jupyter Notebook code cells directly in the Open Terminal file navigator, execute entire notebooks with a single click, edit and modify cells before running, and control the kernel - bringing full interactive notebook execution to the browser. [Commit](https://github.com/open-webui/open-webui/commit/4b3ed3e802d6f2ec8ee7caf358af810b7d09f789)
|
||||
- 🗃️ **Open Terminal SQLite browser.** Users can now browse SQLite database files directly in the Open Terminal file navigator, viewing tables and running queries without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/a181b4a731a9ec7856be08d0b045a454d1341cf4)
|
||||
- 📉 **Open Terminal Mermaid diagram rendering.** Markdown files with Mermaid code blocks are now rendered as diagrams directly in the Open Terminal file navigator, making it easier to visualize flowcharts and other diagrams. [Commit](https://github.com/open-webui/open-webui/commit/aaa49bdd6d6e5c10e8be554039d3cac673008fc2)
|
||||
- 📓 **Open Terminal Jupyter Notebook previews.** Users can now preview Jupyter Notebook files directly in the Open Terminal file navigator, making it easier to view notebook content without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/b081e33c0a37585a1ee60b6e0e1ea03457f1e5f4)
|
||||
- 🔃 **Open Terminal auto-refresh.** The Open Terminal file navigator now automatically refreshes when the model writes or modifies files, keeping the view in sync without manual refresh. [Commit](https://github.com/open-webui/open-webui/commit/828656b35f04bf486609183799cf8aa2e9850a76)
|
||||
- 📎 **Open Terminal file copy button.** Users can now copy file contents directly to clipboard in the Open Terminal file navigator with a single click, making it easier to quickly grab file content without downloading. [Commit](https://github.com/open-webui/open-webui/commit/f5ea1ce250cb02fbc583c6cb3f52a923912d0178)
|
||||
- 💻 **Code syntax highlighting and XLSX improvements in Open Terminal.** Code files now display with syntax highlighting in the Open Terminal file navigator, and XLSX spreadsheets now show column headers and row numbers for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/f962bae98306ea9264967b78b803397f4821f9b0)
|
||||
- 🌳 **Open Terminal JSON tree view.** JSON, JSONC, JSONL, and JSON5 files now display as interactive collapsible tree views in the Open Terminal file navigator, and SVG files render as preview images with syntax highlighting support. [Commit](https://github.com/open-webui/open-webui/commit/f4c38e6001dd9d4853ed923e0bc5e790c4fd9941)
|
||||
- 🛜 **Open Terminal port viewing.** Users can now view listening ports in the Open Terminal file navigator and open proxy connections to them directly from the UI. [Commit](https://github.com/open-webui/open-webui/commit/e08341dab3bb10e26a64eb44cbebd2d507087b03)
|
||||
- 🎬 **Open Terminal video previews.** Users can now preview video and audio files directly in the Open Terminal file navigator, making it easier to view media without downloading them first. [Commit](https://github.com/open-webui/open-webui/commit/c40f26946f2eaeb1587a1f8b0c643b4a5121fc06)
|
||||
- ✏️ **Open Terminal HTML editing.** Users can now edit HTML source files in Open Terminal with CodeMirror editor, and the save button is properly hidden in preview mode. [Commit](https://github.com/open-webui/open-webui/commit/7806cd5aef9fb0505b2c642ef70599a403cf14ba)
|
||||
- 📄 **Open Terminal DOCX preview.** Word documents generated or modified by the AI can now be viewed directly in the file navigator with formatted text, tables, and images rendered inline — no need to download and open in a separate application. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
|
||||
- 📊 **Open Terminal XLSX preview.** Excel spreadsheets in the file navigator now render as interactive tables with column headers and row numbers, making it easy to verify data the AI has generated or processed. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
|
||||
- 📽️ **Open Terminal PPTX preview.** PowerPoint presentations created by the AI can now be viewed slide-by-slide directly in the file navigator, enabling quick review and iteration without leaving the browser. [Commit](https://github.com/open-webui/open-webui/commit/890949abe6b01d201355a86c50317e20da07dd34)
|
||||
- 📁 **Pyodide file system support.** Users can now upload files for Python code execution in the code interpreter. Uploaded files are available in the `/mnt/uploads/` directory, and code can write output files there for download. The file system persists across code executions within the same session. The code interpreter now also informs models that pip install is not available in the Pyodide environment, guiding them to use alternative approaches with available modules. [#3583](https://github.com/open-webui/open-webui/issues/3583), [Commit](https://github.com/open-webui/open-webui/commit/ce0ca894fea8a2904bc6f832ff186d5fe53dd0b9), [Commit](https://github.com/open-webui/open-webui/commit/989938856fdb4b4afa584ae2d18c88d5be614ae2)
|
||||
- 🧰 **Tool files access.** Tools can now access the files from the current chat context via the files property in their metadata, enabling more powerful tool integrations. [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0)
|
||||
- ⚡ **Chat performance.** Chat messages now load and display significantly faster thanks to optimized markdown rendering, eliminating delays when viewing messages with mathematical expressions. [#22196](https://github.com/open-webui/open-webui/pull/22196), [#20878](https://github.com/open-webui/open-webui/discussions/20878)
|
||||
- 📜 **Message list performance.** Improved message list rendering performance by optimizing array operations, reducing complexity from O(n²) to O(n). [#22280](https://github.com/open-webui/open-webui/pull/22280)
|
||||
- 🧵 **Streaming markdown performance.** Improved chat responsiveness during streaming by skipping unnecessary markdown re-parsing when the content hasn't changed, eliminating wasted processing during model pauses. [#22183](https://github.com/open-webui/open-webui/pull/22183)
|
||||
- 🏃 **Chat streaming performance.** Chat streaming is now faster for users not using the voice call feature by skipping unnecessary text parsing that was running on every token. [#22195](https://github.com/open-webui/open-webui/pull/22195)
|
||||
- 🔖 **Source list performance.** Source lists in chat now render faster thanks to optimized computation that avoids unnecessary recalculations, including moving sourceIds computation to a reactive variable. [#22279](https://github.com/open-webui/open-webui/pull/22279), [Commit](https://github.com/open-webui/open-webui/commit/88af78c), [Commit](https://github.com/open-webui/open-webui/commit/339ed1d72e100c89d8eb26de761dfefe842ef90c)
|
||||
- 💨 **Chat message tree operations.** Chat message tree operations are now significantly faster, improving overall chat responsiveness. [#22194](https://github.com/open-webui/open-webui/pull/22194)
|
||||
- 🚀 **Initial page load speed.** Page load is now significantly faster thanks to deferred loading of the syntax highlighting library, reducing the initial JavaScript bundle by several megabytes. [#22304](https://github.com/open-webui/open-webui/pull/22304)
|
||||
- 🗓️ **Action priority query optimization.** Improved performance of action priority resolution by fixing an N+1 query pattern, reducing database round-trips when loading model actions. [#22301](https://github.com/open-webui/open-webui/pull/22301)
|
||||
- 🔑 **API key middleware optimization.** The API key restriction middleware was converted to a pure ASGI middleware for improved streaming performance, removing per-chunk call overhead. [#22188](https://github.com/open-webui/open-webui/pull/22188)
|
||||
- 🏎️ **Model list loading performance.** Model lists now load significantly faster thanks to optimized custom model matching that uses dictionary lookups instead of nested loops. [#22299](https://github.com/open-webui/open-webui/pull/22299), [Commit](https://github.com/open-webui/open-webui/commit/29160741a3defa8768a43100cb6e63c56400279c), [Commit](https://github.com/open-webui/open-webui/commit/03c6caac1fc8625f85cf1164f5a977be8005c1bc)
|
||||
- ⏱️ **Event call timeout configuration.** Administrators can now configure the WebSocket event call timeout via the WEBSOCKET_EVENT_CALLER_TIMEOUT environment variable, giving users more time to respond to event_call forms instead of timing out after 60 seconds. [#22222](https://github.com/open-webui/open-webui/pull/22222), [#22220](https://github.com/open-webui/open-webui/issues/22220)
|
||||
- 🔁 **File refresh button visibility.** The refresh button in the chat file navigator now appears when viewing files as well as directories, allowing users to refresh the file view at any time. [Commit](https://github.com/open-webui/open-webui/commit/49a2e5bf573415dae6d4c7e5bd635e499c8de77a)
|
||||
- 📂 **Nested folders support.** Users can now create subfolders within parent folders, improving organization of chats. A new "Create Subfolder" option is available in the folder context menu. [#22073](https://github.com/open-webui/open-webui/pull/22073), [Commit](https://github.com/open-webui/open-webui/commit/8913f37c3d8fde7dea6d54a550357f1d495b3941)
|
||||
- 🔔 **Banner loading on navigation.** Admin-configured banners now load when navigating to the homepage, not just on page refresh, ensuring users see new banners immediately. [#22340](https://github.com/open-webui/open-webui/pull/22340), [#22180](https://github.com/open-webui/open-webui/issues/22180)
|
||||
- 📡 **System metrics via OpenTelemetry.** Administrators can now monitor Python runtime and system metrics including CPU, memory, garbage collection, and thread counts through the existing OpenTelemetry pipeline. [#22265](https://github.com/open-webui/open-webui/pull/22265)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for French, Finnish, Turkish, German, Simplified Chinese, and Traditional Chinese were enhanced and expanded.
|
||||
- 🔍 **Web search tool guidance.** The web search tool description was updated to encourage direct usage without first checking knowledge bases, making it clearer for users who want to search the web immediately. [#22264](https://github.com/open-webui/open-webui/pull/22264)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🗄️ **Migration memory usage.** Database migration on large deployments now processes messages in batches instead of loading everything into memory, preventing out-of-memory errors during upgrades. [#21542](https://github.com/open-webui/open-webui/pull/21542), [#21539](https://github.com/open-webui/open-webui/discussions/21539)
|
||||
- 🔒 **SQLCipher connection stability.** Fixed a crash that occurred when using database encryption with SQLCipher by changing the default connection pool behavior, ensuring stable operation during multi-threaded operations like user signup. [#22273](https://github.com/open-webui/open-webui/pull/22273), [#22258](https://github.com/open-webui/open-webui/issues/22258)
|
||||
- 🛑 **Stop sequence error.** Fixed a bug where setting stop sequences on a model caused the chat to fail with a split error, preventing any responses from being returned. The fix handles both string and array formats for stop tokens. [#22251](https://github.com/open-webui/open-webui/issues/22251), [Commit](https://github.com/open-webui/open-webui/commit/c7d1d1e390a79c6c86d4bfe439fd7de6f5fb060f)
|
||||
- 🔐 **Microsoft OAuth refresh token fix.** Fixed a bug where Microsoft OAuth refresh token requests failed with error AADSTS90009 by adding support for the required scope parameter. Users can now stay logged in reliably with Microsoft OAuth. [#22359](https://github.com/open-webui/open-webui/pull/22359)
|
||||
- 🛠️ **Parameterless tool calls.** Fixed parameterless tool calls failing during streaming by correcting the default arguments initialization, eliminating unnecessary model retries. [#22189](https://github.com/open-webui/open-webui/pull/22189)
|
||||
- 🔧 **Tool call streaming fixes.** Fixed two bugs where streaming tool calls failed silently for models like GPT-5: function names were incorrectly duplicated when sent in multiple delta chunks, and arguments containing multiple JSON objects were not properly split. Tools now execute correctly instead of failing without explanation. [#22177](https://github.com/open-webui/open-webui/issues/22177), [Commit](https://github.com/open-webui/open-webui/commit/d7efdcce2b1cdbe1637a469294bf9d52dbacab53), [Commit](https://github.com/open-webui/open-webui/commit/459a60a24240eab33441ed50f4f68cc27e65a037)
|
||||
- 🔗 **Tool server URL trailing slash.** Fixed tool server connection failures when URLs have trailing slashes by stripping them before path concatenation. Previously, URLs like "http://host:8080/v1/" + "/openapi.json" produced double-slash URLs that some servers rejected. [#22116](https://github.com/open-webui/open-webui/pull/22116), [#21917](https://github.com/open-webui/open-webui/issues/21917)
|
||||
- 🛡️ **Citation parser error handling.** Fixed crashes when tools return error strings instead of expected data structures by adding type guards to the citation parser. The system now returns an empty source list instead of crashing with AttributeError. [#22118](https://github.com/open-webui/open-webui/pull/22118)
|
||||
- 🧠 **Artifacts memory leak.** Fixed a memory leak where Svelte store subscriptions in the Artifacts component were not properly cleaned up when the component unmounted, causing memory to accumulate over time. [#22303](https://github.com/open-webui/open-webui/pull/22303)
|
||||
- ♾️ **Artifacts reactive loop fix.** Fixed an infinite reactive loop in chat when artifacts are present by moving the animation frame logic outside the reactive block, preventing continuous re-rendering and CPU usage. [#22238](https://github.com/open-webui/open-webui/pull/22238), [Commit](https://github.com/open-webui/open-webui/commit/626fcff417afba642f4f71e0498267a21435c524)
|
||||
- 🔀 **Artifact navigation.** Artifact navigation via arrow buttons now works correctly; the selected artifact is no longer reset when content updates. [#22239](https://github.com/open-webui/open-webui/pull/22239)
|
||||
- 🧩 **Artifact thinking block fix.** Fixed a bug where HTML preview rendered code blocks inside thinking blocks for certain models like Mistral and Z.ai, causing stray code with ">" symbols to appear before the actual artifact. The fix strips thinking blocks before extracting code for artifact rendering. [#22267](https://github.com/open-webui/open-webui/issues/22267), [Commit](https://github.com/open-webui/open-webui/commit/35bc8310772c222fd8a466f7d00113a84e0402d0)
|
||||
- 💬 **Floating Quick Actions availability.** Fixed an issue where the "Ask" and "Explain" Floating Quick Actions were missing when selecting text in chats that used a model that is no longer available. [#22149](https://github.com/open-webui/open-webui/pull/22149), [#22139](https://github.com/open-webui/open-webui/issues/22139)
|
||||
- 💡 **Follow-up suggestions.** Fixed follow-up suggestions not appearing by correcting contradictory format instructions in the prompt template, ensuring the LLM returns the correct JSON object format. [#22212](https://github.com/open-webui/open-webui/pull/22212)
|
||||
- 🔊 **TTS thinking content.** Fixed TTS playback reading think tags instead of skipping them by handling edge cases where code blocks inside thinking content prevented proper tag removal. [#22237](https://github.com/open-webui/open-webui/pull/22237), [#22197](https://github.com/open-webui/open-webui/issues/22197)
|
||||
- 🎨 **Button spinner alignment.** Button spinners across multiple modals now align correctly and stay on the same line as the button text, fixing layout issues when loading states are displayed. [#22227](https://github.com/open-webui/open-webui/pull/22227)
|
||||
- 📶 **Terminal keepalive.** Terminal connections now stay active without being closed by idle timeouts from proxies or load balancers, and spurious disconnection messages no longer appear. [Commit](https://github.com/open-webui/open-webui/commit/ca2aaf0321c219d041e92e2c0c842a4e424732ef)
|
||||
- 📥 **Chat archive handler.** The archive button in the chat navbar now actually archives the chat and refreshes the chat list, instead of doing nothing. [#22229](https://github.com/open-webui/open-webui/pull/22229)
|
||||
- 🐍 **BeautifulSoup4 dependency.** Added the missing BeautifulSoup4 package to backend requirements, fixing failures when using features that depend on HTML parsing. [#22231](https://github.com/open-webui/open-webui/pull/22231)
|
||||
- 👥 **Group users default sort.** Group members in the admin panel now sort by last active time by default instead of creation date, making it easier to find active users. [#22211](https://github.com/open-webui/open-webui/pull/22211)
|
||||
- 🔓 **Tool access permissions.** Users can now change tool and skill access permissions from private to public without errors. [#22325](https://github.com/open-webui/open-webui/pull/22325), [#22324](https://github.com/open-webui/open-webui/issues/22324)
|
||||
- 🖥️ **Open Terminal permission fix.** Open Terminal is now visible without requiring "Allow Speech to Text" permission, fixing an issue where users without microphone access couldn't access the terminal feature. [#22374](https://github.com/open-webui/open-webui/issues/22374), [Commit](https://github.com/open-webui/open-webui/commit/70a31a9a57bdd0690ac270f31ebd1b46e8fdfa98)
|
||||
- 📌 **Stale pinned models cleanup.** Pinned models that are deleted or hidden are now automatically unpinned, keeping your pinned models list up to date. [Commit](https://github.com/open-webui/open-webui/commit/af4500e5040c8343d339cd88dd1d2fb6138c7a72)
|
||||
- 📏 **OpenTelemetry metric descriptions.** Fixed conflicting metric instrument descriptions that caused warnings in the OpenTelemetry collector, resulting in cleaner telemetry logs for administrators. [#22293](https://github.com/open-webui/open-webui/pull/22293)
|
||||
- 🔢 **Non-streaming token tracking.** Token usage from non-streaming chat responses is now correctly saved to the database, fixing missing token counts in the Admin Panel analytics. Previously, non-streaming responses saved NULL usage data, causing messages to be excluded from token aggregation queries. [#22166](https://github.com/open-webui/open-webui/pull/22166)
|
||||
- ⌨️ **Inline code typing.** Fixed a bug where typing inline code with backticks incorrectly deleted the character immediately before the opening backtick, so text formatted as inline code now correctly produces the full word instead of missing the last character. [#20417](https://github.com/open-webui/open-webui/issues/20417), [Commit](https://github.com/open-webui/open-webui/commit/e303c3da3b174da9e92a79b174f85ba574ca06ef)
|
||||
- 📝 **Variable input newlines.** Fixed a bug where variables containing newlines were not displayed correctly in chat messages, and input values from Windows systems are now properly normalized to use standard line endings. [#21447](https://github.com/open-webui/open-webui/issues/21447), [Commit](https://github.com/open-webui/open-webui/commit/7b2f597b30c77ef300d1966e1c6a3edfdb0c465d)
|
||||
- 📷 **Android photo capture.** Fixed an issue where the first photo taken in chat appeared completely black on some Android devices by using an alternative canvas export method. [#22317](https://github.com/open-webui/open-webui/pull/22317)
|
||||
- 🪟 **Open Terminal Windows path fix.** Fixed a bug where navigating back to parent directories on Windows added an incorrect leading slash, causing directory loads to fail. Paths are now properly normalized for Windows drive letters. [#22352](https://github.com/open-webui/open-webui/issues/22352), [Commit](https://github.com/open-webui/open-webui/commit/044fd1bd15cae06a5c56a321ca79d8362942f66a)
|
||||
- 🖼️ **Chat overview profile image sizing.** Fixed a bug where profile images in the chat overview could shrink incorrectly in tight spaces. The images now maintain their proper size with the flex-shrink-0 property. [#22261](https://github.com/open-webui/open-webui/pull/22261)
|
||||
- 📨 **Queued messages display.** Fixed an issue where queued messages could be cut off or hidden. The queued messages area now scrolls properly when content exceeds the visible area, showing up to 25% of the viewport height. [#22176](https://github.com/open-webui/open-webui/pull/22176)
|
||||
- 🖌️ **Image generation in temporary chats.** Generated images now display correctly in temporary chat mode when using builtin image generation tools. Previously, images were not shown because the code was overwriting the image list with a null database response. [#22330](https://github.com/open-webui/open-webui/pull/22330), [#22309](https://github.com/open-webui/open-webui/issues/22309)
|
||||
- 🤖 **Ollama model unload fix.** Fixed a bug where unloading a model from Ollama via the Open WebUI proxy failed with a "Field required" error for the prompt field. The proxy now correctly allows omitting the prompt when using keep_alive: 0 to unload models. [#22260](https://github.com/open-webui/open-webui/issues/22260), [Commit](https://github.com/open-webui/open-webui/commit/95b65ff751f91131b633cb128ff2decdd87c4a85)
|
||||
- 🏷️ **Banner type dropdown fix.** Fixed a bug where selecting a banner type required two clicks to register, as the first selection was being swallowed due to DOM structure changes. The dropdown now works correctly on the first click. [#22378](https://github.com/open-webui/open-webui/pull/22378)
|
||||
- 📈 **Analytics URL encoding fix.** Fixed a bug where the Analytics page failed to load data for models with slashes in their ID, such as "anthropic/claude-opus-4.6". The frontend now properly URL-encodes forward slashes, allowing model analytics to load correctly. [#22380](https://github.com/open-webui/open-webui/issues/22380), [#22382](https://github.com/open-webui/open-webui/pull/22382)
|
||||
- 📋 **Analytics chat list duplicate fix.** Fixed a bug where the Analytics page chat list threw an "each_key_duplicate" Svelte error when chat IDs were duplicated during pagination. The fix adds deterministic ordering to prevent duplicate entries. [#22383](https://github.com/open-webui/open-webui/pull/22383)
|
||||
- 📂 **Folder knowledge base native tool call fix.** Fixed a bug where folders with attached knowledge bases were querying the knowledge base twice when using native tool call mode. The fix now correctly separates knowledge files from regular attachments, letting the builtin query_knowledge_files tool handle knowledge searches instead of duplicating RAG queries. [#22236](https://github.com/open-webui/open-webui/issues/22236), [Commit](https://github.com/open-webui/open-webui/commit/967b1137dcb7a52615f17d086ee89095bb9b60f3), [Commit](https://github.com/open-webui/open-webui/commit/80b5896b70d07ea868e2010b187430d43c9808f0)
|
||||
|
||||
## [0.8.8] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- 📁 **Open Terminal file moving.** Users can now move files and folders between directories in the Open Terminal file browser by dragging and dropping them. [Commit](https://github.com/open-webui/open-webui/commit/0c42cd2c012f9f49816adac897e2b46573b3cb6c), [Commit](https://github.com/open-webui/open-webui/commit/72951324dfeef64e09f4776898d675bc1c44f040), [Commit](https://github.com/open-webui/open-webui/commit/395098c6f1b7499d37ad55145a5931431d3e72e9), [Commit](https://github.com/open-webui/open-webui/commit/11487d66fc1a2dfafbdaa2b7ef939a86caaf3872)
|
||||
- 📄 **Open Terminal HTML file preview.** Users can now preview HTML files directly in the Open Terminal file browser, with a rendered iframe view and source toggle, enabling iterative AI editing of HTML files. [Commit](https://github.com/open-webui/open-webui/commit/3909b62ffcf49839fa57346ed8487ae759811503), [Commit](https://github.com/open-webui/open-webui/commit/933a3bbbd3f4fc3eeb0ec52c7965e9ac1c4cea39)
|
||||
- 🌐 **Open Terminal WebSocket proxy.** Added a new WebSocket proxy endpoint for interactive terminal sessions, enabling real-time bidirectional terminal communication with the terminal server. [Commit](https://github.com/open-webui/open-webui/commit/4f6cb771f1afded09aad6199cdb244dd8a6c77a6)
|
||||
- ⚙️ **Open Terminal feature toggle.** Administrators can now enable or disable the Interactive Terminal feature for Open Terminal via configuration on the terminal server, controlling access to terminal routes. [Commit](https://github.com/open-webui/open-webui/commit/b5c3395f79bcc7ff5bc1d82bb86a60583bb3b5bd)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Simplified Chinese, Traditional Chinese, Irish, and Catalan were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔧 **Middleware variable shadowing.** Fixed a variable shadowing issue in the middleware that could cause incorrect tool output processing during chat. [#22145](https://github.com/open-webui/open-webui/pull/22145)
|
||||
- ⚡ **ChatControls reactivity fix.** Fixed a Svelte reactivity issue where the active tab state in the ChatControls panel was not properly saved when switching between chats. [#22127](https://github.com/open-webui/open-webui/pull/22127)
|
||||
- 🔧 **ChatControls TypeScript fix.** Fixed a TypeScript syntax error in ChatControls.svelte where the module script block was missing lang="ts", causing esbuild to fail during vite dev. [#22131](https://github.com/open-webui/open-webui/pull/22131)
|
||||
- 🔌 **Open Terminal tools for direct connections.** Fixed an issue where Open Terminal tools were not available to the model when the terminal was configured via direct connection settings, ensuring users can now interact with terminal files and operations through the AI. [#22137](https://github.com/open-webui/open-webui/issues/22137)
|
||||
- 📜 **Chat history pagination.** Fixed an issue where older messages in long chats were not loaded when scrolling to the top. [Commit](https://github.com/open-webui/open-webui/commit/d7147d6cddfd314f0f1be77b15cec406a609ef36), [Commit](https://github.com/open-webui/open-webui/commit/c701ebe07bd152eecb42b0bf6de26071358a5c76)
|
||||
- 🔧 **Terminal tool null parameter handling.** Fixed a bug where null parameters in terminal tool calls were sent as the string "None" instead of being omitted, causing 422 validation errors from the open-terminal server. [#22124](https://github.com/open-webui/open-webui/issues/22124), [#22144](https://github.com/open-webui/open-webui/pull/22144)
|
||||
|
||||
### Changed
|
||||
|
||||
## [0.8.7] - 2026-03-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒 **Connection access control privacy.** Tool server and terminal connections without explicit access grants are now private (admin-only) by default, fixing a bug where connections configured with no access grants were visible to all users instead of being restricted. [Commit](https://github.com/open-webui/open-webui/commit/2751a0f0b)
|
||||
- 🧠 **ChatControls memory leak.** The ChatControls panel no longer leaks event listeners, ResizeObserver instances, and media query handlers when navigating between chats, fixing memory accumulation that could degrade performance during extended use. [#22112](https://github.com/open-webui/open-webui/pull/22112)
|
||||
- 💾 **Temporary chat params preservation.** Model parameters are now correctly saved when creating a temporary chat, ensuring custom settings like temperature and top_p persist across the session. [Commit](https://github.com/open-webui/open-webui/commit/fe837d80e)
|
||||
- ⚡ **Faster artifact content updates.** Artifact content extraction during streaming is now debounced via requestAnimationFrame, reducing redundant DOM reads and improving CPU efficiency when tokens arrive faster than the browser can paint. [Commit](https://github.com/open-webui/open-webui/commit/6863ca482)
|
||||
|
||||
## [0.8.6] - 2026-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- 🖥️ **Open Terminal integration.** Users can now connect to [Open Terminal](https://github.com/open-webui/open-terminal) instances to browse, read, and upload files directly in chat, with the terminal acting as an always-on tool. File navigation includes folder browsing, image and PDF previews, drag-and-drop uploads, directory creation, and file deletion. The current working directory is automatically injected into tool descriptions for context-aware commands. [Commit](https://github.com/open-webui/open-webui/commit/636ab99ad8e5b71b32dd37ba7c62c32368585b2a), [Commit](https://github.com/open-webui/open-webui/commit/64ff15a5365e2c4122fccab582782669f06ec58d), [Commit](https://github.com/open-webui/open-webui/commit/4737e1f11847d057859ec78892fa89e24cbcd83b)
|
||||
- 📄 **Terminal file creation.** Users can now create new empty files directly in the Open Terminal file browser, in addition to the existing folder creation functionality. [Commit](https://github.com/open-webui/open-webui/commit/234306ff57c9e24314ff805a60de919632465319)
|
||||
- ✏️ **Terminal file editing.** Users can now edit text files directly in the Open Terminal file browser, with the ability to save changes back to the terminal. [Commit](https://github.com/open-webui/open-webui/commit/3d535db304bfc6fa09e655f737de8a36c0482868)
|
||||
- 🛠️ **Terminal file preview toolbar.** The Open Terminal file browser now displays contextual toolbar buttons based on file type, including preview/source toggle for Markdown and CSV files, reset view for images, and improved editing controls for text files. [Commit](https://github.com/open-webui/open-webui/commit/d2b38127d0572006577b85c770607b04782de4f9)
|
||||
- 🔄 **Terminal file write refresh.** The file browser now automatically refreshes when files are written or modified via the write_file or replace_file_content tools, eliminating the need to manually refresh. [Commit](https://github.com/open-webui/open-webui/commit/18865a9fef1bb154603b7b8af0116a10560e03ac)
|
||||
- 🛡️ **Docker image SBOM attestation.** Docker images now include a Software Bill of Materials (SBOM) for vulnerability scanning and supply chain security compliance. [#21779](https://github.com/open-webui/open-webui/issues/21779), [Commit](https://github.com/open-webui/open-webui/commit/febc66ef2bb05606b59719e737ac5ad839002977)
|
||||
- 📡 **Reporting-Endpoints security header.** Administrators can now configure a Reporting-Endpoints header via the REPORTING_ENDPOINTS environment variable to receive CSP violation reports directly, aiding in security policy debugging and hardening. [#21830](https://github.com/open-webui/open-webui/issues/21830)
|
||||
- 🎯 **Action button priority sorting.** Action buttons under assistant messages now appear in a consistent order based on the priority field from function Valves, allowing developers to control button placement. [#21790](https://github.com/open-webui/open-webui/pull/21790)
|
||||
- 🏷️ **Public/Private model filtering.** The Admin Settings Model listing now displays Public/Private badges and includes filter options to easily view public or private models. [#21732](https://github.com/open-webui/open-webui/issues/21732), [#21797](https://github.com/open-webui/open-webui/pull/21797)
|
||||
- 👁️ **Show/Hide all models bulk action.** Administrators can now show or hide all models at once from the Admin Settings Models page Actions menu, making it faster to manage model visibility. Bulk actions now display a single toast notification on success for better user feedback. [#21838](https://github.com/open-webui/open-webui/pull/21838), [#21958](https://github.com/open-webui/open-webui/pull/21958)
|
||||
- 🔐 **Individual user sharing control.** Administrators can now disable individual user sharing via the USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS environment variable, allowing only group-based sharing when set to false. [#21793](https://github.com/open-webui/open-webui/issues/21793), [Commit](https://github.com/open-webui/open-webui/commit/3d99de67716774af2f95f2e3c8e7cc4879464c71), [Commit](https://github.com/open-webui/open-webui/commit/176f9a781619d836be003d28d53904639cad4128)
|
||||
- 🔄 **OAuth profile sync on login.** Administrators can now enable automatic synchronization of user profile name and email from OAuth providers on login via the OAUTH_UPDATE_NAME_ON_LOGIN and OAUTH_UPDATE_EMAIL_ON_LOGIN environment variables. [#21787](https://github.com/open-webui/open-webui/pull/21787), [Commit](https://github.com/open-webui/open-webui/commit/9478c5e7ac8254b5f522c006da0c1c49bb282727)
|
||||
- 👥 **Default group share permission.** Administrators can now configure the default sharing permission for new groups via the DEFAULT_GROUP_SHARE_PERMISSION environment variable, controlling whether anyone, no one, or only members can share to new groups. [Commit](https://github.com/open-webui/open-webui/commit/538501c88da034434bcd1969f15341dbbaf154e4)
|
||||
- 💨 **Streaming performance.** Chat responses now render more efficiently during streaming, reducing CPU usage and improving responsiveness. [Commit](https://github.com/open-webui/open-webui/commit/484ba91b0777042eb848134f206ef3921f968dea)
|
||||
- 🧮 **Streaming message comparison.** Chat message updates during streaming are now faster thanks to an optimization that skips expensive comparisons when content changes. [#21884](https://github.com/open-webui/open-webui/pull/21884)
|
||||
- 🚀 **Streaming scroll optimization.** Chat auto-scroll during streaming is now more efficient by batching scroll operations via requestAnimationFrame, reducing unnecessary layout reflows when tokens arrive faster than the browser can paint. [#21946](https://github.com/open-webui/open-webui/pull/21946)
|
||||
- 📋 **Message cloning performance.** Chat message cloning during streaming is now more efficient thanks to the use of structuredClone() instead of JSON.parse(JSON.stringify(...)). [#21948](https://github.com/open-webui/open-webui/pull/21948)
|
||||
- 🎯 **Faster code block rendering.** Chat message updates during streaming are now faster. [#22101](https://github.com/open-webui/open-webui/pull/22101)
|
||||
- 📊 **Faster status history display.** Chat message updates during streaming are now faster. [#22103](https://github.com/open-webui/open-webui/pull/22103)
|
||||
- 🛠️ **Faster tool result handling.** Tool execution results are now handled more efficiently, improving streaming performance. [#22104](https://github.com/open-webui/open-webui/pull/22104)
|
||||
- 💾 **Faster model and file operations.** Model selection, file preparation, and history saving are now faster. [#22102](https://github.com/open-webui/open-webui/pull/22102)
|
||||
- 🛠️ **Tool server advanced options toggle.** Advanced OpenAPI configuration options in the tool server modal are now hidden by default behind a toggle, simplifying the interface for basic setups. The admin settings tab was also renamed from "Tools" to "Integrations" for clearer organization. [Commit](https://github.com/open-webui/open-webui/commit/f0c71e5a6d971af7322d4245313e5e04620253f0), [Commit](https://github.com/open-webui/open-webui/commit/4731ccb73c4b4bab78fd86fec7b2c231af8cca8b)
|
||||
- 🔧 **Faster tool loading.** Tool access control now skips an unnecessary database query when no tools are attached to the request, slightly improving performance. [#21873](https://github.com/open-webui/open-webui/pull/21873)
|
||||
- ➗ **Faster math rendering.** Mathematical notation now renders more efficiently, improving responsiveness when displaying equations in chat. [#21880](https://github.com/open-webui/open-webui/pull/21880)
|
||||
- 🏎️ **Faster message list updates.** The chat message list now rebuilds at most once per animation frame during streaming, reducing CPU overhead. [#21885](https://github.com/open-webui/open-webui/pull/21885)
|
||||
- 📋 **Faster message rendering.** Chat message rendering is now more efficient during streaming. [#22086](https://github.com/open-webui/open-webui/pull/22086)
|
||||
- 🗄️ **Faster real-time chat updates.** Chat responses now process faster with improved handling for concurrent users. [#22087](https://github.com/open-webui/open-webui/pull/22087)
|
||||
- 📝 **Faster status persistence.** Only final status updates are now saved to the database during streaming, reducing unnecessary writes. [#22085](https://github.com/open-webui/open-webui/pull/22085)
|
||||
- 🔄 **Faster event matching.** Event handling in the socket handler is now more efficient. [Commit](https://github.com/open-webui/open-webui/commit/ff86283be0479ccb86b639926b2b67ccbbe78746)
|
||||
- 🔀 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for German, Portuguese (Brazil), Simplified Chinese, Traditional Chinese, Catalan, and Spanish were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🗄️ **Database migration execution.** Database migrations now run correctly on startup, fixing a circular import issue that caused schema updates to fail silently. [#21848](https://github.com/open-webui/open-webui/pull/21848), [Commit](https://github.com/open-webui/open-webui/commit/87d33f6e18196876603eee7d1bf8e4977c7fa9c1)
|
||||
- 🔔 **Notification HTML escaping.** Notification messages now properly escape HTML content, matching the behavior in chat messages and ensuring consistent rendering across the interface. [#21860](https://github.com/open-webui/open-webui/issues/21860), [Commit](https://github.com/open-webui/open-webui/commit/e83f668107723fa90ba0efa76c340c8338f45431)
|
||||
- 🛠️ **Tool call JSON error handling.** Chat no longer crashes when models generate malformed JSON in tool call arguments; instead, a descriptive error message is returned to the model for retry. [#21984](https://github.com/open-webui/open-webui/pull/21984), [Commit](https://github.com/open-webui/open-webui/commit/668bd44485bdf88e9083c6f09c3c47ab97a128a4)
|
||||
- 🧠 **Reasoning model KV cache preservation.** Reasoning model thinking tags are no longer stored as HTML in the database, preserving KV cache efficiency for backends like llama.cpp and ensuring faster subsequent conversation turns. [#21815](https://github.com/open-webui/open-webui/issues/21815), [Commit](https://github.com/open-webui/open-webui/commit/81781e6495dcc788c863bbf6b4aa4cf0ddd9fdcc)
|
||||
- ⚡ **Duplicate model execution prevention.** Models are no longer called twice when no tools are configured, eliminating unnecessary API requests and reducing latency. [#21802](https://github.com/open-webui/open-webui/issues/21802), [Commit](https://github.com/open-webui/open-webui/commit/3c8d658160809f6d651837cf93d89dddc1d17caf)
|
||||
- 🔐 **OAuth session database error.** OAuth login no longer fails with a database error when creating sessions, fixing the "'NoneType' object has no attribute 'id'" and "can't adapt type 'dict'" errors that occurred during OAuth group creation. [#21788](https://github.com/open-webui/open-webui/issues/21788)
|
||||
- 👤 **User sharing permission enforcement.** The user sharing option now correctly respects the USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS setting, fixing an issue where sharing to individual users was incorrectly allowed even when disabled. [#21856](https://github.com/open-webui/open-webui/pull/21856), [Commit](https://github.com/open-webui/open-webui/commit/acb21470241ed6fd3eb3f659f196f697c418d9e8), [Commit](https://github.com/open-webui/open-webui/commit/ace69bba7512dc0a653695f9e6311712dfabb640)
|
||||
- 🔑 **Password manager autofill.** Password manager autofill (like iCloud Passwords, 1Password, Bitwarden) now correctly captures filled-in passwords, fixing login failures where the password appeared filled but was sent as empty. [#21869](https://github.com/open-webui/open-webui/pull/21869), [Commit](https://github.com/open-webui/open-webui/commit/9dff497abf821dfba6eb8ea65e48a657ee91fd71)
|
||||
- 📝 **RAG template duplication.** RAG templates are no longer duplicated in chat messages when models make multiple tool calls, preventing hallucinations and incorrect tool usage. [#21780](https://github.com/open-webui/open-webui/issues/21780), [Commit](https://github.com/open-webui/open-webui/commit/8f49725aa5f2d9b87e559e7d3f02f037335b7914)
|
||||
- 📋 **Audit log stdout.** Audit logs now correctly appear on stdout when the ENABLE_AUDIT_STDOUT environment variable is set to true, aligning runtime behavior with the intended configuration. [#21777](https://github.com/open-webui/open-webui/pull/21777)
|
||||
- 🎯 **Function valve priority resolution.** Function priorities defined in code are now correctly applied when no custom value has been saved in the database, ensuring consistent action button and filter ordering. [#21841](https://github.com/open-webui/open-webui/pull/21841)
|
||||
- 📄 **Web content knowledge base append.** Processing web URLs with overwrite=false now correctly appends content to existing knowledge bases instead of silently doing nothing, fixing a regression where no content was being added. [#21786](https://github.com/open-webui/open-webui/pull/21786), [Commit](https://github.com/open-webui/open-webui/commit/5ee509325970f01524348b0f91081110340f2e7e)
|
||||
- 🔍 **Web search domain filter config.** The WEB_SEARCH_DOMAIN_FILTER_LIST environment variable is now correctly read and applied, fixing an issue where domain filtering for web searches always used an empty default value. [#21964](https://github.com/open-webui/open-webui/pull/21964), [#20186](https://github.com/open-webui/open-webui/issues/20186)
|
||||
- 🧹 **Tooltip memory leak.** Tooltip instances are now properly destroyed when elements change, fixing a memory leak that could cause performance issues over time. [#21969](https://github.com/open-webui/open-webui/pull/21969)
|
||||
- ⌨️ **MessageInput memory leak.** Event listeners in the message input component are now properly cleaned up, preventing a memory leak that could cause page crashes during extended use. [#21968](https://github.com/open-webui/open-webui/pull/21968)
|
||||
- 📝 **Notes memory leak.** Event listeners in the Notes component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21963](https://github.com/open-webui/open-webui/pull/21963)
|
||||
- 🏗️ **Model create memory leak.** Event listeners in the model creation page are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21966](https://github.com/open-webui/open-webui/pull/21966)
|
||||
- 💬 **MentionList memory leak.** Event listeners in the MentionList component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21965](https://github.com/open-webui/open-webui/pull/21965)
|
||||
- 📐 **Sidebar memory leak.** Event listeners in the Sidebar component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#22082](https://github.com/open-webui/open-webui/pull/22082)
|
||||
- 🎨 **Sidebar user menu positioning.** The sidebar user menu no longer drifts rightward when the sidebar is resized, keeping the menu properly aligned with its trigger. [#21853](https://github.com/open-webui/open-webui/pull/21853)
|
||||
- 💻 **Code block UI.** Code block headers are now sticky and properly positioned, with language labels now showing tooltips for truncated text. [Commit](https://github.com/open-webui/open-webui/commit/6b462ff121d28cd2d335db7763052622d374e3a5)
|
||||
- 📊 **Multi-model responses horizontal scroll.** The model list in multi-model responses tabs now has horizontal scroll support, making all models accessible on desktop screens. [#21800](https://github.com/open-webui/open-webui/issues/21800), [Commit](https://github.com/open-webui/open-webui/commit/a3de0bcc586ddd14dde6ae915067f082d628eaeb)
|
||||
- 🎭 **TailwindCSS gray color theme.** Custom gray color palette is now correctly applied to the CSS root theme layer, fixing an issue where --color-gray-x variables were missing. [#21900](https://github.com/open-webui/open-webui/pull/21900), [#21899](https://github.com/open-webui/open-webui/issues/21899)
|
||||
- 📎 **Broken documentation links.** Fixed broken links in the backend config and admin settings that pointed to outdated documentation locations. [#21904](https://github.com/open-webui/open-webui/pull/21904)
|
||||
- 🔓 **OAuth session token decryption.** OAuth sessions are now properly detached from the database context before token decryption, preventing potential database session conflicts when reading encrypted tokens. [#21794](https://github.com/open-webui/open-webui/pull/21794)
|
||||
- 🕐 **Chat timestamp i18n fix.** Chat timestamps in the sidebar now display correctly, fixing an issue where the time ago format (e.g., "5m", "2h", "3d") was not being localized properly due to incorrect variable casing in the translation function. [Commit](https://github.com/open-webui/open-webui/commit/ae28e7d24530eb9f7909b293bcd0f33048a022a9)
|
||||
- 🍞 **Model toast notification fix.** Hiding or showing a single model now displays only one toast notification instead of two, removing the redundant generic "model updated" message when a specific action toast is shown. [#22079](https://github.com/open-webui/open-webui/pull/22079)
|
||||
- 📡 **Offline mode embedding model fix.** Open WebUI no longer attempts to download embedding models when in offline mode, fixing error logs that occurred when trying to fetch models that weren't cached locally. [#22106](https://github.com/open-webui/open-webui/pull/22106), [#21405](https://github.com/open-webui/open-webui/issues/21405)
|
||||
|
||||
## [0.8.5] - 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- ⌨️ **Voice dictation shortcut.** Users can now toggle voice dictation using Cmd+Shift+L (or Ctrl+Shift+L on Windows/Linux), making it faster to start and stop dictation without clicking the microphone button.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚫 **Model access KeyError fix.** The /api/models endpoint no longer crashes with a 500 error when models have incomplete info metadata missing the user_id field (e.g. models using global default metadata).
|
||||
- 🔄 **Frontend initialization resilience.** The app layout now gracefully handles individual API failures during initialization (getModels, getBanners, getTools, getUserSettings, setToolServers) instead of blocking the entire page load when any single call fails.
|
||||
- 🛡️ **Backend config null safety.** Language detection during app initialization no longer crashes when the backend config fetch fails, preventing a secondary cause of infinite loading.
|
||||
|
||||
## [0.8.4] - 2026-02-23
|
||||
|
||||
### Added
|
||||
|
||||
- 🛜 **Provider URL suggestions.** The connection form now displays a dropdown with suggested URLs for popular AI providers, making it easier to configure connections. [Commit](https://github.com/open-webui/open-webui/commit/49c36238d01aaff5466344ecd316a6dd3edd74a3)
|
||||
- ☁️ **Anthropic model fetching.** The system now properly fetches available models from the Anthropic API, ensuring all Anthropic models are accessible. [Commit](https://github.com/open-webui/open-webui/commit/e9d852545cc17f0eeb8bdcfa77575a80fed8706d)
|
||||
- 💡 **No models prompt.** When no models are available, a helpful prompt now guides users to manage their provider connections. [Commit](https://github.com/open-webui/open-webui/commit/a0195cd5ae9b9915295839cd0a5fbac5a1b0bfa2)
|
||||
- ⚙️ **Connection enable/disable toggles.** Individual provider connections can now be enabled or disabled from both admin and user settings. [Commit](https://github.com/open-webui/open-webui/commit/990c638f6cf91507b61898f454c26f9516114c36)
|
||||
- ⏸️ **Prompt enable/disable toggle.** Users can now enable or disable prompts directly from the prompts list using a toggle switch, without needing to delete and recreate them. Inactive prompts display an "Inactive" badge and are still visible in the list. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
|
||||
- 🗑️ **Memory deletion.** Agents can now delete specific memories that are no longer relevant, duplicated, or incorrect, giving better control over stored memory content. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
|
||||
- 📋 **Memory listing.** Agents can now list all stored memories, enabling them to identify which memories to manage or delete based on the complete memory inventory. [Commit](https://github.com/open-webui/open-webui/commit/094ed0b48cb86b9b6aff3c93f522072d11230761)
|
||||
- 📦 **Auto pip install toggle.** Administrators can now disable automatic pip package installation from function frontmatter requirements using the ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS environment variable, providing more control over function dependency management. [Commit](https://github.com/open-webui/open-webui/commit/8bfab327ec5f635f9fe93c26efd198712ff7116d)
|
||||
- 🔗 **Anthropic Messages API proxy.** A new API endpoint now supports the Anthropic Messages API format, allowing tools like Claude Code to authenticate through Open WebUI and access configured models. Tool calls are now properly supported in streaming responses with correct multi-block indexing, and error status from tools is propagated correctly. The endpoint converts requests to OpenAI format internally, routes them through the existing chat pipeline, and returns responses in Anthropic format. [#21390](https://github.com/open-webui/open-webui/discussions/21390), [Commit](https://github.com/open-webui/open-webui/commit/91a0301c9e22e93295a7c471d83592a802560795), [Commit](https://github.com/open-webui/open-webui/commit/a9312d25373d3aa161788598f87180b8db11c5b6)
|
||||
- 👥 **Multi-device OAuth sessions.** Users can now stay logged in on multiple devices simultaneously with OAuth, as re-logging in no longer terminates existing sessions. The oldest sessions are automatically pruned when the session limit is exceeded. [#21647](https://github.com/open-webui/open-webui/issues/21647), [Commit](https://github.com/open-webui/open-webui/commit/ae05586fdabf318d551b53ede41575355d3b9e2b)
|
||||
- 🔐 **OAuth group default share setting.** Administrators can now configure the default sharing setting for OAuth-created groups using the OAUTH_GROUP_DEFAULT_SHARE environment variable, allowing control over whether new groups default to private or shared with members. [#21679](https://github.com/open-webui/open-webui/pull/21679), [Commit](https://github.com/open-webui/open-webui/commit/4b9f821b58007d4efa4aa16a4995b23126e08a88)
|
||||
- 🔧 **Knowledge base import behavior.** The web content import endpoint now supports a configurable overwrite flag, allowing users to add multiple URLs to the same knowledge base instead of replacing existing content. [#21613](https://github.com/open-webui/open-webui/pull/21613), [#21336](https://github.com/open-webui/open-webui/issues/21336), [Commit](https://github.com/open-webui/open-webui/commit/4bef69cc6344ff809090441aa6bced573a2aa838)
|
||||
- 🧩 **Skill JSON import support.** Skills can now be imported from both JSON and Markdown files. [#21511](https://github.com/open-webui/open-webui/issues/21511)
|
||||
- 🔍 **You.com web search provider.** A new web search provider option for You.com is now available, giving users another search engine choice for web-enabled models. The You.com provider enriches search results by including both descriptions and snippets for better context. [#21599](https://github.com/open-webui/open-webui/pull/21599)
|
||||
- 🚀 **Message list performance.** Loading conversation history when sending messages is now significantly faster, improving response latency before the model starts generating. This also speeds up chat search and RAG context building. [#21588](https://github.com/open-webui/open-webui/pull/21588)
|
||||
- 🎯 **Concurrent embedding request control.** Administrators can now control the maximum number of concurrent embedding API requests using the RAG_EMBEDDING_CONCURRENT_REQUESTS environment variable, helping manage API rate limits while maintaining embedding performance. [#21662](https://github.com/open-webui/open-webui/pull/21662), [Commit](https://github.com/open-webui/open-webui/commit/5d4547f934b6fbe751bb2041f9597fe11ddf8e43)
|
||||
- ⚡ **Message upsert optimization.** Loading chat data during message saving is now significantly faster by eliminating a redundant database call that occurred on every message upsert, which happens many times during streaming responses. [#21592](https://github.com/open-webui/open-webui/pull/21592)
|
||||
- ⚡ **Message send optimization.** Loading chat data during message sending is now significantly faster by eliminating unnecessary full conversation history loads. The system now uses targeted queries that fetch only the needed data instead of loading entire chat objects with all message history. [#21596](https://github.com/open-webui/open-webui/pull/21596)
|
||||
- 🚀 **Tag filtering optimization.** Chat search with tag filtering now uses more efficient database queries, making filtered searches significantly faster. [Commit](https://github.com/open-webui/open-webui/commit/139f02a9d9fa2ffffcc96aa0de8af8ef51b6bcf2)
|
||||
- ⚡ **Shared chat loading optimization.** The shared chats endpoint now loads only the needed columns instead of the full conversation history, making shared chat listings significantly faster. [#21614](https://github.com/open-webui/open-webui/pull/21614)
|
||||
- 🗂️ **Archived and pinned chat loading.** Loading archived and pinned chat lists is now significantly faster by loading only the needed columns instead of full conversation data. [#21591](https://github.com/open-webui/open-webui/pull/21591)
|
||||
- 💨 **Chat title query optimization.** Retrieving chat titles now queries only the title column instead of the entire conversation history, making title lookups significantly faster and reducing database load. [#21590](https://github.com/open-webui/open-webui/pull/21590)
|
||||
- 🗄️ **Batch access grants for multiple resources.** Loading channels, knowledge bases, models, notes, prompts, skills, and tools now uses batch database queries for access grants instead of individual queries per item, significantly reducing database load. For 30 items, this reduces approximately 31 queries to just 3. [#21616](https://github.com/open-webui/open-webui/pull/21616)
|
||||
- 📋 **Notes list payload optimization.** Notes list and search endpoints now return only a 200-character preview instead of the full note content, reducing response payload from ~167 MB to ~10 KB for 60 notes and eliminating N+1 queries for access grants. The Notes tab now loads in seconds instead of tens of seconds. [#21549](https://github.com/open-webui/open-webui/pull/21549)
|
||||
- ⚡ **Tools list performance.** Loading the tools list is now significantly faster by deferring content and specs fields from database queries, and using cached tool modules instead of reloading them for each request. [Commit](https://github.com/open-webui/open-webui/commit/b48594a16680cc77921a4ed1a11ffa07df7edc60)
|
||||
- 📝 **Group description display.** The admin groups list now shows each group's description, making it easier for administrators to identify groups at a glance.
|
||||
- 🏷️ **Sort by dropdown.** Administrators can now sort groups using a dropdown menu with options for Name or Members, replacing the previous clickable column headers.
|
||||
- 📶 **Admin groups list sorting.** The Group and Users columns in the admin groups list are now clickable for sorting, allowing administrators to sort groups alphabetically by name or numerically by member count. [#21692](https://github.com/open-webui/open-webui/pull/21692)
|
||||
- 🔽 **Rich UI auto-scroll.** The view now automatically scrolls to action-generated Rich UI content once it renders, ensuring users can see the results without manually scrolling. [#21698](https://github.com/open-webui/open-webui/pull/21698), [#21482](https://github.com/open-webui/open-webui/discussions/21482)
|
||||
- 📊 **Admin analytics toggle.** Administrators can now enable or disable the analytics feature using the ENABLE_ADMIN_ANALYTICS environment variable, giving more control over available admin features. [#21651](https://github.com/open-webui/open-webui/pull/21651), [Commit](https://github.com/open-webui/open-webui/commit/35598b8017557258b8c9ee3469d320adb0140751)
|
||||
- 📊 **Analytics sorting enhancement.** The Analytics dashboard now supports sorting by Tokens column for both Model Usage and User Usage tables, and the Share/Percentage columns are now clickable for sorting. Administrators can more easily identify the most token-consuming models and users. [Commit](https://github.com/open-webui/open-webui/commit/053a33631f575ae1ad3123190a9e820b4057f62d)
|
||||
- 📑 **Fetch URL citation sources.** When models fetch URLs during tool calling, the fetched URLs now appear as clickable citation sources in the UI with content previews, matching the existing behavior of web search and knowledge file tools. [#21669](https://github.com/open-webui/open-webui/pull/21669)
|
||||
- 🔗 **Admin settings tab navigation.** The admin settings sidebar now supports native browser tab opening, allowing users to middle-click or right-click to open settings pages in new tabs. The navigation was converted from button-based to anchor-based elements. [#21721](https://github.com/open-webui/open-webui/pull/21721)
|
||||
- 🏷️ **Model visibility badges.** The Admin Settings Models page now displays Public or Private badges directly on each model, making it easy to identify model access levels at a glance without opening the edit screen. [#21732](https://github.com/open-webui/open-webui/issues/21732), [Commit](https://github.com/open-webui/open-webui/commit/29217cb430bd47827ebb20782b264ae7b0f233bb)
|
||||
- 🛠️ **Global model defaults.** Administrators can now configure default metadata and parameters that automatically apply to all models, reducing manual configuration for newly discovered models. Default capabilities (like vision, web search, code interpreter) and parameters (like temperature, max_tokens) can be set globally in Admin Settings, with per-model overrides still available. [#20658](https://github.com/open-webui/open-webui/issues/20658), [Commit](https://github.com/open-webui/open-webui/commit/c341f97cfe15510b7d128bd84f1e607b5289b957)
|
||||
- 💬 **Plaintext tool output display.** Tool outputs that are plain strings now display naturally in a monospace block instead of quoted/escaped format, making multi-line string outputs easier to read. [#21553](https://github.com/open-webui/open-webui/issues/21553), [Commit](https://github.com/open-webui/open-webui/commit/3ad2ea6f2839e97e53f00fd797a9e083ff78d88e)
|
||||
- 🔐 **Event call input masking.** Functions can now request masked password input in confirmation dialogs, allowing sensitive data entry to be hidden from view. This extends the existing masking feature from user valves to event calls. [#21540](https://github.com/open-webui/open-webui/issues/21540), [Commit](https://github.com/open-webui/open-webui/commit/4853ededcabcd76d9bd2036181486cd3a41458a1)
|
||||
- 🗂️ **JSON logging support.** Administrators can now enable JSON-formatted logging by setting the LOG_FORMAT environment variable to "json", making logs suitable for log aggregators like Loki, Fluentd, CloudWatch, and Datadog. [#21747](https://github.com/open-webui/open-webui/pull/21747)
|
||||
- ♿ **UI accessibility improvements.** Screen reader users can now navigate the interface more easily with improved keyboard navigation in dialogs and proper ARIA labels on all interactive elements. Added aria-labels to close, back, and action buttons across various components, and improved semantic HTML and screen reader support across auth, sidebar, chat, and notification components, addressing WCAG compliance. Added aria-labels to search inputs, select fields, and modals in admin and user settings, and improved accessibility for text inputs, rating components, citations, and web search results. Added aria-labels to workspace components including Knowledge, Models, Prompts, Skills, and Tools pages for improved screen reader support. [#21706](https://github.com/open-webui/open-webui/pull/21706), [#21705](https://github.com/open-webui/open-webui/pull/21705), [#21710](https://github.com/open-webui/open-webui/pull/21710), [#21709](https://github.com/open-webui/open-webui/pull/21709), [#21717](https://github.com/open-webui/open-webui/pull/21717), [#21715](https://github.com/open-webui/open-webui/pull/21715), [#21708](https://github.com/open-webui/open-webui/pull/21708), [#21719](https://github.com/open-webui/open-webui/pull/21719)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Finnish, French, Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 💥 **Admin functions page crash fix.** The admin Functions tab no longer crashes when clicked, fixing a null reference error that occurred while the functions list was loading. [#21661](https://github.com/open-webui/open-webui/pull/21661), [Commit](https://github.com/open-webui/open-webui/commit/8265422ba0660e7ba2192eb19efd70f8be652748)
|
||||
- 💀 **Cyclic chat history deadlock fix.** Chat histories with circular parent-child message references no longer cause the backend to freeze when syncing usage stats. The system now detects and safely aborts when encountering cyclic message references. [#21681](https://github.com/open-webui/open-webui/pull/21681)
|
||||
- 🔀 **Model fallback routing fix.** Custom model fallback now works correctly across all model types, preventing "Model not found" errors when the fallback model uses a different backend (pipe, Ollama, or OpenAI). [#21736](https://github.com/open-webui/open-webui/pull/21736)
|
||||
- 🐛 **Default model selection fix.** Admin-configured default models are now properly respected when starting new chats instead of being overwritten by the first available model. [#21736](https://github.com/open-webui/open-webui/pull/21736)
|
||||
- 👁️ **Scroll jumping fix.** Deleting a message pair after stopping generation no longer causes the chat to visually jump around, making message deletion smoother. [#21743](https://github.com/open-webui/open-webui/pull/21743), [Commit](https://github.com/open-webui/open-webui/commit/1f474187a77d2c8a392f00d86f48eb3cb3a18b88)
|
||||
- 💬 **New chat message handling fix.** Fixed a bug where clicking "New Chat" after sending a message would silently drop subsequent messages. The system now properly clears pending message queues when starting a new conversation. [#21731](https://github.com/open-webui/open-webui/pull/21731)
|
||||
- 🔍 **RAG template mutation fix.** Fixed a bug where RAG template text was recursively injected into user messages during multiple sequential tool calls, causing message content to grow exponentially and potentially confuse the model. The system now preserves the original user message before tool-calling loops and correctly accumulates citation sources. [#21663](https://github.com/open-webui/open-webui/issues/21663), [#21668](https://github.com/open-webui/open-webui/pull/21668), [Commit](https://github.com/open-webui/open-webui/commit/becac2b2b7af8aacadbfc9b7cee2024cf7ed6acc)
|
||||
- 🔒 **Iframe sandbox security.** Embedded tools can no longer submit forms or access same-origin content by default, improving security for users. [#21529](https://github.com/open-webui/open-webui/pull/21529)
|
||||
- 🔐 **Signup race condition fix.** Fixed a security vulnerability where multiple admin accounts could be created on fresh deployments when running multiple uvicorn workers. The signup handler now properly handles concurrent requests during first-user registration, preventing unauthorized admin privilege escalation. [#21631](https://github.com/open-webui/open-webui/pull/21631)
|
||||
- 🔐 **LDAP optional fields fix.** LDAP configuration now properly accepts empty Application DN and password values, allowing LDAP authentication to work without these optional fields. Previously, empty values caused authentication failures. [Commit](https://github.com/open-webui/open-webui/commit/e1fa42d48a15c8b496a887ecfa32fc01cfd74b36)
|
||||
- 🛠️ **API tools fix.** The /api/v1/chat/completions endpoint now properly respects caller-provided tools instead of overriding them with server-side tools, fixing issues where external agents like Claude Code or Cursor would receive unexpected tool advertisements. [#21557](https://github.com/open-webui/open-webui/issues/21557), [#21555](https://github.com/open-webui/open-webui/pull/21555)
|
||||
- ⏱️ **Embeddings and proxy timeout fix.** The embeddings and OpenAI proxy endpoints now properly honor the AIOHTTP_CLIENT_TIMEOUT environment variable, instead of using default timeouts that could cause requests to hang. [#21558](https://github.com/open-webui/open-webui/pull/21558)
|
||||
- 📄 **Text file type detection fix.** TypeScript and other text files that were mis-detected as video files based on their extension are now correctly identified and processed as text files, fixing upload rejections for .ts files. [#21454](https://github.com/open-webui/open-webui/issues/21454), [Commit](https://github.com/open-webui/open-webui/commit/f651809001ba8e40ba5f416773c1aa6f082a6c46)
|
||||
- 🗄️ **File access control respect.** The files list and search endpoints now properly respect the BYPASS_ADMIN_ACCESS_CONTROL setting, ensuring admins only see their own files when the setting is disabled, consistent with other endpoints. [#21595](https://github.com/open-webui/open-webui/pull/21595), [#21589](https://github.com/open-webui/open-webui/issues/21589)
|
||||
- 🗄️ **PostgreSQL workspace cloning.** Cloning workspace models now works correctly on PostgreSQL databases by generating proper unique IDs for access grants instead of using potentially duplicate or invalid IDs. [Commit](https://github.com/open-webui/open-webui/commit/3dd44c4f1931d13bfd46062291c6f23b33dde003)
|
||||
- 🔓 **MCP SSL verification fix.** MCP tool connections now properly respect the AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL environment variable to disable SSL verification, instead of always verifying SSL certificates. [Commit](https://github.com/open-webui/open-webui/commit/af5661c2c807465f5600899e8c1a421f96cd7a8c), [#21481](https://github.com/open-webui/open-webui/issues/21481)
|
||||
- 🔒 **Model default feature permissions.** Model default features like code interpreter, web search, and image generation now respect global configuration and user permission settings, preventing disabled features from appearing in the chat input. [#21690](https://github.com/open-webui/open-webui/pull/21690)
|
||||
- 🔍 **Model selector typing fix.** The model selector list no longer disappears or becomes grayed out when typing quickly in the search field, thanks to improved virtual scroll handling. [#21659](https://github.com/open-webui/open-webui/pull/21659)
|
||||
- ⛔ **Disabled model cloning prevention.** Disabled models can no longer be cloned as workspace models, preventing invalid empty configurations from being created. The Clone option is now hidden for inactive models. [#21724](https://github.com/open-webui/open-webui/pull/21724)
|
||||
- 🔧 **SCIM parameter handling.** The SCIM Users and Groups endpoints now accept out-of-range startIndex and count values by clamping them to valid ranges instead of returning errors, in compliance with RFC 7644. [#21577](https://github.com/open-webui/open-webui/pull/21577)
|
||||
- 🔍 **Hybrid search result fix.** Hybrid search now returns correct results after fixing a bug where query result unpacking order was mismatched, causing search results to appear empty. [#21562](https://github.com/open-webui/open-webui/pull/21562)
|
||||
- 🛠️ **Imported items display.** Imported functions and tools now appear immediately in the list after import, without requiring a page reload. [#21593](https://github.com/open-webui/open-webui/issues/21593)
|
||||
- 🔄 **WebSocket race condition fix.** Collaborative note saves no longer crash with errors when users disconnect before pending saves complete, preventing AttributeError exceptions and excessive logging. [#21601](https://github.com/open-webui/open-webui/issues/21601), [Commit](https://github.com/open-webui/open-webui/commit/0a700aafe46dfea2cf9721bb81725d2582b0d781)
|
||||
- ✋ **Drag-and-drop overlay fix.** The "Add Files" overlay no longer remains stuck on screen when dragging files back out of the chat window in Mozilla Firefox. [#21664](https://github.com/open-webui/open-webui/pull/21664)
|
||||
- 👁️ **Group search visibility fix.** Groups now appear correctly in access control search results, even when the search doesn't match any users. [#21691](https://github.com/open-webui/open-webui/pull/21691)
|
||||
- 🖱️ **User menu drag and click fixes.** Fixed draggable ghost images when dragging menu items and eliminated phantom link clicks that occurred when dragging outside dropdown menus. [#21699](https://github.com/open-webui/open-webui/pull/21699)
|
||||
- 🧭 **Admin and workspace nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Admin and Workspace panels by adding proper drag constraints and text selection prevention. [#21701](https://github.com/open-webui/open-webui/pull/21701)
|
||||
- 🎮 **Playground nav drag fix.** Fixed ghost drag images when dragging top navigation tabs in the Playground panel by adding proper drag constraints and text selection prevention. [#21704](https://github.com/open-webui/open-webui/pull/21704)
|
||||
- ✋ **Dropdown menu drag fix.** Dropdown menu items can no longer be accidentally dragged as ghost images when highlighting text, making menu interactions smoother. [#21713](https://github.com/open-webui/open-webui/pull/21713)
|
||||
- 🗂️ **Folder menu drag fix.** Folder dropdown menu items can no longer be accidentally highlighted or dragged as ghost images, making folder options behave like standard menus. [#21753](https://github.com/open-webui/open-webui/pull/21753)
|
||||
- 📝 **Console log spam fix.** Requesting deleted or missing files no longer floods the backend console with Python traceback logs, thanks to proper exception handling for expected 404 errors. [#21687](https://github.com/open-webui/open-webui/pull/21687)
|
||||
- 🐛 **Firefox avatar overflow fix.** Fixed a visual bug in Firefox where broken model or user avatar images would display overflowing alt text that overlapped adjacent labels on the Analytics and Leaderboard pages. Failed avatar images now properly show fallback icons instead. [#21730](https://github.com/open-webui/open-webui/pull/21730)
|
||||
- 🎨 **Dark mode select background fix.** Fixed an issue where select inputs and dropdown menus had inconsistent lighter background colors in dark mode by removing conflicting dark theme overrides, ensuring a cohesive transparent look. [#21728](https://github.com/open-webui/open-webui/pull/21728)
|
||||
- 💾 **Prompt import fix.** Importing prompts that were previously exported no longer fails with a "[object Object]" error toast, making prompt backup and restore work correctly. [#21594](https://github.com/open-webui/open-webui/issues/21594)
|
||||
- 🔧 **Ollama reasoning effort fix.** Reasoning effort now works correctly with Ollama models that require string values ("low", "medium", "high") instead of boolean, fixing "invalid option provided" errors when using models like GPT-OSS. [#20921](https://github.com/open-webui/open-webui/issues/20921), [#20928](https://github.com/open-webui/open-webui/pull/20928), [Commit](https://github.com/open-webui/open-webui/commit/30a13b9b2fb2c6da7e1ddbf52edb93a58d09cc56)
|
||||
- 🔍 **Hybrid search deduplication fix.** Hybrid search now correctly deduplicates results using content hashes, preventing duplicate chunks from appearing when using enriched text for BM25 search. [Commit](https://github.com/open-webui/open-webui/commit/d9fd2a3f30481efa24cc54193bf2f67fd0299b52)
|
||||
- 📋 **SQLAlchemy warning fix.** Fixed a SQLAlchemy warning that appeared in logs when deleting shared chats, improving log clarity. [Commit](https://github.com/open-webui/open-webui/commit/0185f3340d2778f3b75a8036b0e81a0aec78037f)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🎯 **Prompt suggestions relocated.** Prompt suggestions have been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - Models, where they can now be configured per-model or globally via the new model defaults.
|
||||
- 📢 **Banners relocated.** Banners configuration has been moved from Admin Panel - Settings - Interface to Admin Panel - Settings - General.
|
||||
|
||||
## [0.8.3] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- ✏️ **Model edit shortcut.** Users can now edit models directly from the model selector dropdown menu, making it faster to modify model settings without navigating to separate admin or workspace pages. [Commit](https://github.com/open-webui/open-webui/commit/519ff40cb69cdc1d215cee369e9db70ff7438153)
|
||||
- 🎨 **Image edit API background support.** The image edit API now supports the background parameter for OpenAI's gpt-image-1 model, enabling background transparency control ("transparent", "opaque", "auto") when the feature is exposed in the UI. [#21459](https://github.com/open-webui/open-webui/pull/21459)
|
||||
- ⚡ **Faster model filtering.** Model access control filtering no longer makes a redundant database query to re-fetch model info that is already available in memory, reducing latency when loading model lists for non-admin users. [Commit](https://github.com/open-webui/open-webui/commit/34cd3d79e8688f589e3dd2f03415f8a8f9a13115)
|
||||
- 🔧 **Tool call display improvements.** Tool call results now display arguments in a cleaner key-value format instead of raw JSON, with a responsive layout that shows only the tool name on narrow screens and the full label on wider screens, preventing text wrapping to multiple lines. [Commit](https://github.com/open-webui/open-webui/commit/2ce935bdb10d2b26b230cd54cb649f5c667ed96a)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Portuguese (Brazil), Simplified Chinese, and Traditional Chinese were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📧 **USER_EMAIL variable fix.** The {{USER_EMAIL}} template variable now correctly returns the user's email address instead of "Unknown" in prompts. [#21479](https://github.com/open-webui/open-webui/pull/21479), [#21465](https://github.com/open-webui/open-webui/issues/21465)
|
||||
- 🖼️ **Image and file attachment handling fixes.** Uploaded images are now correctly sent to vision-enabled models, and file attachments now work even when no user text is entered alongside a system prompt. This fixes two issues where the backend was not properly processing file attachments: images weren't converted to the expected format for API requests, and file context was dropped when the user sent only a file without accompanying text. [Commit](https://github.com/open-webui/open-webui/commit/f1053d94c7ef7b8b78682dd73586b65a84d202a1), [#21477](https://github.com/open-webui/open-webui/issues/21477), [#21457](https://github.com/open-webui/open-webui/issues/21457)
|
||||
- 🛡️ **Missing function error handling.** Models that reference deleted functions no longer cause the entire /api/models endpoint to crash; instead, the missing functions are skipped and logged, allowing the rest of the models to load successfully. [#21476](https://github.com/open-webui/open-webui/pull/21476), [#21464](https://github.com/open-webui/open-webui/issues/21464)
|
||||
- 🚀 **Startup model pre-fetch error handling.** If model pre-fetching fails during app startup, the application now logs a warning and continues instead of crashing entirely. [Commit](https://github.com/open-webui/open-webui/commit/337109e99ce390f55a9085d0a301853637923779)
|
||||
- ⚙️ **Function module loading error handling.** Function modules that fail to load during startup or model processing are now caught and logged, preventing crashes when models reference functions with loading errors. [Commit](https://github.com/open-webui/open-webui/commit/15b893e651de71b033408e1b713e0b51f6829ab8)
|
||||
- 🗄️ **PostgreSQL group query fix.** The '/api/v1/groups/' endpoint no longer fails with a GROUP BY error when using PostgreSQL; member counts are now calculated using correlated subqueries for better database compatibility. [#21458](https://github.com/open-webui/open-webui/pull/21458), [#21467](https://github.com/open-webui/open-webui/issues/21467)
|
||||
|
||||
## [0.8.2] - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Open WebUI Contributor License Agreement
|
||||
# Contributor License Agreement
|
||||
|
||||
By submitting my contributions to Open WebUI, I grant Open WebUI full freedom to use my work in any way they choose, under any terms they like, both now and in the future. This approach helps ensure the project remains unified, flexible, and easy to maintain, while empowering Open WebUI to respond quickly to the needs of its users and the wider community.
|
||||
By submitting my contributions to this repository in any form, I grant Open WebUI Inc. a perpetual, worldwide, irrevocable, royalty-free license, under copyright and patent, to use, modify, distribute, sublicense, and commercialize my work under any terms they choose, both now and in the future.
|
||||
|
||||
Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the Open WebUI project. This collaborative approach strengthens the project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible.
|
||||
I represent that my contributions are my original work (or that I have sufficient rights to grant this license) and that I have the authority to enter into this agreement.
|
||||
|
||||
**_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._**
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||

|
||||
|
||||
**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
|
||||
**Open WebUI is an [extensible](https://docs.openwebui.com/features/extensibility/plugin), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
|
||||
|
||||
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
|
||||
|
||||
@@ -172,7 +172,7 @@ After installation, you can access Open WebUI at [http://localhost:3000](http://
|
||||
|
||||
We offer various installation alternatives, including non-Docker native installation methods, Docker Compose, Kustomize, and Helm. Visit our [Open WebUI Documentation](https://docs.openwebui.com/getting-started/) or join our [Discord community](https://discord.gg/5rJgQTnV4s) for comprehensive guidance.
|
||||
|
||||
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/advanced-topics/development) for instructions on setting up a local development environment.
|
||||
Look at the [Local Development Guide](https://docs.openwebui.com/getting-started/development) for instructions on setting up a local development environment.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
+130
-20
@@ -322,7 +322,7 @@ JWT_EXPIRES_IN = PersistentConfig(
|
||||
if JWT_EXPIRES_IN.value == "-1":
|
||||
log.warning(
|
||||
"⚠️ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n"
|
||||
" See: https://docs.openwebui.com/getting-started/env-configuration\n"
|
||||
" See: https://docs.openwebui.com/reference/env-configuration\n"
|
||||
)
|
||||
|
||||
####################################
|
||||
@@ -339,6 +339,12 @@ ENABLE_OAUTH_SIGNUP = PersistentConfig(
|
||||
os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true",
|
||||
)
|
||||
|
||||
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = PersistentConfig(
|
||||
"OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE",
|
||||
"oauth.refresh_token_include_scope",
|
||||
os.environ.get("OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", "False").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig(
|
||||
"OAUTH_MERGE_ACCOUNTS_BY_EMAIL",
|
||||
@@ -573,6 +579,20 @@ ENABLE_OAUTH_GROUP_CREATION = PersistentConfig(
|
||||
)
|
||||
|
||||
|
||||
oauth_group_default_share = (
|
||||
os.environ.get("OAUTH_GROUP_DEFAULT_SHARE", "true").strip().lower()
|
||||
)
|
||||
OAUTH_GROUP_DEFAULT_SHARE = PersistentConfig(
|
||||
"OAUTH_GROUP_DEFAULT_SHARE",
|
||||
"oauth.group_default_share",
|
||||
(
|
||||
"members"
|
||||
if oauth_group_default_share == "members"
|
||||
else oauth_group_default_share == "true"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
OAUTH_BLOCKED_GROUPS = PersistentConfig(
|
||||
"OAUTH_BLOCKED_GROUPS",
|
||||
"oauth.blocked_groups",
|
||||
@@ -628,6 +648,18 @@ OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig(
|
||||
os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true",
|
||||
)
|
||||
|
||||
OAUTH_UPDATE_NAME_ON_LOGIN = PersistentConfig(
|
||||
"OAUTH_UPDATE_NAME_ON_LOGIN",
|
||||
"oauth.update_name_on_login",
|
||||
os.environ.get("OAUTH_UPDATE_NAME_ON_LOGIN", "False").lower() == "true",
|
||||
)
|
||||
|
||||
OAUTH_UPDATE_EMAIL_ON_LOGIN = PersistentConfig(
|
||||
"OAUTH_UPDATE_EMAIL_ON_LOGIN",
|
||||
"oauth.update_email_on_login",
|
||||
os.environ.get("OAUTH_UPDATE_EMAIL_ON_LOGIN", "False").lower() == "true",
|
||||
)
|
||||
|
||||
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
|
||||
os.environ.get("OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID", "False").lower()
|
||||
== "true"
|
||||
@@ -1157,6 +1189,20 @@ TOOL_SERVER_CONNECTIONS = PersistentConfig(
|
||||
tool_server_connections,
|
||||
)
|
||||
|
||||
####################################
|
||||
# TERMINAL_SERVER
|
||||
####################################
|
||||
|
||||
terminal_server_connections = json.loads(
|
||||
os.environ.get("TERMINAL_SERVER_CONNECTIONS", "[]")
|
||||
)
|
||||
|
||||
TERMINAL_SERVER_CONNECTIONS = PersistentConfig(
|
||||
"TERMINAL_SERVER_CONNECTIONS",
|
||||
"terminal_server.connections",
|
||||
terminal_server_connections,
|
||||
)
|
||||
|
||||
####################################
|
||||
# WEBUI
|
||||
####################################
|
||||
@@ -1249,6 +1295,18 @@ MODEL_ORDER_LIST = PersistentConfig(
|
||||
[],
|
||||
)
|
||||
|
||||
DEFAULT_MODEL_METADATA = PersistentConfig(
|
||||
"DEFAULT_MODEL_METADATA",
|
||||
"models.default_metadata",
|
||||
{},
|
||||
)
|
||||
|
||||
DEFAULT_MODEL_PARAMS = PersistentConfig(
|
||||
"DEFAULT_MODEL_PARAMS",
|
||||
"models.default_params",
|
||||
{},
|
||||
)
|
||||
|
||||
DEFAULT_USER_ROLE = PersistentConfig(
|
||||
"DEFAULT_USER_ROLE",
|
||||
"ui.default_user_role",
|
||||
@@ -1407,6 +1465,11 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
|
||||
== "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
||||
os.environ.get("USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS", "True").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
|
||||
USER_PERMISSIONS_CHAT_CONTROLS = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
|
||||
@@ -1428,6 +1491,10 @@ USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_WEB_UPLOAD = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_WEB_UPLOAD", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_DELETE = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true"
|
||||
)
|
||||
@@ -1560,12 +1627,16 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
|
||||
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
"access_grants": {
|
||||
"allow_users": USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
||||
},
|
||||
"chat": {
|
||||
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
"valves": USER_PERMISSIONS_CHAT_VALVES,
|
||||
"system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT,
|
||||
"params": USER_PERMISSIONS_CHAT_PARAMS,
|
||||
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
|
||||
"web_upload": USER_PERMISSIONS_CHAT_WEB_UPLOAD,
|
||||
"delete": USER_PERMISSIONS_CHAT_DELETE,
|
||||
"delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE,
|
||||
"continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE,
|
||||
@@ -1678,6 +1749,10 @@ ENABLE_ADMIN_CHAT_ACCESS = (
|
||||
os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_ADMIN_ANALYTICS = (
|
||||
os.environ.get("ENABLE_ADMIN_ANALYTICS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
ENABLE_COMMUNITY_SHARING = PersistentConfig(
|
||||
"ENABLE_COMMUNITY_SHARING",
|
||||
"ui.enable_community_sharing",
|
||||
@@ -1891,7 +1966,7 @@ Suggest 3-5 relevant follow-up questions or prompts that the user might naturall
|
||||
- Only suggest follow-ups that make sense given the chat content and do not repeat what was already covered.
|
||||
- If the conversation is very short or not specific, suggest more general (but relevant) follow-ups the user might ask.
|
||||
- Use the conversation's primary language; default to English if multilingual.
|
||||
- Response must be a JSON array of strings, no extra text or formatting.
|
||||
- Response must be a JSON object with a "follow_ups" key containing an array of strings, no extra text or formatting.
|
||||
### Output:
|
||||
JSON format: { "follow_ups": ["Question 1?", "Question 2?", "Question 3?"] }
|
||||
### Chat History:
|
||||
@@ -2221,20 +2296,36 @@ CODE_INTERPRETER_BLOCKED_MODULES = [
|
||||
]
|
||||
|
||||
DEFAULT_CODE_INTERPRETER_PROMPT = """
|
||||
#### Tools Available
|
||||
#### Code Interpreter
|
||||
|
||||
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.
|
||||
- When writing code in the code_interpreter XML tag, Do NOT use the triple backticks code block for markdown formatting, example: ```py # python code ``` will cause an error because it is markdown formatting, it is not python code.
|
||||
- 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.
|
||||
You have access to a Python code interpreter via: `<code_interpreter type="code" lang="python"></code_interpreter>`
|
||||
|
||||
Ensure that the tools are effectively utilized to achieve the highest-quality analysis for the user."""
|
||||
- The Python shell runs directly in the user's browser for fast execution of analysis, calculations, or problem-solving. Use it in this response.
|
||||
- You can use a wide array of libraries for data manipulation, visualization, API calls, or any computational task. Think outside the box and harness Python's full potential.
|
||||
- **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 (```py ... ```) inside the XML tags — that is markdown formatting, not executable Python code.
|
||||
- **Always print meaningful outputs** (results, tables, summaries, visuals). Avoid implicit outputs; use explicit print statements.
|
||||
- After obtaining output, **provide a concise analysis, interpretation, or next steps** to help the user understand the findings.
|
||||
- If results are unclear or unexpected, refine the code and re-execute. Iterate until you deliver meaningful insights.
|
||||
- **If a link to an image, audio, or any file appears in the output, display it exactly as-is** in your response so the user can access it. Do not modify the link.
|
||||
- Respond in the chat's primary language. Default to English if multilingual.
|
||||
|
||||
Ensure the code interpreter is effectively utilized to achieve the highest-quality analysis for the user."""
|
||||
|
||||
# Appended to the code interpreter prompt only when engine is pyodide (not jupyter)
|
||||
CODE_INTERPRETER_PYODIDE_PROMPT = """
|
||||
|
||||
##### Pyodide Environment
|
||||
|
||||
- This Python environment runs via Pyodide in the browser. **Do not install packages** — `pip install`, `subprocess`, and `micropip.install()` are not available.
|
||||
- If a required library is unavailable, use an alternative approach with available modules. Do not attempt to install anything.
|
||||
|
||||
##### Persistent File System
|
||||
|
||||
- User-uploaded files are available at `/mnt/uploads/`. When the user asks you to work with their files, read from this directory.
|
||||
- You can also write output files to `/mnt/uploads/` so the user can access and download them from the file browser.
|
||||
- The file system persists across code executions within the same session.
|
||||
- Use `import os; os.listdir('/mnt/uploads')` to discover available files."""
|
||||
|
||||
|
||||
####################################
|
||||
@@ -2903,6 +2994,12 @@ ENABLE_ASYNC_EMBEDDING = PersistentConfig(
|
||||
os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true",
|
||||
)
|
||||
|
||||
RAG_EMBEDDING_CONCURRENT_REQUESTS = PersistentConfig(
|
||||
"RAG_EMBEDDING_CONCURRENT_REQUESTS",
|
||||
"rag.embedding_concurrent_requests",
|
||||
int(os.getenv("RAG_EMBEDDING_CONCURRENT_REQUESTS", "0")),
|
||||
)
|
||||
|
||||
RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None)
|
||||
|
||||
RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None)
|
||||
@@ -3136,17 +3233,24 @@ WEB_SEARCH_RESULT_COUNT = PersistentConfig(
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
web_search_domain_filter_list = json.loads(
|
||||
os.getenv("WEB_SEARCH_DOMAIN_FILTER_LIST", "[]")
|
||||
)
|
||||
except Exception as e:
|
||||
web_search_domain_filter_list = [
|
||||
# "wikipedia.com",
|
||||
# "wikimedia.org",
|
||||
# "wikidata.org",
|
||||
# "!stackoverflow.com",
|
||||
]
|
||||
|
||||
# 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.
|
||||
WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
|
||||
"WEB_SEARCH_DOMAIN_FILTER_LIST",
|
||||
"rag.web.search.domain.filter_list",
|
||||
[
|
||||
# "wikipedia.com",
|
||||
# "wikimedia.org",
|
||||
# "wikidata.org",
|
||||
# "!stackoverflow.com",
|
||||
],
|
||||
web_search_domain_filter_list,
|
||||
)
|
||||
|
||||
WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
|
||||
@@ -3485,6 +3589,12 @@ YANDEX_WEB_SEARCH_CONFIG = PersistentConfig(
|
||||
os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""),
|
||||
)
|
||||
|
||||
YOUCOM_API_KEY = PersistentConfig(
|
||||
"YOUCOM_API_KEY",
|
||||
"rag.web.search.youcom_api_key",
|
||||
os.environ.get("YOUCOM_API_KEY", ""),
|
||||
)
|
||||
|
||||
####################################
|
||||
# Images
|
||||
####################################
|
||||
|
||||
@@ -5,6 +5,9 @@ import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import shutil
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
@@ -72,9 +75,51 @@ except Exception:
|
||||
# LOGGING
|
||||
####################################
|
||||
|
||||
_LEVEL_MAP = {
|
||||
"DEBUG": "debug",
|
||||
"INFO": "info",
|
||||
"WARNING": "warn",
|
||||
"ERROR": "error",
|
||||
"CRITICAL": "fatal",
|
||||
}
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Format log records as single-line JSON objects for structured logging."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict[str, Any] = {
|
||||
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
|
||||
timespec="milliseconds"
|
||||
),
|
||||
"level": _LEVEL_MAP.get(record.levelname, record.levelname.lower()),
|
||||
"msg": record.getMessage(),
|
||||
"caller": record.name,
|
||||
}
|
||||
|
||||
if record.exc_info and record.exc_info[0] is not None:
|
||||
log_entry["error"] = "".join(
|
||||
traceback.format_exception(*record.exc_info)
|
||||
).rstrip()
|
||||
elif record.exc_text:
|
||||
log_entry["error"] = record.exc_text
|
||||
|
||||
if record.stack_info:
|
||||
log_entry["stacktrace"] = record.stack_info
|
||||
|
||||
return json.dumps(log_entry, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower()
|
||||
|
||||
GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper()
|
||||
if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping():
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
|
||||
if LOG_FORMAT == "json":
|
||||
_handler = logging.StreamHandler(sys.stdout)
|
||||
_handler.setFormatter(JSONFormatter())
|
||||
logging.basicConfig(handlers=[_handler], level=GLOBAL_LOG_LEVEL, force=True)
|
||||
else:
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True)
|
||||
else:
|
||||
GLOBAL_LOG_LEVEL = "INFO"
|
||||
|
||||
@@ -557,6 +602,10 @@ OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
|
||||
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
|
||||
)
|
||||
|
||||
# Maximum number of concurrent OAuth sessions per user per provider
|
||||
# This prevents unbounded session growth while allowing multi-device usage
|
||||
OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get("OAUTH_MAX_SESSIONS_PER_USER", "10"))
|
||||
|
||||
# Token Exchange Configuration
|
||||
# Allows external apps to exchange OAuth tokens for OpenWebUI tokens
|
||||
ENABLE_OAUTH_TOKEN_EXCHANGE = (
|
||||
@@ -739,6 +788,16 @@ try:
|
||||
except ValueError:
|
||||
WEBSOCKET_SERVER_PING_INTERVAL = 25
|
||||
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT = os.environ.get("WEBSOCKET_EVENT_CALLER_TIMEOUT", "")
|
||||
|
||||
if WEBSOCKET_EVENT_CALLER_TIMEOUT == "":
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT = None
|
||||
else:
|
||||
try:
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT = int(WEBSOCKET_EVENT_CALLER_TIMEOUT)
|
||||
except ValueError:
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT = 300
|
||||
|
||||
|
||||
REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true"
|
||||
|
||||
@@ -978,6 +1037,11 @@ OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get(
|
||||
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||
####################################
|
||||
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
|
||||
os.environ.get("ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS", "True").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split()
|
||||
PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
|
||||
|
||||
@@ -987,3 +1051,16 @@ PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split()
|
||||
####################################
|
||||
|
||||
EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL")
|
||||
|
||||
####################################
|
||||
# GROUP DEFAULTS
|
||||
####################################
|
||||
|
||||
# Controls the default "Who can share to this group" setting for new groups.
|
||||
# Env var values: "true" (anyone), "false" (no one), "members" (only group members).
|
||||
_default_group_share = (
|
||||
os.environ.get("DEFAULT_GROUP_SHARE_PERMISSION", "members").strip().lower()
|
||||
)
|
||||
DEFAULT_GROUP_SHARE_PERMISSION = (
|
||||
"members" if _default_group_share == "members" else _default_group_share == "true"
|
||||
)
|
||||
|
||||
@@ -102,11 +102,30 @@ if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"):
|
||||
conn.execute(f"PRAGMA key = '{database_password}'")
|
||||
return conn
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://", # Dummy URL since we're using creator
|
||||
creator=create_sqlcipher_connection,
|
||||
echo=False,
|
||||
)
|
||||
# The dummy "sqlite://" URL would cause SQLAlchemy to auto-select
|
||||
# SingletonThreadPool, which non-deterministically closes in-use
|
||||
# connections when thread count exceeds pool_size, leading to segfaults
|
||||
# in the native sqlcipher3 C library. Use NullPool by default for safety,
|
||||
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
|
||||
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
creator=create_sqlcipher_connection,
|
||||
pool_size=DATABASE_POOL_SIZE,
|
||||
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
||||
pool_timeout=DATABASE_POOL_TIMEOUT,
|
||||
pool_recycle=DATABASE_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
poolclass=QueuePool,
|
||||
echo=False,
|
||||
)
|
||||
else:
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
creator=create_sqlcipher_connection,
|
||||
poolclass=NullPool,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
log.info("Connected to encrypted SQLite database using SQLCipher")
|
||||
|
||||
|
||||
+194
-63
@@ -96,6 +96,7 @@ from open_webui.routers import (
|
||||
users,
|
||||
utils,
|
||||
scim,
|
||||
terminals,
|
||||
)
|
||||
|
||||
from open_webui.routers.retrieval import (
|
||||
@@ -132,6 +133,8 @@ from open_webui.config import (
|
||||
THREAD_POOL_SIZE,
|
||||
# Tool Server Configs
|
||||
TOOL_SERVER_CONNECTIONS,
|
||||
# Terminal Server
|
||||
TERMINAL_SERVER_CONNECTIONS,
|
||||
# Code Execution
|
||||
ENABLE_CODE_EXECUTION,
|
||||
CODE_EXECUTION_ENGINE,
|
||||
@@ -240,6 +243,7 @@ from open_webui.config import (
|
||||
RAG_EMBEDDING_ENGINE,
|
||||
RAG_EMBEDDING_BATCH_SIZE,
|
||||
ENABLE_ASYNC_EMBEDDING,
|
||||
RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
RAG_TOP_K,
|
||||
RAG_TOP_K_RERANKER,
|
||||
RAG_RELEVANCE_THRESHOLD,
|
||||
@@ -359,6 +363,7 @@ from open_webui.config import (
|
||||
YANDEX_WEB_SEARCH_URL,
|
||||
YANDEX_WEB_SEARCH_API_KEY,
|
||||
YANDEX_WEB_SEARCH_CONFIG,
|
||||
YOUCOM_API_KEY,
|
||||
# WebUI
|
||||
WEBUI_AUTH,
|
||||
WEBUI_NAME,
|
||||
@@ -392,6 +397,8 @@ from open_webui.config import (
|
||||
DEFAULT_PINNED_MODELS,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
MODEL_ORDER_LIST,
|
||||
DEFAULT_MODEL_METADATA,
|
||||
DEFAULT_MODEL_PARAMS,
|
||||
EVALUATION_ARENA_MODELS,
|
||||
# WebUI (OAuth)
|
||||
ENABLE_OAUTH_ROLE_MANAGEMENT,
|
||||
@@ -432,6 +439,7 @@ from open_webui.config import (
|
||||
RESPONSE_WATERMARK,
|
||||
# Admin
|
||||
ENABLE_ADMIN_CHAT_ACCESS,
|
||||
ENABLE_ADMIN_ANALYTICS,
|
||||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
ENABLE_ADMIN_EXPORT,
|
||||
# Tasks
|
||||
@@ -498,6 +506,7 @@ from open_webui.env import (
|
||||
WEBUI_ADMIN_PASSWORD,
|
||||
WEBUI_ADMIN_NAME,
|
||||
ENABLE_EASTER_EGGS,
|
||||
LOG_FORMAT,
|
||||
)
|
||||
|
||||
|
||||
@@ -518,7 +527,7 @@ from open_webui.utils.middleware import (
|
||||
process_chat_payload,
|
||||
process_chat_response,
|
||||
)
|
||||
from open_webui.utils.tools import set_tool_servers
|
||||
from open_webui.utils.tools import set_tool_servers, set_terminal_servers
|
||||
|
||||
from open_webui.utils.auth import (
|
||||
get_license_data,
|
||||
@@ -576,7 +585,8 @@ class SPAStaticFiles(StaticFiles):
|
||||
raise ex
|
||||
|
||||
|
||||
print(rf"""
|
||||
if LOG_FORMAT != "json":
|
||||
print(rf"""
|
||||
██████╗ ██████╗ ███████╗███╗ ██╗ ██╗ ██╗███████╗██████╗ ██╗ ██╗██╗
|
||||
██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██║ ██║██╔════╝██╔══██╗██║ ██║██║
|
||||
██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ██║ █╗ ██║█████╗ ██████╔╝██║ ██║██║
|
||||
@@ -639,25 +649,28 @@ async def lifespan(app: FastAPI):
|
||||
asyncio.create_task(periodic_session_pool_cleanup())
|
||||
|
||||
if app.state.config.ENABLE_BASE_MODELS_CACHE:
|
||||
await get_all_models(
|
||||
Request(
|
||||
# Creating a mock request object to pass to get_all_models
|
||||
{
|
||||
"type": "http",
|
||||
"asgi.version": "3.0",
|
||||
"asgi.spec_version": "2.0",
|
||||
"method": "GET",
|
||||
"path": "/internal",
|
||||
"query_string": b"",
|
||||
"headers": Headers({}).raw,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("127.0.0.1", 80),
|
||||
"scheme": "http",
|
||||
"app": app,
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
try:
|
||||
await get_all_models(
|
||||
Request(
|
||||
# Creating a mock request object to pass to get_all_models
|
||||
{
|
||||
"type": "http",
|
||||
"asgi.version": "3.0",
|
||||
"asgi.spec_version": "2.0",
|
||||
"method": "GET",
|
||||
"path": "/internal",
|
||||
"query_string": b"",
|
||||
"headers": Headers({}).raw,
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"server": ("127.0.0.1", 80),
|
||||
"scheme": "http",
|
||||
"app": app,
|
||||
}
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to pre-fetch models at startup: {e}")
|
||||
|
||||
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
|
||||
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
||||
@@ -680,8 +693,13 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
await set_tool_servers(mock_request)
|
||||
log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)")
|
||||
|
||||
await set_terminal_servers(mock_request)
|
||||
log.info(
|
||||
f"Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)"
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to initialize tool servers at startup: {e}")
|
||||
log.warning(f"Failed to initialize tool/terminal servers at startup: {e}")
|
||||
|
||||
yield
|
||||
|
||||
@@ -765,6 +783,15 @@ app.state.OPENAI_MODELS = {}
|
||||
app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS
|
||||
app.state.TOOL_SERVERS = []
|
||||
|
||||
########################################
|
||||
#
|
||||
# TERMINAL SERVER
|
||||
#
|
||||
########################################
|
||||
|
||||
app.state.config.TERMINAL_SERVER_CONNECTIONS = TERMINAL_SERVER_CONNECTIONS
|
||||
app.state.TERMINAL_SERVERS = []
|
||||
|
||||
########################################
|
||||
#
|
||||
# DIRECT CONNECTIONS
|
||||
@@ -816,6 +843,8 @@ app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
|
||||
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
|
||||
app.state.config.DEFAULT_PINNED_MODELS = DEFAULT_PINNED_MODELS
|
||||
app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
|
||||
app.state.config.DEFAULT_MODEL_METADATA = DEFAULT_MODEL_METADATA
|
||||
app.state.config.DEFAULT_MODEL_PARAMS = DEFAULT_MODEL_PARAMS
|
||||
|
||||
|
||||
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
|
||||
@@ -977,6 +1006,7 @@ app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE
|
||||
app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL
|
||||
app.state.config.RAG_EMBEDDING_BATCH_SIZE = RAG_EMBEDDING_BATCH_SIZE
|
||||
app.state.config.ENABLE_ASYNC_EMBEDDING = ENABLE_ASYNC_EMBEDDING
|
||||
app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = RAG_EMBEDDING_CONCURRENT_REQUESTS
|
||||
|
||||
app.state.config.RAG_RERANKING_ENGINE = RAG_RERANKING_ENGINE
|
||||
app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
|
||||
@@ -1062,6 +1092,7 @@ app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
|
||||
app.state.config.YANDEX_WEB_SEARCH_URL = YANDEX_WEB_SEARCH_URL
|
||||
app.state.config.YANDEX_WEB_SEARCH_API_KEY = YANDEX_WEB_SEARCH_API_KEY
|
||||
app.state.config.YANDEX_WEB_SEARCH_CONFIG = YANDEX_WEB_SEARCH_CONFIG
|
||||
app.state.config.YOUCOM_API_KEY = YOUCOM_API_KEY
|
||||
|
||||
|
||||
app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
|
||||
@@ -1130,6 +1161,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||
else None
|
||||
),
|
||||
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
concurrent_requests=app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
)
|
||||
|
||||
app.state.RERANKING_FUNCTION = get_reranking_function(
|
||||
@@ -1366,46 +1398,52 @@ app.add_middleware(RedirectMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
|
||||
class APIKeyRestrictionMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
auth_header = request.headers.get("Authorization")
|
||||
token = None
|
||||
class APIKeyRestrictionMiddleware:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
if auth_header:
|
||||
parts = auth_header.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
token = parts[1]
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
request = Request(scope)
|
||||
auth_header = request.headers.get("Authorization")
|
||||
token = None
|
||||
|
||||
# Only apply restrictions if an sk- API key is used
|
||||
if token and token.startswith("sk-"):
|
||||
# Check if restrictions are enabled
|
||||
if request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
|
||||
allowed_paths = [
|
||||
path.strip()
|
||||
for path in str(
|
||||
request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS
|
||||
).split(",")
|
||||
if path.strip()
|
||||
]
|
||||
if auth_header:
|
||||
parts = auth_header.split(" ", 1)
|
||||
if len(parts) == 2:
|
||||
token = parts[1]
|
||||
|
||||
request_path = request.url.path
|
||||
# Only apply restrictions if an sk- API key is used
|
||||
if token and token.startswith("sk-"):
|
||||
# Check if restrictions are enabled
|
||||
if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS:
|
||||
allowed_paths = [
|
||||
path.strip()
|
||||
for path in str(
|
||||
app.state.config.API_KEYS_ALLOWED_ENDPOINTS
|
||||
).split(",")
|
||||
if path.strip()
|
||||
]
|
||||
|
||||
# Match exact path or prefix path
|
||||
is_allowed = any(
|
||||
request_path == allowed or request_path.startswith(allowed + "/")
|
||||
for allowed in allowed_paths
|
||||
)
|
||||
request_path = request.url.path
|
||||
|
||||
if not is_allowed:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={
|
||||
"detail": "API key not allowed to access this endpoint."
|
||||
},
|
||||
# Match exact path or prefix path
|
||||
is_allowed = any(
|
||||
request_path == allowed
|
||||
or request_path.startswith(allowed + "/")
|
||||
for allowed in allowed_paths
|
||||
)
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
if not is_allowed:
|
||||
await JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={
|
||||
"detail": "API key not allowed to access this endpoint."
|
||||
},
|
||||
)(scope, receive, send)
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
app.add_middleware(APIKeyRestrictionMiddleware)
|
||||
@@ -1439,6 +1477,16 @@ async def check_url(request: Request, call_next):
|
||||
scheme="Bearer", credentials=request.cookies.get("token")
|
||||
)
|
||||
|
||||
# Fallback to x-api-key header for Anthropic Messages API routes
|
||||
if request.state.token is None and request.headers.get("x-api-key"):
|
||||
request_path = request.url.path
|
||||
if request_path in ("/api/message", "/api/v1/messages"):
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
request.state.token = HTTPAuthorizationCredentials(
|
||||
scheme="Bearer", credentials=request.headers.get("x-api-key")
|
||||
)
|
||||
|
||||
request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS
|
||||
response = await call_next(request)
|
||||
process_time = int(time.time()) - start_time
|
||||
@@ -1512,8 +1560,10 @@ app.include_router(functions.router, prefix="/api/v1/functions", tags=["function
|
||||
app.include_router(
|
||||
evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"]
|
||||
)
|
||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||
if ENABLE_ADMIN_ANALYTICS:
|
||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||
app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"])
|
||||
app.include_router(terminals.router, prefix="/api/v1/terminals", tags=["terminals"])
|
||||
|
||||
# SCIM 2.0 API for identity management
|
||||
if ENABLE_SCIM:
|
||||
@@ -1668,9 +1718,18 @@ async def chat_completion(
|
||||
request.state.direct = True
|
||||
request.state.model = model
|
||||
|
||||
model_info_params = (
|
||||
model_info.params.model_dump() if model_info and model_info.params else {}
|
||||
# Model params: global defaults as base, per-model overrides win
|
||||
default_model_params = (
|
||||
getattr(request.app.state.config, "DEFAULT_MODEL_PARAMS", None) or {}
|
||||
)
|
||||
model_info_params = {
|
||||
**default_model_params,
|
||||
**(
|
||||
model_info.params.model_dump()
|
||||
if model_info and model_info.params
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
# Check base model existence for custom models
|
||||
if model_info_params.get("base_model_id"):
|
||||
@@ -1685,8 +1744,13 @@ async def chat_completion(
|
||||
default_models[0].strip() if default_models[0] else None
|
||||
)
|
||||
|
||||
if fallback_model_id:
|
||||
request.base_model_id = fallback_model_id
|
||||
if (
|
||||
fallback_model_id
|
||||
and fallback_model_id in request.app.state.MODELS
|
||||
):
|
||||
# Update model and form_data so routing uses the fallback model's type
|
||||
model = request.app.state.MODELS[fallback_model_id]
|
||||
form_data["model"] = fallback_model_id
|
||||
else:
|
||||
raise Exception("Model not found")
|
||||
else:
|
||||
@@ -1742,9 +1806,12 @@ async def chat_completion(
|
||||
"local:"
|
||||
): # temporary chats are not stored
|
||||
|
||||
# Verify chat ownership
|
||||
chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id)
|
||||
if chat is None and user.role != "admin": # admins can access any chat
|
||||
# Verify chat ownership — lightweight EXISTS check avoids
|
||||
# deserializing the full chat JSON blob just to confirm the row exists
|
||||
if (
|
||||
not Chats.is_chat_owner(metadata["chat_id"], user.id)
|
||||
and user.role != "admin"
|
||||
): # admins can access any chat
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
@@ -1890,6 +1957,68 @@ generate_chat_completions = chat_completion
|
||||
generate_chat_completion = chat_completion
|
||||
|
||||
|
||||
##################################
|
||||
#
|
||||
# Anthropic Messages API Compatible Endpoint
|
||||
#
|
||||
##################################
|
||||
|
||||
|
||||
from open_webui.utils.anthropic import (
|
||||
convert_anthropic_to_openai_payload,
|
||||
convert_openai_to_anthropic_response,
|
||||
openai_stream_to_anthropic_stream,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/message")
|
||||
@app.post("/api/v1/messages") # Anthropic Messages API compatible endpoint
|
||||
async def generate_messages(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""
|
||||
Anthropic Messages API compatible endpoint.
|
||||
|
||||
Accepts the Anthropic Messages API format, converts internally to OpenAI
|
||||
Chat Completions format, routes through the existing chat completion
|
||||
pipeline, then converts the response back to Anthropic Messages format.
|
||||
|
||||
Supports both streaming and non-streaming requests.
|
||||
All models configured in Open WebUI are accessible via this endpoint.
|
||||
|
||||
Authentication: Supports both standard Authorization header and
|
||||
Anthropic's x-api-key header (via middleware translation).
|
||||
"""
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
requested_model = form_data.get("model", "")
|
||||
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data)
|
||||
|
||||
# Route through the existing chat_completion handler
|
||||
response = await chat_completion(request, openai_payload, user)
|
||||
|
||||
# Convert response back to Anthropic format
|
||||
if isinstance(response, StreamingResponse):
|
||||
# Streaming response: wrap the generator to convert SSE format
|
||||
return StreamingResponse(
|
||||
openai_stream_to_anthropic_stream(
|
||||
response.body_iterator, model=requested_model
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
elif isinstance(response, dict):
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model)
|
||||
else:
|
||||
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/chat/completed")
|
||||
async def chat_completed(
|
||||
request: Request, form_data: dict, user=Depends(get_verified_user)
|
||||
@@ -2039,6 +2168,7 @@ async def get_app_config(request: Request):
|
||||
"enable_user_status": app.state.config.ENABLE_USER_STATUS,
|
||||
"enable_admin_export": ENABLE_ADMIN_EXPORT,
|
||||
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
|
||||
"enable_admin_analytics": ENABLE_ADMIN_ANALYTICS,
|
||||
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
|
||||
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
|
||||
"enable_memories": app.state.config.ENABLE_MEMORIES,
|
||||
@@ -2063,6 +2193,7 @@ async def get_app_config(request: Request):
|
||||
"user_count": user_count,
|
||||
"code": {
|
||||
"engine": app.state.config.CODE_EXECUTION_ENGINE,
|
||||
"interpreter_engine": app.state.config.CODE_INTERPRETER_ENGINE,
|
||||
},
|
||||
"audio": {
|
||||
"tts": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD
|
||||
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
|
||||
from sqlalchemy import engine_from_config, pool, create_engine
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
@@ -14,6 +15,13 @@ config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# Re-apply JSON formatter after fileConfig replaces handlers.
|
||||
if LOG_FORMAT == "json":
|
||||
from open_webui.env import JSONFormatter
|
||||
|
||||
for handler in logging.root.handlers:
|
||||
handler.setFormatter(JSONFormatter())
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
|
||||
@@ -21,6 +21,39 @@ down_revision: Union[str, None] = "374d2f66af06"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BATCH_SIZE = 5000
|
||||
|
||||
|
||||
def _flush_batch(conn, table, batch):
|
||||
"""
|
||||
Insert a batch of messages, falling back to row-by-row on error.
|
||||
|
||||
Tries a single bulk insert first (fast path). If that fails (e.g. due to
|
||||
a duplicate key), falls back to individual inserts wrapped in savepoints
|
||||
so the rest of the batch can still succeed.
|
||||
"""
|
||||
savepoint = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(sa.insert(table), batch)
|
||||
savepoint.commit()
|
||||
return len(batch), 0
|
||||
except Exception:
|
||||
savepoint.rollback()
|
||||
# Batch failed - insert one-by-one to isolate the bad row(s)
|
||||
inserted = 0
|
||||
failed = 0
|
||||
for msg in batch:
|
||||
sp = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(sa.insert(table).values(**msg))
|
||||
sp.commit()
|
||||
inserted += 1
|
||||
except Exception as e:
|
||||
sp.rollback()
|
||||
failed += 1
|
||||
log.warning(f"Failed to insert message {msg['id']}: {e}")
|
||||
return inserted, failed
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Step 1: Create table
|
||||
@@ -88,18 +121,21 @@ def upgrade() -> None:
|
||||
sa.column("updated_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
# Fetch all chats (excluding shared chats which have user_id starting with 'shared-')
|
||||
chats = conn.execute(
|
||||
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat).where(
|
||||
~chat_table.c.user_id.like("shared-%")
|
||||
)
|
||||
).fetchall()
|
||||
# Stream rows instead of loading all into memory:
|
||||
# - yield_per: fetches rows in chunks via cursor.fetchmany() (all backends)
|
||||
# - stream_results: enables server-side cursors on PostgreSQL (no-op on SQLite)
|
||||
result = conn.execute(
|
||||
sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat)
|
||||
.where(~chat_table.c.user_id.like("shared-%"))
|
||||
.execution_options(yield_per=1000, stream_results=True)
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
messages_inserted = 0
|
||||
messages_failed = 0
|
||||
messages_batch = []
|
||||
total_inserted = 0
|
||||
total_failed = 0
|
||||
|
||||
for chat_row in chats:
|
||||
for chat_row in result:
|
||||
chat_id = chat_row[0]
|
||||
user_id = chat_row[1]
|
||||
chat_data = chat_row[2]
|
||||
@@ -127,6 +163,11 @@ def upgrade() -> None:
|
||||
|
||||
timestamp = message.get("timestamp", now)
|
||||
|
||||
try:
|
||||
timestamp = int(float(timestamp))
|
||||
except Exception as e:
|
||||
timestamp = now
|
||||
|
||||
# Normalize timestamp: convert ms to seconds, validate range
|
||||
if timestamp > 10_000_000_000:
|
||||
timestamp = timestamp // 1000
|
||||
@@ -134,39 +175,49 @@ def upgrade() -> None:
|
||||
if timestamp < 1577836800 or timestamp > now + 86400:
|
||||
timestamp = now
|
||||
|
||||
# Use savepoint to allow individual insert failures without aborting transaction
|
||||
savepoint = conn.begin_nested()
|
||||
try:
|
||||
conn.execute(
|
||||
sa.insert(chat_message_table).values(
|
||||
id=f"{chat_id}-{message_id}",
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
parent_id=message.get("parentId"),
|
||||
content=message.get("content"),
|
||||
output=message.get("output"),
|
||||
model_id=message.get("model"),
|
||||
files=message.get("files"),
|
||||
sources=message.get("sources"),
|
||||
embeds=message.get("embeds"),
|
||||
done=message.get("done", True),
|
||||
status_history=message.get("statusHistory"),
|
||||
error=message.get("error"),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
messages_batch.append(
|
||||
{
|
||||
"id": f"{chat_id}-{message_id}",
|
||||
"chat_id": chat_id,
|
||||
"user_id": user_id,
|
||||
"role": role,
|
||||
"parent_id": message.get("parentId"),
|
||||
"content": message.get("content"),
|
||||
"output": message.get("output"),
|
||||
"model_id": message.get("model"),
|
||||
"files": message.get("files"),
|
||||
"sources": message.get("sources"),
|
||||
"embeds": message.get("embeds"),
|
||||
"done": message.get("done", True),
|
||||
"status_history": message.get("statusHistory"),
|
||||
"error": message.get("error"),
|
||||
"usage": message.get("usage"),
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
# Flush batch when full
|
||||
if len(messages_batch) >= BATCH_SIZE:
|
||||
inserted, failed = _flush_batch(
|
||||
conn, chat_message_table, messages_batch
|
||||
)
|
||||
savepoint.commit()
|
||||
messages_inserted += 1
|
||||
except Exception as e:
|
||||
savepoint.rollback()
|
||||
messages_failed += 1
|
||||
log.warning(f"Failed to insert message {message_id}: {e}")
|
||||
continue
|
||||
total_inserted += inserted
|
||||
total_failed += failed
|
||||
if total_inserted % 50000 < BATCH_SIZE:
|
||||
log.info(
|
||||
f"Migration progress: {total_inserted} messages inserted..."
|
||||
)
|
||||
messages_batch.clear()
|
||||
|
||||
# Flush remaining messages
|
||||
if messages_batch:
|
||||
inserted, failed = _flush_batch(conn, chat_message_table, messages_batch)
|
||||
total_inserted += inserted
|
||||
total_failed += failed
|
||||
|
||||
log.info(
|
||||
f"Backfilled {messages_inserted} messages into chat_message table ({messages_failed} failed)"
|
||||
f"Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -204,6 +204,43 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def has_user_access_grant(access_grants: Optional[list]) -> bool:
|
||||
"""
|
||||
Returns True when a direct grant list includes any non-wildcard user grant.
|
||||
"""
|
||||
for grant in normalize_access_grants(access_grants):
|
||||
if grant["principal_type"] == "user" and grant["principal_id"] != "*":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def strip_user_access_grants(access_grants: Optional[list]) -> list:
|
||||
"""
|
||||
Remove all non-wildcard user grants from the list.
|
||||
Keeps group grants and the public wildcard (user:*) intact.
|
||||
"""
|
||||
if not access_grants:
|
||||
return []
|
||||
return [
|
||||
grant
|
||||
for grant in access_grants
|
||||
if not (
|
||||
(
|
||||
grant.get("principal_type")
|
||||
if isinstance(grant, dict)
|
||||
else getattr(grant, "principal_type", None)
|
||||
)
|
||||
== "user"
|
||||
and (
|
||||
grant.get("principal_id")
|
||||
if isinstance(grant, dict)
|
||||
else getattr(grant, "principal_id", None)
|
||||
)
|
||||
!= "*"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def grants_to_access_control(grants: list) -> Optional[dict]:
|
||||
"""
|
||||
Convert a list of grant objects (AccessGrantModel or AccessGrantResponse)
|
||||
@@ -402,7 +439,7 @@ class AccessGrantsTable:
|
||||
results = []
|
||||
for grant_dict in normalized_grants:
|
||||
grant = AccessGrant(
|
||||
id=grant_dict["id"],
|
||||
id=str(uuid.uuid4()),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
principal_type=grant_dict["principal_type"],
|
||||
@@ -456,6 +493,31 @@ class AccessGrantsTable:
|
||||
)
|
||||
return [AccessGrantModel.model_validate(g) for g in grants]
|
||||
|
||||
def get_grants_by_resources(
|
||||
self,
|
||||
resource_type: str,
|
||||
resource_ids: list[str],
|
||||
db: Optional[Session] = None,
|
||||
) -> dict[str, list[AccessGrantModel]]:
|
||||
"""Batch-fetch grants for multiple resources. Returns {resource_id: [grants]}."""
|
||||
if not resource_ids:
|
||||
return {}
|
||||
with get_db_context(db) as db:
|
||||
grants = (
|
||||
db.query(AccessGrant)
|
||||
.filter(
|
||||
AccessGrant.resource_type == resource_type,
|
||||
AccessGrant.resource_id.in_(resource_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
result: dict[str, list[AccessGrantModel]] = {
|
||||
rid: [] for rid in resource_ids
|
||||
}
|
||||
for g in grants:
|
||||
result[g.resource_id].append(AccessGrantModel.model_validate(g))
|
||||
return result
|
||||
|
||||
def has_access(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -146,7 +146,7 @@ class AuthsTable:
|
||||
def authenticate_user_by_api_key(
|
||||
self, api_key: str, db: Optional[Session] = None
|
||||
) -> Optional[UserModel]:
|
||||
log.info(f"authenticate_user_by_api_key: {api_key}")
|
||||
log.info(f"authenticate_user_by_api_key")
|
||||
# if no api_key, return None
|
||||
if not api_key:
|
||||
return None
|
||||
@@ -197,7 +197,10 @@ class AuthsTable:
|
||||
with get_db_context(db) as db:
|
||||
result = db.query(Auth).filter_by(id=id).update({"email": email})
|
||||
db.commit()
|
||||
return True if result == 1 else False
|
||||
if result == 1:
|
||||
Users.update_user_by_id(id, {"email": email}, db=db)
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -261,13 +261,19 @@ class ChannelTable:
|
||||
return AccessGrants.get_grants_by_resource("channel", channel_id, db=db)
|
||||
|
||||
def _to_channel_model(
|
||||
self, channel: Channel, db: Optional[Session] = None
|
||||
self,
|
||||
channel: Channel,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> ChannelModel:
|
||||
channel_data = ChannelModel.model_validate(channel).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
access_grants = self._get_access_grants(channel_data["id"], db=db)
|
||||
channel_data["access_grants"] = access_grants
|
||||
channel_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(channel_data["id"], db=db)
|
||||
)
|
||||
return ChannelModel.model_validate(channel_data)
|
||||
|
||||
def _collect_unique_user_ids(
|
||||
@@ -368,7 +374,18 @@ class ChannelTable:
|
||||
def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]:
|
||||
with get_db_context(db) as db:
|
||||
channels = db.query(Channel).all()
|
||||
return [self._to_channel_model(channel, db=db) for channel in channels]
|
||||
channel_ids = [channel.id for channel in channels]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"channel", channel_ids, db=db
|
||||
)
|
||||
return [
|
||||
self._to_channel_model(
|
||||
channel,
|
||||
access_grants=grants_map.get(channel.id, []),
|
||||
db=db,
|
||||
)
|
||||
for channel in channels
|
||||
]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
return AccessGrants.has_permission_filter(
|
||||
@@ -417,7 +434,14 @@ class ChannelTable:
|
||||
standard_channels = query.all()
|
||||
|
||||
all_channels = membership_channels + standard_channels
|
||||
return [self._to_channel_model(c, db=db) for c in all_channels]
|
||||
channel_ids = [c.id for c in all_channels]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"channel", channel_ids, db=db
|
||||
)
|
||||
return [
|
||||
self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db)
|
||||
for c in all_channels
|
||||
]
|
||||
|
||||
def get_dm_channel_by_user_ids(
|
||||
self, user_ids: list[str], db: Optional[Session] = None
|
||||
@@ -724,7 +748,17 @@ class ChannelTable:
|
||||
)
|
||||
channel_ids = [cf.channel_id for cf in channel_files]
|
||||
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
|
||||
return [self._to_channel_model(channel, db=db) for channel in channels]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"channel", channel_ids, db=db
|
||||
)
|
||||
return [
|
||||
self._to_channel_model(
|
||||
channel,
|
||||
access_grants=grants_map.get(channel.id, []),
|
||||
db=db,
|
||||
)
|
||||
for channel in channels
|
||||
]
|
||||
|
||||
def get_channels_by_file_id_and_user_id(
|
||||
self, file_id: str, user_id: str, db: Optional[Session] = None
|
||||
|
||||
@@ -292,9 +292,11 @@ class ChatMessageTable:
|
||||
query = query.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
# Group by chat_id and order by most recent message in each chat
|
||||
# Secondary sort on chat_id ensures deterministic pagination
|
||||
# (prevents duplicates across pages when timestamps tie)
|
||||
chat_ids = (
|
||||
query.group_by(ChatMessage.chat_id)
|
||||
.order_by(func.max(ChatMessage.created_at).desc())
|
||||
.order_by(func.max(ChatMessage.created_at).desc(), ChatMessage.chat_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
|
||||
@@ -456,11 +456,11 @@ class ChatTable:
|
||||
return ChatModel.model_validate(chat)
|
||||
|
||||
def get_chat_title_by_id(self, id: str) -> Optional[str]:
|
||||
chat = self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
return chat.chat.get("title", "New Chat")
|
||||
with get_db_context() as db:
|
||||
result = db.query(Chat.title).filter_by(id=id).first()
|
||||
if result is None:
|
||||
return None
|
||||
return result[0] or "New Chat"
|
||||
|
||||
def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
|
||||
chat = self.get_chat_by_id(id)
|
||||
@@ -489,6 +489,7 @@ class ChatTable:
|
||||
if isinstance(message.get("content"), str):
|
||||
message["content"] = sanitize_text_for_db(message["content"])
|
||||
|
||||
user_id = chat.user_id
|
||||
chat = chat.chat
|
||||
history = chat.get("history", {})
|
||||
|
||||
@@ -509,7 +510,7 @@ class ChatTable:
|
||||
ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=id,
|
||||
user_id=self.get_chat_by_id(id).user_id,
|
||||
user_id=user_id,
|
||||
data=history["messages"][message_id],
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -630,7 +631,9 @@ class ChatTable:
|
||||
with get_db_context(db) as db:
|
||||
# Use subquery to delete chat_messages for shared chats
|
||||
shared_chat_id_subquery = (
|
||||
db.query(Chat.id).filter_by(user_id=f"shared-{chat_id}").subquery()
|
||||
db.query(Chat.id)
|
||||
.filter_by(user_id=f"shared-{chat_id}")
|
||||
.scalar_subquery()
|
||||
)
|
||||
db.query(ChatMessage).filter(
|
||||
ChatMessage.chat_id.in_(shared_chat_id_subquery)
|
||||
@@ -713,7 +716,7 @@ class ChatTable:
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ChatModel]:
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id, archived=True)
|
||||
@@ -731,13 +734,17 @@ class ChatTable:
|
||||
raise ValueError("Invalid order_by field")
|
||||
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
query = query.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
query = query.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
query = query.with_entities(
|
||||
Chat.id, Chat.title, Chat.updated_at, Chat.created_at
|
||||
)
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
@@ -745,7 +752,17 @@ class ChatTable:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
return [
|
||||
ChatTitleIdResponse.model_validate(
|
||||
{
|
||||
"id": chat[0],
|
||||
"title": chat[1],
|
||||
"updated_at": chat[2],
|
||||
"created_at": chat[3],
|
||||
}
|
||||
)
|
||||
for chat in all_chats
|
||||
]
|
||||
|
||||
def get_shared_chat_list_by_user_id(
|
||||
self,
|
||||
@@ -754,7 +771,7 @@ class ChatTable:
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ChatModel]:
|
||||
) -> list[SharedChatResponse]:
|
||||
|
||||
with get_db_context(db) as db:
|
||||
query = (
|
||||
@@ -776,13 +793,23 @@ class ChatTable:
|
||||
raise ValueError("Invalid order_by field")
|
||||
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
query = query.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
query = query.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
# Select only the columns needed for SharedChatResponse
|
||||
# to avoid loading the heavy chat JSON blob
|
||||
query = query.with_entities(
|
||||
Chat.id,
|
||||
Chat.title,
|
||||
Chat.share_id,
|
||||
Chat.updated_at,
|
||||
Chat.created_at,
|
||||
)
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
@@ -790,7 +817,18 @@ class ChatTable:
|
||||
query = query.limit(limit)
|
||||
|
||||
all_chats = query.all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
return [
|
||||
SharedChatResponse.model_validate(
|
||||
{
|
||||
"id": chat[0],
|
||||
"title": chat[1],
|
||||
"share_id": chat[2],
|
||||
"updated_at": chat[3],
|
||||
"created_at": chat[4],
|
||||
}
|
||||
)
|
||||
for chat in all_chats
|
||||
]
|
||||
|
||||
def get_chat_list_by_user_id(
|
||||
self,
|
||||
@@ -816,13 +854,13 @@ class ChatTable:
|
||||
|
||||
if order_by and direction and getattr(Chat, order_by):
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
query = query.order_by(getattr(Chat, order_by).asc(), Chat.id)
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
query = query.order_by(getattr(Chat, order_by).desc(), Chat.id)
|
||||
else:
|
||||
raise ValueError("Invalid direction for ordering")
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
@@ -854,7 +892,7 @@ class ChatTable:
|
||||
if not include_archived:
|
||||
query = query.filter_by(archived=False)
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc()).with_entities(
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities(
|
||||
Chat.id, Chat.title, Chat.updated_at, Chat.created_at
|
||||
)
|
||||
|
||||
@@ -938,6 +976,37 @@ class ChatTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_chat_owner(
|
||||
self, id: str, user_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Lightweight ownership check — uses EXISTS subquery instead of loading
|
||||
the full Chat row (which includes the potentially large JSON blob).
|
||||
"""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
return db.query(
|
||||
exists().where(and_(Chat.id == id, Chat.user_id == user_id))
|
||||
).scalar()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_chat_folder_id(
|
||||
self, id: str, user_id: str, db: Optional[Session] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Fetch only the folder_id column for a chat, without loading the full
|
||||
JSON blob. Returns None if chat doesn't exist or doesn't belong to user.
|
||||
"""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
result = (
|
||||
db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first()
|
||||
)
|
||||
return result[0] if result else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_chats(
|
||||
self, skip: int = 0, limit: int = 50, db: Optional[Session] = None
|
||||
) -> list[ChatModel]:
|
||||
@@ -970,14 +1039,18 @@ class ChatTable:
|
||||
if order_by and direction:
|
||||
if hasattr(Chat, order_by):
|
||||
if direction.lower() == "asc":
|
||||
query = query.order_by(getattr(Chat, order_by).asc())
|
||||
query = query.order_by(
|
||||
getattr(Chat, order_by).asc(), Chat.id
|
||||
)
|
||||
elif direction.lower() == "desc":
|
||||
query = query.order_by(getattr(Chat, order_by).desc())
|
||||
query = query.order_by(
|
||||
getattr(Chat, order_by).desc(), Chat.id
|
||||
)
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
else:
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
total = query.count()
|
||||
|
||||
@@ -997,14 +1070,25 @@ class ChatTable:
|
||||
|
||||
def get_pinned_chats_by_user_id(
|
||||
self, user_id: str, db: Optional[Session] = None
|
||||
) -> list[ChatModel]:
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
with get_db_context(db) as db:
|
||||
all_chats = (
|
||||
db.query(Chat)
|
||||
.filter_by(user_id=user_id, pinned=True, archived=False)
|
||||
.order_by(Chat.updated_at.desc())
|
||||
.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at)
|
||||
)
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
return [
|
||||
ChatTitleIdResponse.model_validate(
|
||||
{
|
||||
"id": chat[0],
|
||||
"title": chat[1],
|
||||
"updated_at": chat[2],
|
||||
"created_at": chat[3],
|
||||
}
|
||||
)
|
||||
for chat in all_chats
|
||||
]
|
||||
|
||||
def get_archived_chats_by_user_id(
|
||||
self, user_id: str, db: Optional[Session] = None
|
||||
@@ -1108,7 +1192,7 @@ class ChatTable:
|
||||
if folder_ids:
|
||||
query = query.filter(Chat.folder_id.in_(folder_ids))
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
# Check if the database dialect is either 'sqlite' or 'postgresql'
|
||||
dialect_name = db.bind.dialect.name
|
||||
@@ -1229,7 +1313,7 @@ class ChatTable:
|
||||
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
query = query.filter_by(archived=False)
|
||||
|
||||
query = query.order_by(Chat.updated_at.desc())
|
||||
query = query.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
|
||||
@@ -71,6 +71,7 @@ class FolderForm(BaseModel):
|
||||
name: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
parent_id: Optional[str] = None
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
|
||||
@@ -308,6 +308,28 @@ class FunctionsTable:
|
||||
log.exception(f"Error getting function valves by id {id}: {e}")
|
||||
return None
|
||||
|
||||
def get_function_valves_by_ids(
|
||||
self, ids: list[str], db: Optional[Session] = None
|
||||
) -> dict[str, dict]:
|
||||
"""
|
||||
Batch fetch valves for multiple functions in a single query.
|
||||
Returns a dict mapping function_id -> valves dict.
|
||||
Functions without valves are mapped to {}.
|
||||
"""
|
||||
if not ids:
|
||||
return {}
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
functions = (
|
||||
db.query(Function.id, Function.valves)
|
||||
.filter(Function.id.in_(ids))
|
||||
.all()
|
||||
)
|
||||
return {f.id: (f.valves if f.valves else {}) for f in functions}
|
||||
except Exception as e:
|
||||
log.exception(f"Error batch-fetching function valves: {e}")
|
||||
return {}
|
||||
|
||||
def update_function_valves_by_id(
|
||||
self, id: str, valves: dict, db: Optional[Session] = None
|
||||
) -> Optional[FunctionValves]:
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION
|
||||
|
||||
from open_webui.models.files import FileMetadataResponse
|
||||
|
||||
@@ -130,13 +131,26 @@ class GroupListResponse(BaseModel):
|
||||
|
||||
|
||||
class GroupTable:
|
||||
def _ensure_default_share_config(self, group_data: dict) -> dict:
|
||||
"""Ensure the group data dict has a default share config if not already set."""
|
||||
if "data" not in group_data or group_data["data"] is None:
|
||||
group_data["data"] = {}
|
||||
if "config" not in group_data["data"]:
|
||||
group_data["data"]["config"] = {}
|
||||
if "share" not in group_data["data"]["config"]:
|
||||
group_data["data"]["config"]["share"] = DEFAULT_GROUP_SHARE_PERMISSION
|
||||
return group_data
|
||||
|
||||
def insert_new_group(
|
||||
self, user_id: str, form_data: GroupForm, db: Optional[Session] = None
|
||||
) -> Optional[GroupModel]:
|
||||
with get_db_context(db) as db:
|
||||
group_data = self._ensure_default_share_config(
|
||||
form_data.model_dump(exclude_none=True)
|
||||
)
|
||||
group = GroupModel(
|
||||
**{
|
||||
**form_data.model_dump(exclude_none=True),
|
||||
**group_data,
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
@@ -164,10 +178,14 @@ class GroupTable:
|
||||
|
||||
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
|
||||
with get_db_context(db) as db:
|
||||
member_count = func.count(GroupMember.user_id).label("member_count")
|
||||
query = db.query(Group, member_count).outerjoin(
|
||||
GroupMember, GroupMember.group_id == Group.id
|
||||
member_count = (
|
||||
select(func.count(GroupMember.user_id))
|
||||
.where(GroupMember.group_id == Group.id)
|
||||
.correlate(Group)
|
||||
.scalar_subquery()
|
||||
.label("member_count")
|
||||
)
|
||||
query = db.query(Group, member_count)
|
||||
|
||||
if filter:
|
||||
if "query" in filter:
|
||||
@@ -218,7 +236,7 @@ class GroupTable:
|
||||
)
|
||||
)
|
||||
|
||||
results = query.group_by(Group.id).order_by(Group.updated_at.desc()).all()
|
||||
results = query.order_by(Group.updated_at.desc()).all()
|
||||
|
||||
return [
|
||||
GroupResponse.model_validate(
|
||||
@@ -260,11 +278,15 @@ class GroupTable:
|
||||
|
||||
total = query.count()
|
||||
|
||||
member_count = func.count(GroupMember.user_id).label("member_count")
|
||||
member_count = (
|
||||
select(func.count(GroupMember.user_id))
|
||||
.where(GroupMember.group_id == Group.id)
|
||||
.correlate(Group)
|
||||
.scalar_subquery()
|
||||
.label("member_count")
|
||||
)
|
||||
results = (
|
||||
query.add_columns(member_count)
|
||||
.outerjoin(GroupMember, GroupMember.group_id == Group.id)
|
||||
.group_by(Group.id)
|
||||
.order_by(Group.updated_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
@@ -496,6 +518,11 @@ class GroupTable:
|
||||
user_id=user_id,
|
||||
name=group_name,
|
||||
description="",
|
||||
data={
|
||||
"config": {
|
||||
"share": DEFAULT_GROUP_SHARE_PERMISSION,
|
||||
}
|
||||
},
|
||||
created_at=int(time.time()),
|
||||
updated_at=int(time.time()),
|
||||
)
|
||||
|
||||
@@ -144,13 +144,18 @@ class KnowledgeTable:
|
||||
return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db)
|
||||
|
||||
def _to_knowledge_model(
|
||||
self, knowledge: Knowledge, db: Optional[Session] = None
|
||||
self,
|
||||
knowledge: Knowledge,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> KnowledgeModel:
|
||||
knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
knowledge_data["access_grants"] = self._get_access_grants(
|
||||
knowledge_data["id"], db=db
|
||||
knowledge_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(knowledge_data["id"], db=db)
|
||||
)
|
||||
return KnowledgeModel.model_validate(knowledge_data)
|
||||
|
||||
@@ -192,9 +197,13 @@ class KnowledgeTable:
|
||||
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
|
||||
)
|
||||
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
|
||||
knowledge_ids = [knowledge.id for knowledge in all_knowledge]
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"knowledge", knowledge_ids, db=db
|
||||
)
|
||||
|
||||
knowledge_bases = []
|
||||
for knowledge in all_knowledge:
|
||||
@@ -202,7 +211,11 @@ class KnowledgeTable:
|
||||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
**self._to_knowledge_model(knowledge, db=db).model_dump(),
|
||||
**self._to_knowledge_model(
|
||||
knowledge,
|
||||
access_grants=grants_map.get(knowledge.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -261,13 +274,20 @@ class KnowledgeTable:
|
||||
|
||||
items = query.all()
|
||||
|
||||
knowledge_ids = [kb.id for kb, _ in items]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"knowledge", knowledge_ids, db=db
|
||||
)
|
||||
|
||||
knowledge_bases = []
|
||||
for knowledge_base, user in items:
|
||||
knowledge_bases.append(
|
||||
KnowledgeUserModel.model_validate(
|
||||
{
|
||||
**self._to_knowledge_model(
|
||||
knowledge_base, db=db
|
||||
knowledge_base,
|
||||
access_grants=grants_map.get(knowledge_base.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
"user": (
|
||||
UserModel.model_validate(user).model_dump()
|
||||
@@ -440,8 +460,16 @@ class KnowledgeTable:
|
||||
.filter(KnowledgeFile.file_id == file_id)
|
||||
.all()
|
||||
)
|
||||
knowledge_ids = [k.id for k in knowledges]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"knowledge", knowledge_ids, db=db
|
||||
)
|
||||
return [
|
||||
self._to_knowledge_model(knowledge, db=db)
|
||||
self._to_knowledge_model(
|
||||
knowledge,
|
||||
access_grants=grants_map.get(knowledge.id, []),
|
||||
db=db,
|
||||
)
|
||||
for knowledge in knowledges
|
||||
]
|
||||
except Exception:
|
||||
@@ -585,6 +613,21 @@ class KnowledgeTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_file(
|
||||
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""Check whether a file belongs to a knowledge base."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
return (
|
||||
db.query(KnowledgeFile)
|
||||
.filter_by(knowledge_id=knowledge_id, file_id=file_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def remove_file_from_knowledge_by_id(
|
||||
self, knowledge_id: str, file_id: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
|
||||
@@ -144,11 +144,20 @@ class ModelsTable:
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("model", model_id, db=db)
|
||||
|
||||
def _to_model_model(self, model: Model, db: Optional[Session] = None) -> ModelModel:
|
||||
def _to_model_model(
|
||||
self,
|
||||
model: Model,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> ModelModel:
|
||||
model_data = ModelModel.model_validate(model).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
model_data["access_grants"] = self._get_access_grants(model_data["id"], db=db)
|
||||
model_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(model_data["id"], db=db)
|
||||
)
|
||||
return ModelModel.model_validate(model_data)
|
||||
|
||||
def insert_new_model(
|
||||
@@ -181,8 +190,14 @@ class ModelsTable:
|
||||
|
||||
def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
all_models = db.query(Model).all()
|
||||
model_ids = [model.id for model in all_models]
|
||||
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
|
||||
return [
|
||||
self._to_model_model(model, db=db) for model in db.query(Model).all()
|
||||
self._to_model_model(
|
||||
model, access_grants=grants_map.get(model.id, []), db=db
|
||||
)
|
||||
for model in all_models
|
||||
]
|
||||
|
||||
def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]:
|
||||
@@ -190,9 +205,11 @@ class ModelsTable:
|
||||
all_models = db.query(Model).filter(Model.base_model_id != None).all()
|
||||
|
||||
user_ids = list(set(model.user_id for model in all_models))
|
||||
model_ids = [model.id for model in all_models]
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
|
||||
|
||||
models = []
|
||||
for model in all_models:
|
||||
@@ -200,7 +217,11 @@ class ModelsTable:
|
||||
models.append(
|
||||
ModelUserResponse.model_validate(
|
||||
{
|
||||
**self._to_model_model(model, db=db).model_dump(),
|
||||
**self._to_model_model(
|
||||
model,
|
||||
access_grants=grants_map.get(model.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -209,9 +230,14 @@ class ModelsTable:
|
||||
|
||||
def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]:
|
||||
with get_db_context(db) as db:
|
||||
all_models = db.query(Model).filter(Model.base_model_id == None).all()
|
||||
model_ids = [model.id for model in all_models]
|
||||
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
|
||||
return [
|
||||
self._to_model_model(model, db=db)
|
||||
for model in db.query(Model).filter(Model.base_model_id == None).all()
|
||||
self._to_model_model(
|
||||
model, access_grants=grants_map.get(model.id, []), db=db
|
||||
)
|
||||
for model in all_models
|
||||
]
|
||||
|
||||
def get_models_by_user_id(
|
||||
@@ -325,11 +351,18 @@ class ModelsTable:
|
||||
|
||||
items = query.all()
|
||||
|
||||
model_ids = [model.id for model, _ in items]
|
||||
grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db)
|
||||
|
||||
models = []
|
||||
for model, user in items:
|
||||
models.append(
|
||||
ModelUserResponse(
|
||||
**self._to_model_model(model, db=db).model_dump(),
|
||||
**self._to_model_model(
|
||||
model,
|
||||
access_grants=grants_map.get(model.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
@@ -356,7 +389,18 @@ class ModelsTable:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
models = db.query(Model).filter(Model.id.in_(ids)).all()
|
||||
return [self._to_model_model(model, db=db) for model in models]
|
||||
model_ids = [model.id for model in models]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"model", model_ids, db=db
|
||||
)
|
||||
return [
|
||||
self._to_model_model(
|
||||
model,
|
||||
access_grants=grants_map.get(model.id, []),
|
||||
db=db,
|
||||
)
|
||||
for model in models
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -465,9 +509,18 @@ class ModelsTable:
|
||||
|
||||
db.commit()
|
||||
|
||||
all_models = db.query(Model).all()
|
||||
model_ids = [model.id for model in all_models]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"model", model_ids, db=db
|
||||
)
|
||||
return [
|
||||
self._to_model_model(model, db=db)
|
||||
for model in db.query(Model).all()
|
||||
self._to_model_model(
|
||||
model,
|
||||
access_grants=grants_map.get(model.id, []),
|
||||
db=db,
|
||||
)
|
||||
for model in all_models
|
||||
]
|
||||
except Exception as e:
|
||||
log.exception(f"Error syncing models for user {user_id}: {e}")
|
||||
|
||||
@@ -93,9 +93,18 @@ class NoteTable:
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("note", note_id, db=db)
|
||||
|
||||
def _to_note_model(self, note: Note, db: Optional[Session] = None) -> NoteModel:
|
||||
def _to_note_model(
|
||||
self,
|
||||
note: Note,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> NoteModel:
|
||||
note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"})
|
||||
note_data["access_grants"] = self._get_access_grants(note_data["id"], db=db)
|
||||
note_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(note_data["id"], db=db)
|
||||
)
|
||||
return NoteModel.model_validate(note_data)
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
@@ -142,7 +151,14 @@ class NoteTable:
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
notes = query.all()
|
||||
return [self._to_note_model(note, db=db) for note in notes]
|
||||
note_ids = [note.id for note in notes]
|
||||
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
|
||||
return [
|
||||
self._to_note_model(
|
||||
note, access_grants=grants_map.get(note.id, []), db=db
|
||||
)
|
||||
for note in notes
|
||||
]
|
||||
|
||||
def search_notes(
|
||||
self,
|
||||
@@ -227,11 +243,18 @@ class NoteTable:
|
||||
|
||||
items = query.all()
|
||||
|
||||
note_ids = [note.id for note, _ in items]
|
||||
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
|
||||
|
||||
notes = []
|
||||
for note, user in items:
|
||||
notes.append(
|
||||
NoteUserResponse(
|
||||
**self._to_note_model(note, db=db).model_dump(),
|
||||
**self._to_note_model(
|
||||
note,
|
||||
access_grants=grants_map.get(note.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
@@ -266,7 +289,14 @@ class NoteTable:
|
||||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [self._to_note_model(note, db=db) for note in notes]
|
||||
note_ids = [note.id for note in notes]
|
||||
grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db)
|
||||
return [
|
||||
self._to_note_model(
|
||||
note, access_grants=grants_map.get(note.id, []), db=db
|
||||
)
|
||||
for note in notes
|
||||
]
|
||||
|
||||
def get_note_by_id(
|
||||
self, id: str, db: Optional[Session] = None
|
||||
|
||||
@@ -135,6 +135,7 @@ class OAuthSessionTable:
|
||||
db.refresh(result)
|
||||
|
||||
if result:
|
||||
db.expunge(result) # Detach so dict swap is never flushed
|
||||
result.token = token # Return decrypted token
|
||||
return OAuthSessionModel.model_validate(result)
|
||||
else:
|
||||
@@ -151,6 +152,7 @@ class OAuthSessionTable:
|
||||
with get_db_context(db) as db:
|
||||
session = db.query(OAuthSession).filter_by(id=session_id).first()
|
||||
if session:
|
||||
db.expunge(session)
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
@@ -171,6 +173,7 @@ class OAuthSessionTable:
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
db.expunge(session)
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
@@ -188,9 +191,11 @@ class OAuthSessionTable:
|
||||
session = (
|
||||
db.query(OAuthSession)
|
||||
.filter_by(provider=provider, user_id=user_id)
|
||||
.order_by(OAuthSession.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
db.expunge(session)
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
@@ -210,6 +215,7 @@ class OAuthSessionTable:
|
||||
results = []
|
||||
for session in sessions:
|
||||
try:
|
||||
db.expunge(session)
|
||||
session.token = self._decrypt_token(session.token)
|
||||
results.append(OAuthSessionModel.model_validate(session))
|
||||
except Exception as e:
|
||||
@@ -244,6 +250,7 @@ class OAuthSessionTable:
|
||||
session = db.query(OAuthSession).filter_by(id=session_id).first()
|
||||
|
||||
if session:
|
||||
db.expunge(session)
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
|
||||
@@ -97,12 +97,19 @@ class PromptsTable:
|
||||
return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db)
|
||||
|
||||
def _to_prompt_model(
|
||||
self, prompt: Prompt, db: Optional[Session] = None
|
||||
self,
|
||||
prompt: Prompt,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> PromptModel:
|
||||
prompt_data = PromptModel.model_validate(prompt).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
prompt_data["access_grants"] = self._get_access_grants(prompt_data["id"], db=db)
|
||||
prompt_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(prompt_data["id"], db=db)
|
||||
)
|
||||
return PromptModel.model_validate(prompt_data)
|
||||
|
||||
def insert_new_prompt(
|
||||
@@ -206,9 +213,13 @@ class PromptsTable:
|
||||
)
|
||||
|
||||
user_ids = list(set(prompt.user_id for prompt in all_prompts))
|
||||
prompt_ids = [prompt.id for prompt in all_prompts]
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"prompt", prompt_ids, db=db
|
||||
)
|
||||
|
||||
prompts = []
|
||||
for prompt in all_prompts:
|
||||
@@ -216,7 +227,11 @@ class PromptsTable:
|
||||
prompts.append(
|
||||
PromptUserResponse.model_validate(
|
||||
{
|
||||
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||
**self._to_prompt_model(
|
||||
prompt,
|
||||
access_grants=grants_map.get(prompt.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -259,7 +274,6 @@ class PromptsTable:
|
||||
|
||||
# Join with User table for user filtering and sorting
|
||||
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
|
||||
query = query.filter(Prompt.is_active == True)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
@@ -330,11 +344,20 @@ class PromptsTable:
|
||||
|
||||
items = query.all()
|
||||
|
||||
prompt_ids = [prompt.id for prompt, _ in items]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"prompt", prompt_ids, db=db
|
||||
)
|
||||
|
||||
prompts = []
|
||||
for prompt, user in items:
|
||||
prompts.append(
|
||||
PromptUserResponse(
|
||||
**self._to_prompt_model(prompt, db=db).model_dump(),
|
||||
**self._to_prompt_model(
|
||||
prompt,
|
||||
access_grants=grants_map.get(prompt.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
@@ -562,43 +585,24 @@ class PromptsTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def delete_prompt_by_command(
|
||||
self, command: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""Soft delete a prompt by setting is_active to False."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(command=command).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
prompt.is_active = False
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
|
||||
"""Soft delete a prompt by setting is_active to False."""
|
||||
def toggle_prompt_active(
|
||||
self, prompt_id: str, db: Optional[Session] = None
|
||||
) -> Optional[PromptModel]:
|
||||
"""Toggle the is_active flag on a prompt."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
prompt.is_active = False
|
||||
prompt.is_active = not prompt.is_active
|
||||
prompt.updated_at = int(time.time())
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
db.refresh(prompt)
|
||||
return self._to_prompt_model(prompt, db=db)
|
||||
return None
|
||||
except Exception:
|
||||
return False
|
||||
return None
|
||||
|
||||
def hard_delete_prompt_by_command(
|
||||
def delete_prompt_by_command(
|
||||
self, command: str, db: Optional[Session] = None
|
||||
) -> bool:
|
||||
"""Permanently delete a prompt and its history."""
|
||||
@@ -609,8 +613,23 @@ class PromptsTable:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
# Delete prompt
|
||||
db.query(Prompt).filter_by(command=command).delete()
|
||||
db.delete(prompt)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> bool:
|
||||
"""Permanently delete a prompt and its history."""
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
prompt = db.query(Prompt).filter_by(id=prompt_id).first()
|
||||
if prompt:
|
||||
PromptHistories.delete_history_by_prompt_id(prompt.id, db=db)
|
||||
AccessGrants.revoke_all_access("prompt", prompt.id, db=db)
|
||||
|
||||
db.delete(prompt)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -110,11 +110,20 @@ class SkillsTable:
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("skill", skill_id, db=db)
|
||||
|
||||
def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel:
|
||||
def _to_skill_model(
|
||||
self,
|
||||
skill: Skill,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> SkillModel:
|
||||
skill_data = SkillModel.model_validate(skill).model_dump(
|
||||
exclude={"access_grants"}
|
||||
)
|
||||
skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db)
|
||||
skill_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(skill_data["id"], db=db)
|
||||
)
|
||||
return SkillModel.model_validate(skill_data)
|
||||
|
||||
def insert_new_skill(
|
||||
@@ -172,9 +181,11 @@ class SkillsTable:
|
||||
all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all()
|
||||
|
||||
user_ids = list(set(skill.user_id for skill in all_skills))
|
||||
skill_ids = [skill.id for skill in all_skills]
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
grants_map = AccessGrants.get_grants_by_resources("skill", skill_ids, db=db)
|
||||
|
||||
skills = []
|
||||
for skill in all_skills:
|
||||
@@ -182,7 +193,11 @@ class SkillsTable:
|
||||
skills.append(
|
||||
SkillUserModel.model_validate(
|
||||
{
|
||||
**self._to_skill_model(skill, db=db).model_dump(),
|
||||
**self._to_skill_model(
|
||||
skill,
|
||||
access_grants=grants_map.get(skill.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -267,11 +282,20 @@ class SkillsTable:
|
||||
|
||||
items = query.all()
|
||||
|
||||
skill_ids = [skill.id for skill, _ in items]
|
||||
grants_map = AccessGrants.get_grants_by_resources(
|
||||
"skill", skill_ids, db=db
|
||||
)
|
||||
|
||||
skills = []
|
||||
for skill, user in items:
|
||||
skills.append(
|
||||
SkillUserResponse(
|
||||
**self._to_skill_model(skill, db=db).model_dump(),
|
||||
**self._to_skill_model(
|
||||
skill,
|
||||
access_grants=grants_map.get(skill.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
user=(
|
||||
UserResponse(
|
||||
**UserModel.model_validate(user).model_dump()
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, defer
|
||||
from open_webui.internal.db import Base, JSONField, get_db, get_db_context
|
||||
from open_webui.models.users import Users, UserResponse
|
||||
from open_webui.models.groups import Groups
|
||||
@@ -100,9 +100,18 @@ class ToolsTable:
|
||||
) -> list[AccessGrantModel]:
|
||||
return AccessGrants.get_grants_by_resource("tool", tool_id, db=db)
|
||||
|
||||
def _to_tool_model(self, tool: Tool, db: Optional[Session] = None) -> ToolModel:
|
||||
def _to_tool_model(
|
||||
self,
|
||||
tool: Tool,
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[Session] = None,
|
||||
) -> ToolModel:
|
||||
tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"})
|
||||
tool_data["access_grants"] = self._get_access_grants(tool_data["id"], db=db)
|
||||
tool_data["access_grants"] = (
|
||||
access_grants
|
||||
if access_grants is not None
|
||||
else self._get_access_grants(tool_data["id"], db=db)
|
||||
)
|
||||
return ToolModel.model_validate(tool_data)
|
||||
|
||||
def insert_new_tool(
|
||||
@@ -147,14 +156,21 @@ class ToolsTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_tools(self, db: Optional[Session] = None) -> list[ToolUserModel]:
|
||||
def get_tools(
|
||||
self, defer_content: bool = False, db: Optional[Session] = None
|
||||
) -> list[ToolUserModel]:
|
||||
with get_db_context(db) as db:
|
||||
all_tools = db.query(Tool).order_by(Tool.updated_at.desc()).all()
|
||||
query = db.query(Tool).order_by(Tool.updated_at.desc())
|
||||
if defer_content:
|
||||
query = query.options(defer(Tool.content), defer(Tool.specs))
|
||||
all_tools = query.all()
|
||||
|
||||
user_ids = list(set(tool.user_id for tool in all_tools))
|
||||
tool_ids = [tool.id for tool in all_tools]
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
users_dict = {user.id: user for user in users}
|
||||
grants_map = AccessGrants.get_grants_by_resources("tool", tool_ids, db=db)
|
||||
|
||||
tools = []
|
||||
for tool in all_tools:
|
||||
@@ -162,7 +178,11 @@ class ToolsTable:
|
||||
tools.append(
|
||||
ToolUserModel.model_validate(
|
||||
{
|
||||
**self._to_tool_model(tool, db=db).model_dump(),
|
||||
**self._to_tool_model(
|
||||
tool,
|
||||
access_grants=grants_map.get(tool.id, []),
|
||||
db=db,
|
||||
).model_dump(),
|
||||
"user": user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
@@ -170,9 +190,13 @@ class ToolsTable:
|
||||
return tools
|
||||
|
||||
def get_tools_by_user_id(
|
||||
self, user_id: str, permission: str = "write", db: Optional[Session] = None
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = "write",
|
||||
defer_content: bool = False,
|
||||
db: Optional[Session] = None,
|
||||
) -> list[ToolUserModel]:
|
||||
tools = self.get_tools(db=db)
|
||||
tools = self.get_tools(defer_content=defer_content, db=db)
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id, db=db)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,14 @@ def get_content_from_url(request, url: str) -> str:
|
||||
return content, docs
|
||||
|
||||
|
||||
CHUNK_HASH_KEY = "_chunk_hash"
|
||||
|
||||
|
||||
def _content_hash(text: str) -> str:
|
||||
"""SHA-256 hash of text, used as a stable chunk identifier for RRF dedup."""
|
||||
return hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
|
||||
class VectorSearchRetriever(BaseRetriever):
|
||||
collection_name: Any
|
||||
embedding_function: Any
|
||||
@@ -126,9 +134,11 @@ class VectorSearchRetriever(BaseRetriever):
|
||||
|
||||
results = []
|
||||
for idx in range(len(ids)):
|
||||
metadata = metadatas[idx]
|
||||
metadata[CHUNK_HASH_KEY] = _content_hash(documents[idx])
|
||||
results.append(
|
||||
Document(
|
||||
metadata=metadatas[idx],
|
||||
metadata=metadata,
|
||||
page_content=documents[idx],
|
||||
)
|
||||
)
|
||||
@@ -240,15 +250,21 @@ async def query_doc_with_hybrid_search(
|
||||
|
||||
log.debug(f"query_doc_with_hybrid_search:doc {collection_name}")
|
||||
|
||||
original_texts = collection_result.documents[0]
|
||||
bm25_metadatas = [
|
||||
{**meta, CHUNK_HASH_KEY: _content_hash(original_texts[idx])}
|
||||
for idx, meta in enumerate(collection_result.metadatas[0])
|
||||
]
|
||||
|
||||
bm25_texts = (
|
||||
get_enriched_texts(collection_result)
|
||||
if enable_enriched_texts
|
||||
else collection_result.documents[0]
|
||||
else original_texts
|
||||
)
|
||||
|
||||
bm25_retriever = BM25Retriever.from_texts(
|
||||
texts=bm25_texts,
|
||||
metadatas=collection_result.metadatas[0],
|
||||
metadatas=bm25_metadatas,
|
||||
)
|
||||
bm25_retriever.k = k
|
||||
|
||||
@@ -258,18 +274,24 @@ async def query_doc_with_hybrid_search(
|
||||
top_k=k,
|
||||
)
|
||||
|
||||
# Use CHUNK_HASH_KEY for dedup so enriched BM25 texts don't defeat RRF
|
||||
if hybrid_bm25_weight <= 0:
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[vector_search_retriever], weights=[1.0]
|
||||
retrievers=[vector_search_retriever],
|
||||
weights=[1.0],
|
||||
id_key=CHUNK_HASH_KEY,
|
||||
)
|
||||
elif hybrid_bm25_weight >= 1:
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[bm25_retriever], weights=[1.0]
|
||||
retrievers=[bm25_retriever],
|
||||
weights=[1.0],
|
||||
id_key=CHUNK_HASH_KEY,
|
||||
)
|
||||
else:
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[bm25_retriever, vector_search_retriever],
|
||||
weights=[hybrid_bm25_weight, 1.0 - hybrid_bm25_weight],
|
||||
id_key=CHUNK_HASH_KEY,
|
||||
)
|
||||
|
||||
compressor = RerankCompressor(
|
||||
@@ -292,7 +314,7 @@ async def query_doc_with_hybrid_search(
|
||||
# retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker
|
||||
if k < k_reranker:
|
||||
sorted_items = sorted(
|
||||
zip(distances, metadatas, documents), key=lambda x: x[0], reverse=True
|
||||
zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True
|
||||
)
|
||||
sorted_items = sorted_items[:k]
|
||||
|
||||
@@ -803,6 +825,7 @@ def get_embedding_function(
|
||||
embedding_batch_size,
|
||||
azure_api_version=None,
|
||||
enable_async=True,
|
||||
concurrent_requests=0,
|
||||
) -> Awaitable:
|
||||
if embedding_engine == "":
|
||||
# Sentence transformers: CPU-bound sync operation
|
||||
@@ -844,11 +867,25 @@ def get_embedding_function(
|
||||
log.debug(
|
||||
f"generate_multiple_async: Processing {len(batches)} batches in parallel"
|
||||
)
|
||||
# Execute all batches in parallel
|
||||
tasks = [
|
||||
embedding_function(batch, prefix=prefix, user=user)
|
||||
for batch in batches
|
||||
]
|
||||
# Use semaphore to limit concurrent embedding API requests
|
||||
# 0 = unlimited (no semaphore)
|
||||
if concurrent_requests:
|
||||
semaphore = asyncio.Semaphore(concurrent_requests)
|
||||
|
||||
async def generate_batch_with_semaphore(batch):
|
||||
async with semaphore:
|
||||
return await embedding_function(
|
||||
batch, prefix=prefix, user=user
|
||||
)
|
||||
|
||||
tasks = [
|
||||
generate_batch_with_semaphore(batch) for batch in batches
|
||||
]
|
||||
else:
|
||||
tasks = [
|
||||
embedding_function(batch, prefix=prefix, user=user)
|
||||
for batch in batches
|
||||
]
|
||||
batch_results = await asyncio.gather(*tasks)
|
||||
else:
|
||||
log.debug(
|
||||
@@ -1269,6 +1306,8 @@ def get_model_path(model: str, update_model: bool = False):
|
||||
return model_repo_path
|
||||
except Exception as e:
|
||||
log.exception(f"Cannot determine model snapshot path: {e}")
|
||||
if OFFLINE_MODE:
|
||||
raise
|
||||
return model
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def search_youcom(
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[List[str]] = None,
|
||||
language: str = "EN",
|
||||
) -> List[SearchResult]:
|
||||
"""Search using You.com's YDC Index API and return the results as a list of SearchResult objects.
|
||||
|
||||
Args:
|
||||
api_key (str): A You.com API key
|
||||
query (str): The query to search for
|
||||
count (int): Maximum number of results to return
|
||||
filter_list (list[str], optional): Domain filter list
|
||||
language (str): Language code for search results (default: "EN")
|
||||
"""
|
||||
url = "https://ydc-index.io/v1/search"
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-API-KEY": api_key,
|
||||
}
|
||||
params = {
|
||||
"query": query,
|
||||
"count": count,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
json_response = response.json()
|
||||
results = json_response.get("results", {}).get("web", [])
|
||||
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result["url"],
|
||||
title=result.get("title"),
|
||||
snippet=_build_snippet(result),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
|
||||
|
||||
def _build_snippet(result: dict) -> str:
|
||||
"""Combine the description and snippets list into a single string.
|
||||
|
||||
The You.com API returns a short ``description`` plus a ``snippets``
|
||||
list with richer passages. Merging them gives downstream retrieval
|
||||
(embedding, BM25, bypass-loader context) the most content to work with.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
description = result.get("description")
|
||||
if description:
|
||||
parts.append(description)
|
||||
|
||||
snippets = result.get("snippets")
|
||||
if snippets and isinstance(snippets, list):
|
||||
parts.extend(snippets)
|
||||
|
||||
return "\n\n".join(parts)
|
||||
@@ -278,7 +278,7 @@ class ModelChatsResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
|
||||
@router.get("/models/{model_id:path}/chats", response_model=ModelChatsResponse)
|
||||
async def get_model_chats(
|
||||
model_id: str,
|
||||
start_date: Optional[int] = Query(None),
|
||||
@@ -367,7 +367,7 @@ class ModelOverviewResponse(BaseModel):
|
||||
tags: list[TagEntry]
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
|
||||
@router.get("/models/{model_id:path}/overview", response_model=ModelOverviewResponse)
|
||||
async def get_model_overview(
|
||||
model_id: str,
|
||||
days: int = Query(30, description="Number of days of history (0 for all)"),
|
||||
|
||||
@@ -1194,7 +1194,9 @@ def transcription(
|
||||
)
|
||||
|
||||
try:
|
||||
ext = file.filename.split(".")[-1]
|
||||
safe_name = os.path.basename(file.filename) if file.filename else ""
|
||||
ext = safe_name.rsplit(".", 1)[-1] if "." in safe_name else ""
|
||||
|
||||
id = uuid.uuid4()
|
||||
|
||||
filename = f"{id}.{ext}"
|
||||
@@ -1204,6 +1206,10 @@ def transcription(
|
||||
os.makedirs(file_dir, exist_ok=True)
|
||||
file_path = f"{file_dir}/{filename}"
|
||||
|
||||
# Defense-in-depth: ensure resolved path stays within intended directory
|
||||
if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)):
|
||||
raise ValueError("Invalid file path detected")
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
@@ -1225,7 +1231,7 @@ def transcription(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
detail="Transcription failed.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -1233,7 +1239,7 @@ def transcription(
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(e),
|
||||
detail="Transcription failed.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -700,8 +700,9 @@ async def signup_handler(
|
||||
Returns the newly created UserModel.
|
||||
Raises HTTPException on failure.
|
||||
"""
|
||||
has_users = Users.has_users(db=db)
|
||||
role = "admin" if not has_users else request.app.state.config.DEFAULT_USER_ROLE
|
||||
# Insert with default role first to avoid TOCTOU race on first signup.
|
||||
# If has_users() is checked before insert, concurrent requests during
|
||||
# first-user registration can all see an empty table and each get admin.
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
user = Auths.insert_new_auth(
|
||||
@@ -709,12 +710,19 @@ async def signup_handler(
|
||||
password=hashed,
|
||||
name=name,
|
||||
profile_image_url=profile_image_url,
|
||||
role=role,
|
||||
role=request.app.state.config.DEFAULT_USER_ROLE,
|
||||
db=db,
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR)
|
||||
|
||||
# Atomically check if this is the only user *after* the insert.
|
||||
# Only the single user present at this point should become admin.
|
||||
if Users.get_num_users(db=db) == 1:
|
||||
Users.update_user_role_by_id(user.id, "admin", db=db)
|
||||
user = Users.get_user_by_id(user.id, db=db)
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
@@ -727,10 +735,6 @@ async def signup_handler(
|
||||
},
|
||||
)
|
||||
|
||||
if not has_users:
|
||||
# Disable signup after the first user is created
|
||||
request.app.state.config.ENABLE_SIGNUP = False
|
||||
|
||||
apply_default_group_assignment(
|
||||
request.app.state.config.DEFAULT_GROUP_ID,
|
||||
user.id,
|
||||
@@ -1153,8 +1157,6 @@ async def update_ldap_server(
|
||||
"host",
|
||||
"attribute_for_mail",
|
||||
"attribute_for_username",
|
||||
"app_dn",
|
||||
"app_dn_password",
|
||||
"search_base",
|
||||
]
|
||||
for key in required_fields:
|
||||
@@ -1169,8 +1171,8 @@ async def update_ldap_server(
|
||||
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
|
||||
form_data.attribute_for_username
|
||||
)
|
||||
request.app.state.config.LDAP_APP_DN = form_data.app_dn
|
||||
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password
|
||||
request.app.state.config.LDAP_APP_DN = form_data.app_dn or ""
|
||||
request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or ""
|
||||
request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base
|
||||
request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters
|
||||
request.app.state.config.LDAP_USE_TLS = form_data.use_tls
|
||||
|
||||
@@ -1578,7 +1578,9 @@ async def update_message_by_id(
|
||||
if (
|
||||
user.role != "admin"
|
||||
and message.user_id != user.id
|
||||
and not channel_has_access(user.id, channel, permission="read", db=db)
|
||||
and not channel_has_access(
|
||||
user.id, channel, permission="write", strict=False, db=db
|
||||
)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
|
||||
@@ -723,10 +723,7 @@ async def get_chat_list_by_folder_id(
|
||||
async def get_user_pinned_chats(
|
||||
user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
return [
|
||||
ChatTitleIdResponse(**chat.model_dump())
|
||||
for chat in Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
]
|
||||
return Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
@@ -821,18 +818,13 @@ async def get_archived_session_user_chat_list(
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
chat_list = [
|
||||
ChatTitleIdResponse(**chat.model_dump())
|
||||
for chat in Chats.get_archived_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
return chat_list
|
||||
return Chats.get_archived_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -887,18 +879,13 @@ async def get_shared_session_user_chat_list(
|
||||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
chat_list = [
|
||||
SharedChatResponse(**chat.model_dump())
|
||||
for chat in Chats.get_shared_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
]
|
||||
|
||||
return chat_list
|
||||
return Chats.get_shared_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -1147,7 +1134,7 @@ async def delete_chat_by_id(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chat = Chats.get_chat_by_id(id, db=db)
|
||||
chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
|
||||
@@ -15,6 +15,7 @@ from open_webui.utils.tools import (
|
||||
get_tool_server_data,
|
||||
get_tool_server_url,
|
||||
set_tool_servers,
|
||||
set_terminal_servers,
|
||||
)
|
||||
from open_webui.utils.mcp.client import MCPClient
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
@@ -214,6 +215,51 @@ async def set_tool_servers_config(
|
||||
}
|
||||
|
||||
|
||||
class TerminalServerConnection(BaseModel):
|
||||
id: Optional[str] = ""
|
||||
name: Optional[str] = ""
|
||||
|
||||
enabled: Optional[bool] = True
|
||||
|
||||
url: str
|
||||
path: Optional[str] = "/openapi.json"
|
||||
|
||||
key: Optional[str] = ""
|
||||
auth_type: Optional[str] = "bearer"
|
||||
|
||||
config: Optional[dict] = None
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
|
||||
class TerminalServersConfigForm(BaseModel):
|
||||
TERMINAL_SERVER_CONNECTIONS: list[TerminalServerConnection]
|
||||
|
||||
|
||||
@router.get("/terminal_servers")
|
||||
async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
"TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/terminal_servers")
|
||||
async def set_terminal_servers_config(
|
||||
request: Request,
|
||||
form_data: TerminalServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [
|
||||
connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS
|
||||
]
|
||||
|
||||
await set_terminal_servers(request)
|
||||
|
||||
return {
|
||||
"TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tool_servers/verify")
|
||||
async def verify_tool_servers_config(
|
||||
request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)
|
||||
@@ -467,6 +513,8 @@ class ModelsConfigForm(BaseModel):
|
||||
DEFAULT_MODELS: Optional[str]
|
||||
DEFAULT_PINNED_MODELS: Optional[str]
|
||||
MODEL_ORDER_LIST: Optional[list[str]]
|
||||
DEFAULT_MODEL_METADATA: Optional[dict] = None
|
||||
DEFAULT_MODEL_PARAMS: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelsConfigForm)
|
||||
@@ -475,6 +523,8 @@ async def get_models_config(request: Request, user=Depends(get_admin_user)):
|
||||
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
|
||||
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
|
||||
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
|
||||
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
|
||||
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
|
||||
}
|
||||
|
||||
|
||||
@@ -485,10 +535,14 @@ async def set_models_config(
|
||||
request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS
|
||||
request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS
|
||||
request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST
|
||||
request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA
|
||||
request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS
|
||||
return {
|
||||
"DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS,
|
||||
"DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS,
|
||||
"MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST,
|
||||
"DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA,
|
||||
"DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from open_webui.routers.audio import transcribe
|
||||
from open_webui.storage.provider import Storage
|
||||
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.misc import strict_match_mime_type
|
||||
from pydantic import BaseModel
|
||||
@@ -56,70 +57,34 @@ log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
############################
|
||||
# Check if the current user has access to a file through any knowledge bases the user may be in.
|
||||
############################
|
||||
|
||||
|
||||
# TODO: Optimize this function to use the knowledge_file table for faster lookups.
|
||||
def has_access_to_file(
|
||||
file_id: Optional[str],
|
||||
access_type: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: Optional[Session] = None,
|
||||
) -> bool:
|
||||
file = Files.get_file_by_id(file_id, db=db)
|
||||
log.debug(f"Checking if user has {access_type} access to file")
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Check if the file is associated with any knowledge bases the user has access to
|
||||
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user.id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
return True
|
||||
|
||||
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
|
||||
if knowledge_base_id:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
|
||||
user.id, access_type, db=db
|
||||
)
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.id == knowledge_base_id:
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
|
||||
if access_type == "read" and channels:
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any chats the user has access to
|
||||
# TODO: Granular access control for chats
|
||||
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
|
||||
if chats:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
|
||||
############################
|
||||
# Upload File
|
||||
############################
|
||||
|
||||
|
||||
def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool:
|
||||
"""Check if a file is likely a text file by reading a chunk and validating UTF-8.
|
||||
|
||||
This catches files whose extensions are mis-mapped by mimetypes/browsers
|
||||
(e.g. TypeScript .ts → video/mp2t) without maintaining an extension whitelist.
|
||||
"""
|
||||
try:
|
||||
resolved = Storage.get_file(file_path)
|
||||
with open(resolved, "rb") as f:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
return False
|
||||
# Null bytes are a strong indicator of binary content
|
||||
if b"\x00" in chunk:
|
||||
return False
|
||||
chunk.decode("utf-8")
|
||||
return True
|
||||
except (UnicodeDecodeError, Exception):
|
||||
return False
|
||||
|
||||
|
||||
def process_uploaded_file(
|
||||
request,
|
||||
file,
|
||||
@@ -131,14 +96,19 @@ def process_uploaded_file(
|
||||
):
|
||||
def _process_handler(db_session):
|
||||
try:
|
||||
if file.content_type:
|
||||
content_type = file.content_type
|
||||
|
||||
# Detect mis-labeled text files (e.g. .ts → video/mp2t)
|
||||
if content_type and content_type.startswith(("image/", "video/")):
|
||||
if _is_text_file(file_path):
|
||||
content_type = "text/plain"
|
||||
|
||||
if content_type:
|
||||
stt_supported_content_types = getattr(
|
||||
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
|
||||
)
|
||||
|
||||
if strict_match_mime_type(
|
||||
stt_supported_content_types, file.content_type
|
||||
):
|
||||
if strict_match_mime_type(stt_supported_content_types, content_type):
|
||||
file_path_processed = Storage.get_file(file_path)
|
||||
result = transcribe(
|
||||
request, file_path_processed, file_metadata, user
|
||||
@@ -152,7 +122,7 @@ def process_uploaded_file(
|
||||
user=user,
|
||||
db=db_session,
|
||||
)
|
||||
elif (not file.content_type.startswith(("image/", "video/"))) or (
|
||||
elif (not content_type.startswith(("image/", "video/"))) or (
|
||||
request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external"
|
||||
):
|
||||
process_file(
|
||||
@@ -163,7 +133,7 @@ def process_uploaded_file(
|
||||
)
|
||||
else:
|
||||
raise Exception(
|
||||
f"File type {file.content_type} is not supported for processing"
|
||||
f"File type {content_type} is not supported for processing"
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
@@ -362,7 +332,7 @@ async def list_files(
|
||||
content: bool = Query(True),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
if user.role == "admin":
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
files = Files.get_files(db=db)
|
||||
else:
|
||||
files = Files.get_files_by_user_id(user.id, db=db)
|
||||
@@ -398,8 +368,10 @@ async def search_files(
|
||||
Search for files by filename with support for wildcard patterns.
|
||||
Uses SQL-based filtering with pagination for better performance.
|
||||
"""
|
||||
# Determine user_id: null for admin (search all), user.id for regular users
|
||||
user_id = None if user.role == "admin" else user.id
|
||||
# Determine user_id: null for admin with bypass (search all), user.id otherwise
|
||||
user_id = (
|
||||
None if (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) else user.id
|
||||
)
|
||||
|
||||
# Use optimized database query with pagination
|
||||
files = Files.search_files(
|
||||
@@ -689,6 +661,8 @@ async def get_file_content_by_id(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error("Error getting file content")
|
||||
@@ -740,6 +714,8 @@ async def get_html_file_content_by_id(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
log.error("Error getting file content")
|
||||
|
||||
@@ -119,7 +119,7 @@ def create_folder(
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
|
||||
None, user.id, form_data.name, db=db
|
||||
form_data.parent_id, user.id, form_data.name, db=db
|
||||
)
|
||||
|
||||
if folder:
|
||||
@@ -129,7 +129,9 @@ def create_folder(
|
||||
)
|
||||
|
||||
try:
|
||||
folder = Folders.insert_new_folder(user.id, form_data, db=db)
|
||||
folder = Folders.insert_new_folder(
|
||||
user.id, form_data, form_data.parent_id, db=db
|
||||
)
|
||||
return folder
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -317,7 +319,9 @@ async def delete_folder_by_id(
|
||||
folder = folders.pop()
|
||||
if folder:
|
||||
try:
|
||||
folder_ids = Folders.delete_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
folder_ids = Folders.delete_folder_by_id_and_user_id(
|
||||
folder.id, user.id, db=db
|
||||
)
|
||||
|
||||
for folder_id in folder_ids:
|
||||
if delete_contents:
|
||||
|
||||
@@ -641,7 +641,10 @@ async def image_generations(
|
||||
|
||||
for image in res["data"]:
|
||||
if image_url := image.get("url", None):
|
||||
image_data, content_type = get_image_data(image_url, headers)
|
||||
image_data, content_type = get_image_data(
|
||||
image_url,
|
||||
{k: v for k, v in headers.items() if k != "Content-Type"},
|
||||
)
|
||||
else:
|
||||
image_data, content_type = get_image_data(image["b64_json"])
|
||||
|
||||
@@ -849,6 +852,7 @@ class EditImageForm(BaseModel):
|
||||
size: Optional[str] = None
|
||||
n: Optional[int] = None
|
||||
negative_prompt: Optional[str] = None
|
||||
background: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/edit")
|
||||
@@ -953,6 +957,9 @@ async def image_edits(
|
||||
"prompt": form_data.prompt,
|
||||
**({"n": form_data.n} if form_data.n else {}),
|
||||
**({"size": size} if size else {}),
|
||||
**(
|
||||
{"background": form_data.background} if form_data.background else {}
|
||||
),
|
||||
**(
|
||||
{}
|
||||
if re.match(
|
||||
@@ -989,7 +996,10 @@ async def image_edits(
|
||||
images = []
|
||||
for image in res["data"]:
|
||||
if image_url := image.get("url", None):
|
||||
image_data, content_type = get_image_data(image_url, headers)
|
||||
image_data, content_type = get_image_data(
|
||||
image_url,
|
||||
{k: v for k, v in headers.items() if k != "Content-Type"},
|
||||
)
|
||||
else:
|
||||
image_data, content_type = get_image_data(image["b64_json"])
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ from open_webui.storage.provider import Storage
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_verified_user, get_admin_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
@@ -251,7 +251,7 @@ async def create_new_knowledge(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
# NOTE: We intentionally do NOT use Depends(get_session) here.
|
||||
# Database operations (has_permission, insert_new_knowledge) manage their own sessions.
|
||||
# Database operations (has_permission, filter_allowed_access_grants, insert_new_knowledge) manage their own sessions.
|
||||
# This prevents holding a connection during embed_knowledge_base_metadata()
|
||||
# which makes external embedding API calls (1-5+ seconds).
|
||||
if user.role != "admin" and not has_permission(
|
||||
@@ -262,17 +262,13 @@ async def create_new_knowledge(
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = []
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_knowledge",
|
||||
)
|
||||
|
||||
knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
|
||||
|
||||
@@ -482,17 +478,13 @@ async def update_knowledge_by_id(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = []
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_knowledge",
|
||||
)
|
||||
|
||||
knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
|
||||
if knowledge:
|
||||
@@ -554,24 +546,13 @@ async def update_knowledge_access_by_id(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_knowledge",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_knowledge",
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db)
|
||||
|
||||
@@ -764,6 +745,13 @@ def update_file_from_knowledge_by_id(
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Validate the file actually belongs to this knowledge base
|
||||
if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Remove content from the vector database
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
collection_name=knowledge.id, filter={"file_id": form_data.file_id}
|
||||
@@ -838,6 +826,13 @@ def remove_file_from_knowledge_by_id(
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Validate the file actually belongs to this knowledge base
|
||||
if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
Knowledges.remove_file_from_knowledge_by_id(
|
||||
knowledge_id=id, file_id=form_data.file_id, db=db
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from open_webui.models.models import (
|
||||
ModelAccessResponse,
|
||||
Models,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
from pydantic import BaseModel
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
@@ -33,7 +33,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -512,6 +512,7 @@ async def update_model_by_id(
|
||||
|
||||
class ModelAccessGrantsForm(BaseModel):
|
||||
id: str
|
||||
name: Optional[str] = None
|
||||
access_grants: list[dict]
|
||||
|
||||
|
||||
@@ -535,7 +536,7 @@ async def update_model_access_by_id(
|
||||
model = Models.insert_new_model(
|
||||
ModelForm(
|
||||
id=form_data.id,
|
||||
name=form_data.id,
|
||||
name=form_data.name or form_data.id,
|
||||
meta=ModelMeta(),
|
||||
params=ModelParams(),
|
||||
),
|
||||
@@ -564,24 +565,13 @@ async def update_model_access_by_id(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_models",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_models",
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants(
|
||||
"model", form_data.id, form_data.access_grants, db=db
|
||||
|
||||
@@ -27,8 +27,8 @@ from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -36,6 +36,14 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optional[dict]:
|
||||
if not data:
|
||||
return data
|
||||
md = (data.get("content") or {}).get("md") or ""
|
||||
return {"content": {"md": md[:max_length]}}
|
||||
|
||||
|
||||
############################
|
||||
# GetNotes
|
||||
############################
|
||||
@@ -82,6 +90,7 @@ async def get_notes(
|
||||
NoteUserResponse(
|
||||
**{
|
||||
**note.model_dump(),
|
||||
"data": _truncate_note_data(note.data),
|
||||
"user": UserResponse(**users[note.user_id].model_dump()),
|
||||
}
|
||||
)
|
||||
@@ -135,7 +144,10 @@ async def search_notes(
|
||||
|
||||
filter["user_id"] = user.id
|
||||
|
||||
return Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
|
||||
result = Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
|
||||
for note in result.items:
|
||||
note.data = _truncate_note_data(note.data)
|
||||
return result
|
||||
|
||||
|
||||
############################
|
||||
@@ -271,18 +283,14 @@ async def update_note_by_id(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
# Check if user can share publicly
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = []
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_notes",
|
||||
db=db,
|
||||
)
|
||||
|
||||
try:
|
||||
note = Notes.update_note_by_id(id, form_data, db=db)
|
||||
@@ -345,24 +353,13 @@ async def update_note_access_by_id(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_notes",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_notes",
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db)
|
||||
|
||||
|
||||
@@ -1176,7 +1176,7 @@ async def embeddings(
|
||||
|
||||
class GenerateCompletionForm(BaseModel):
|
||||
model: str
|
||||
prompt: str
|
||||
prompt: Optional[str] = None
|
||||
suffix: Optional[str] = None
|
||||
images: Optional[list[str]] = None
|
||||
format: Optional[Union[dict, str]] = None
|
||||
|
||||
@@ -57,6 +57,7 @@ from open_webui.utils.misc import (
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,6 +92,12 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
||||
return None
|
||||
|
||||
|
||||
async def get_models_request(url, key=None, user: UserModel = None):
|
||||
if is_anthropic_url(url):
|
||||
return await get_anthropic_models(url, key, user=user)
|
||||
return await send_get_request(f"{url}/models", key, user=user)
|
||||
|
||||
|
||||
def openai_reasoning_model_handler(payload):
|
||||
"""
|
||||
Handle reasoning model specific parameters
|
||||
@@ -365,13 +372,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
request_tasks = []
|
||||
for idx, url in enumerate(api_base_urls):
|
||||
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
|
||||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models",
|
||||
api_keys[idx],
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
|
||||
else:
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
@@ -384,11 +385,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
if enable:
|
||||
if len(model_ids) == 0:
|
||||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models",
|
||||
api_keys[idx],
|
||||
user=user,
|
||||
)
|
||||
get_models_request(url, api_keys[idx], user=user)
|
||||
)
|
||||
else:
|
||||
model_list = {
|
||||
@@ -594,6 +591,10 @@ async def get_models(
|
||||
"data": api_config.get("model_ids", []) or [],
|
||||
"object": "list",
|
||||
}
|
||||
elif is_anthropic_url(url):
|
||||
models = await get_anthropic_models(url, key, user=user)
|
||||
if models is None:
|
||||
raise Exception("Failed to connect to Anthropic API")
|
||||
else:
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
@@ -602,7 +603,6 @@ async def get_models(
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
# Extract response error details if available
|
||||
error_detail = f"HTTP Error: {r.status}"
|
||||
try:
|
||||
res = await r.json()
|
||||
@@ -614,9 +614,7 @@ async def get_models(
|
||||
|
||||
response_data = await r.json()
|
||||
|
||||
# Check if we're calling OpenAI API based on the URL
|
||||
if "api.openai.com" in url:
|
||||
# Filter models according to the specified conditions
|
||||
response_data["data"] = [
|
||||
model
|
||||
for model in response_data.get("data", [])
|
||||
@@ -707,6 +705,15 @@ async def verify_connection(
|
||||
)
|
||||
|
||||
return response_data
|
||||
elif is_anthropic_url(url):
|
||||
result = await get_anthropic_models(url, key)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to connect to Anthropic API"
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
return result
|
||||
else:
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
@@ -1181,7 +1188,10 @@ async def embeddings(request: Request, form_data: dict, user):
|
||||
request, url, key, api_config, user=user
|
||||
)
|
||||
try:
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
session = aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
r = await session.request(
|
||||
method="POST",
|
||||
url=f"{url}/embeddings",
|
||||
@@ -1408,7 +1418,10 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
|
||||
else:
|
||||
request_url = f"{url}/{path}"
|
||||
|
||||
session = aiohttp.ClientSession(trust_env=True)
|
||||
session = aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
r = await session.request(
|
||||
method=request.method,
|
||||
url=request_url,
|
||||
|
||||
@@ -9,7 +9,7 @@ from open_webui.models.prompts import (
|
||||
PromptModel,
|
||||
Prompts,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.prompt_history import (
|
||||
PromptHistories,
|
||||
@@ -18,7 +18,7 @@ from open_webui.models.prompt_history import (
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.internal.db import get_session
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -473,30 +473,61 @@ async def update_prompt_access_by_id(
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_prompts",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_prompts",
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db)
|
||||
|
||||
return Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
|
||||
############################
|
||||
# TogglePromptActiveById
|
||||
############################
|
||||
|
||||
|
||||
@router.post("/id/{prompt_id}/toggle", response_model=Optional[PromptModel])
|
||||
async def toggle_prompt_active(
|
||||
prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
prompt = Prompts.get_prompt_by_id(prompt_id, db=db)
|
||||
|
||||
if not prompt:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="prompt",
|
||||
resource_id=prompt.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
result = Prompts.toggle_prompt_active(prompt.id, db=db)
|
||||
if result:
|
||||
return result
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# DeletePromptById
|
||||
############################
|
||||
|
||||
@@ -37,6 +37,7 @@ from langchain_text_splitters import (
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from open_webui.models.files import FileModel, FileUpdateForm, Files
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.storage.provider import Storage
|
||||
from open_webui.internal.db import get_session, get_db
|
||||
@@ -77,6 +78,7 @@ from open_webui.retrieval.web.sougou import search_sougou
|
||||
from open_webui.retrieval.web.firecrawl import search_firecrawl
|
||||
from open_webui.retrieval.web.external import search_external
|
||||
from open_webui.retrieval.web.yandex import search_yandex
|
||||
from open_webui.retrieval.web.ydc import search_youcom
|
||||
|
||||
from open_webui.retrieval.utils import (
|
||||
get_content_from_url,
|
||||
@@ -270,6 +272,7 @@ async def get_status(request: Request):
|
||||
"RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL,
|
||||
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +284,7 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
|
||||
"RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
|
||||
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
"openai_config": {
|
||||
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
|
||||
"key": request.app.state.config.RAG_OPENAI_API_KEY,
|
||||
@@ -321,6 +325,7 @@ class EmbeddingModelUpdateForm(BaseModel):
|
||||
RAG_EMBEDDING_MODEL: str
|
||||
RAG_EMBEDDING_BATCH_SIZE: Optional[int] = 1
|
||||
ENABLE_ASYNC_EMBEDDING: Optional[bool] = True
|
||||
RAG_EMBEDDING_CONCURRENT_REQUESTS: Optional[int] = 0
|
||||
|
||||
|
||||
def unload_embedding_model(request: Request):
|
||||
@@ -355,6 +360,9 @@ async def update_embedding_config(
|
||||
request.app.state.config.ENABLE_ASYNC_EMBEDDING = (
|
||||
form_data.ENABLE_ASYNC_EMBEDDING
|
||||
)
|
||||
request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = (
|
||||
form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS
|
||||
)
|
||||
|
||||
if request.app.state.config.RAG_EMBEDDING_ENGINE in [
|
||||
"ollama",
|
||||
@@ -422,6 +430,7 @@ async def update_embedding_config(
|
||||
else None
|
||||
),
|
||||
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -430,6 +439,7 @@ async def update_embedding_config(
|
||||
"RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL,
|
||||
"RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
"ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
"RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
"openai_config": {
|
||||
"url": request.app.state.config.RAG_OPENAI_API_BASE_URL,
|
||||
"key": request.app.state.config.RAG_OPENAI_API_KEY,
|
||||
@@ -583,6 +593,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
"YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -649,6 +660,7 @@ class WebConfig(BaseModel):
|
||||
YANDEX_WEB_SEARCH_URL: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_API_KEY: Optional[str] = None
|
||||
YANDEX_WEB_SEARCH_CONFIG: Optional[str] = None
|
||||
YOUCOM_API_KEY: Optional[str] = None
|
||||
|
||||
|
||||
class ConfigForm(BaseModel):
|
||||
@@ -717,10 +729,10 @@ class ConfigForm(BaseModel):
|
||||
CHUNK_OVERLAP: Optional[int] = None
|
||||
|
||||
# File upload settings
|
||||
FILE_MAX_SIZE: Optional[int] = None
|
||||
FILE_MAX_COUNT: Optional[int] = None
|
||||
FILE_IMAGE_COMPRESSION_WIDTH: Optional[int] = None
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT: Optional[int] = None
|
||||
FILE_MAX_SIZE: Optional[Union[int, str]] = None
|
||||
FILE_MAX_COUNT: Optional[Union[int, str]] = None
|
||||
FILE_IMAGE_COMPRESSION_WIDTH: Optional[Union[int, str]] = None
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT: Optional[Union[int, str]] = None
|
||||
ALLOWED_FILE_EXTENSIONS: Optional[List[str]] = None
|
||||
|
||||
# Integration settings
|
||||
@@ -1043,26 +1055,29 @@ async def update_rag_config(
|
||||
)
|
||||
|
||||
# File upload settings
|
||||
request.app.state.config.FILE_MAX_SIZE = (
|
||||
form_data.FILE_MAX_SIZE
|
||||
if form_data.FILE_MAX_SIZE is not None
|
||||
else request.app.state.config.FILE_MAX_SIZE
|
||||
)
|
||||
request.app.state.config.FILE_MAX_COUNT = (
|
||||
form_data.FILE_MAX_COUNT
|
||||
if form_data.FILE_MAX_COUNT is not None
|
||||
else request.app.state.config.FILE_MAX_COUNT
|
||||
)
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None
|
||||
else request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
)
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
|
||||
form_data.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None
|
||||
else request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
)
|
||||
# Empty string means "clear to None" (unlimited/no compression),
|
||||
# None means "don't change", int means "set to this value"
|
||||
if form_data.FILE_MAX_SIZE is not None:
|
||||
request.app.state.config.FILE_MAX_SIZE = (
|
||||
None if form_data.FILE_MAX_SIZE == "" else form_data.FILE_MAX_SIZE
|
||||
)
|
||||
if form_data.FILE_MAX_COUNT is not None:
|
||||
request.app.state.config.FILE_MAX_COUNT = (
|
||||
None if form_data.FILE_MAX_COUNT == "" else form_data.FILE_MAX_COUNT
|
||||
)
|
||||
if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None:
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = (
|
||||
None
|
||||
if form_data.FILE_IMAGE_COMPRESSION_WIDTH == ""
|
||||
else form_data.FILE_IMAGE_COMPRESSION_WIDTH
|
||||
)
|
||||
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None:
|
||||
request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = (
|
||||
None
|
||||
if form_data.FILE_IMAGE_COMPRESSION_HEIGHT == ""
|
||||
else form_data.FILE_IMAGE_COMPRESSION_HEIGHT
|
||||
)
|
||||
|
||||
request.app.state.config.ALLOWED_FILE_EXTENSIONS = (
|
||||
form_data.ALLOWED_FILE_EXTENSIONS
|
||||
if form_data.ALLOWED_FILE_EXTENSIONS is not None
|
||||
@@ -1205,6 +1220,7 @@ async def update_rag_config(
|
||||
request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = (
|
||||
form_data.web.YANDEX_WEB_SEARCH_CONFIG
|
||||
)
|
||||
request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY
|
||||
|
||||
return {
|
||||
"status": True,
|
||||
@@ -1332,6 +1348,7 @@ async def update_rag_config(
|
||||
"YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL,
|
||||
"YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY,
|
||||
"YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG,
|
||||
"YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1589,6 +1606,7 @@ def save_docs_to_vector_db(
|
||||
else None
|
||||
),
|
||||
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
concurrent_requests=request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
)
|
||||
|
||||
# Run async embedding in sync context using the main event loop
|
||||
@@ -1746,6 +1764,7 @@ def process_file(
|
||||
DOCLING_API_KEY=request.app.state.config.DOCLING_API_KEY,
|
||||
DOCLING_PARAMS=request.app.state.config.DOCLING_PARAMS,
|
||||
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
|
||||
PDF_LOADER_MODE=request.app.state.config.PDF_LOADER_MODE,
|
||||
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
|
||||
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
|
||||
DOCUMENT_INTELLIGENCE_MODEL=request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL,
|
||||
@@ -1933,6 +1952,9 @@ async def process_web(
|
||||
request: Request,
|
||||
form_data: ProcessUrlForm,
|
||||
process: bool = Query(True, description="Whether to process and save the content"),
|
||||
overwrite: bool = Query(
|
||||
True, description="Whether to overwrite existing collection"
|
||||
),
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
try:
|
||||
@@ -1952,7 +1974,8 @@ async def process_web(
|
||||
request,
|
||||
docs,
|
||||
collection_name,
|
||||
overwrite=True,
|
||||
overwrite=overwrite,
|
||||
add=(not overwrite),
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
@@ -2288,6 +2311,13 @@ def search_web(
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
user=user,
|
||||
)
|
||||
elif engine == "youcom":
|
||||
return search_youcom(
|
||||
request.app.state.config.YOUCOM_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
)
|
||||
else:
|
||||
raise Exception("No search engine API key found in environment variables")
|
||||
|
||||
@@ -2327,7 +2357,7 @@ async def process_web_search(
|
||||
# Limited concurrency with semaphore
|
||||
semaphore = asyncio.Semaphore(concurrent_limit)
|
||||
|
||||
async def search_with_limit(query):
|
||||
async def search_query_with_semaphore(query):
|
||||
async with semaphore:
|
||||
return await run_in_threadpool(
|
||||
search_web,
|
||||
@@ -2337,7 +2367,9 @@ async def process_web_search(
|
||||
user,
|
||||
)
|
||||
|
||||
search_tasks = [search_with_limit(query) for query in form_data.queries]
|
||||
search_tasks = [
|
||||
search_query_with_semaphore(query) for query in form_data.queries
|
||||
]
|
||||
else:
|
||||
# Unlimited parallel execution (previous behavior)
|
||||
search_tasks = [
|
||||
@@ -2462,6 +2494,34 @@ async def process_web_search(
|
||||
)
|
||||
|
||||
|
||||
def _validate_collection_access(collection_names: list[str], user) -> None:
|
||||
"""
|
||||
Prevent users from querying collections they don't own.
|
||||
Enforces ownership on user-memory-* and file-* collections.
|
||||
Admins bypass this check.
|
||||
"""
|
||||
if user.role == "admin":
|
||||
return
|
||||
|
||||
for name in collection_names:
|
||||
if name.startswith("user-memory-") and name != f"user-memory-{user.id}":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
elif name.startswith("file-"):
|
||||
file_id = name[len("file-") :]
|
||||
if not has_access_to_file(
|
||||
file_id=file_id,
|
||||
access_type="read",
|
||||
user=user,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
|
||||
class QueryDocForm(BaseModel):
|
||||
collection_name: str
|
||||
query: str
|
||||
@@ -2477,6 +2537,8 @@ async def query_doc_handler(
|
||||
form_data: QueryDocForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
_validate_collection_access([form_data.collection_name], user)
|
||||
|
||||
try:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
|
||||
form_data.hybrid is None or form_data.hybrid
|
||||
@@ -2551,6 +2613,8 @@ async def query_collection_handler(
|
||||
form_data: QueryCollectionsForm,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
_validate_collection_access(form_data.collection_names, user)
|
||||
|
||||
try:
|
||||
if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (
|
||||
form_data.hybrid is None or form_data.hybrid
|
||||
@@ -2729,6 +2793,27 @@ async def process_files_batch(
|
||||
|
||||
for file in form_data.files:
|
||||
try:
|
||||
# Ownership check: verify the requesting user owns the file or is an admin
|
||||
db_file = Files.get_file_by_id(file.id)
|
||||
if not db_file:
|
||||
file_errors.append(
|
||||
BatchProcessFilesResult(
|
||||
file_id=file.id,
|
||||
status="failed",
|
||||
error="File not found",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if db_file.user_id != user.id and user.role != "admin":
|
||||
file_errors.append(
|
||||
BatchProcessFilesResult(
|
||||
file_id=file.id,
|
||||
status="failed",
|
||||
error="Permission denied: not file owner",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
text_content = file.data.get("content", "")
|
||||
docs: List[Document] = [
|
||||
Document(
|
||||
|
||||
@@ -523,13 +523,17 @@ async def get_schemas():
|
||||
@router.get("/Users", response_model=SCIMListResponse)
|
||||
async def get_users(
|
||||
request: Request,
|
||||
startIndex: int = Query(1, ge=1),
|
||||
count: int = Query(20, ge=1, le=100),
|
||||
startIndex: int = Query(1),
|
||||
count: int = Query(20),
|
||||
filter: Optional[str] = None,
|
||||
_: bool = Depends(get_scim_auth),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""List SCIM Users"""
|
||||
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
|
||||
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
|
||||
startIndex = max(1, startIndex)
|
||||
count = max(0, min(100, count))
|
||||
skip = startIndex - 1
|
||||
limit = count
|
||||
|
||||
@@ -794,13 +798,18 @@ async def delete_user(
|
||||
@router.get("/Groups", response_model=SCIMListResponse)
|
||||
async def get_groups(
|
||||
request: Request,
|
||||
startIndex: int = Query(1, ge=1),
|
||||
count: int = Query(20, ge=1, le=100),
|
||||
startIndex: int = Query(1),
|
||||
count: int = Query(20),
|
||||
filter: Optional[str] = None,
|
||||
_: bool = Depends(get_scim_auth),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
"""List SCIM Groups"""
|
||||
# Clamp per SCIM 2.0 spec (RFC 7644 §3.4.2.4):
|
||||
# startIndex < 1 SHALL be treated as 1; count < 0 SHALL be treated as 0.
|
||||
startIndex = max(1, startIndex)
|
||||
count = max(0, min(100, count))
|
||||
|
||||
# Get all groups
|
||||
groups_list = Groups.get_all_groups(db=db)
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ from open_webui.models.skills import (
|
||||
SkillAccessListResponse,
|
||||
Skills,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
@@ -341,24 +341,13 @@ async def update_skill_access_by_id(
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_skills",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_skills",
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db)
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Reverse proxy for admin-configured terminal servers.
|
||||
|
||||
Routes:
|
||||
GET / — list terminals the user has access to
|
||||
* /{server_id}/{path:path} — proxy request to terminal server
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, Request, Response, WebSocket
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STREAMING_CONTENT_TYPES = ("application/octet-stream", "image/", "application/pdf")
|
||||
STRIPPED_RESPONSE_HEADERS = frozenset(
|
||||
("transfer-encoding", "connection", "content-encoding", "content-length")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_terminal_servers(request: Request, user=Depends(get_verified_user)):
|
||||
"""Return terminal servers the authenticated user has access to."""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
return [
|
||||
{
|
||||
"id": connection.get("id", ""),
|
||||
"url": connection.get("url", ""),
|
||||
"name": connection.get("name", ""),
|
||||
}
|
||||
for connection in connections
|
||||
if connection.get("enabled", True)
|
||||
and has_connection_access(user, connection, user_group_ids)
|
||||
]
|
||||
|
||||
|
||||
PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
||||
|
||||
|
||||
@router.api_route("/{server_id}/{path:path}", methods=PROXY_METHODS)
|
||||
async def proxy_terminal(
|
||||
server_id: str,
|
||||
path: str,
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
"""Proxy a request to the admin terminal server identified by *server_id*."""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connection = next((c for c in connections if c.get("id") == server_id), None)
|
||||
|
||||
if connection is None:
|
||||
return JSONResponse(
|
||||
{"error": f"Terminal server '{server_id}' not found"}, status_code=404
|
||||
)
|
||||
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
if not has_connection_access(user, connection, user_group_ids):
|
||||
return JSONResponse({"error": "Access denied"}, status_code=403)
|
||||
|
||||
base_url = (connection.get("url") or "").rstrip("/")
|
||||
if not base_url:
|
||||
return JSONResponse(
|
||||
{"error": "Terminal server URL not configured"}, status_code=503
|
||||
)
|
||||
|
||||
target_url = f"{base_url}/{path}"
|
||||
if request.query_params:
|
||||
target_url += f"?{request.query_params}"
|
||||
|
||||
headers = {"X-User-Id": user.id}
|
||||
cookies = {}
|
||||
auth_type = connection.get("auth_type", "bearer")
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = f"Bearer {connection.get('key', '')}"
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = f"Bearer {request.state.token.credentials}"
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = request.headers.get("x-oauth-access-token", "")
|
||||
if oauth_token:
|
||||
headers["Authorization"] = f"Bearer {oauth_token}"
|
||||
# auth_type == "none": no Authorization header
|
||||
|
||||
content_type = request.headers.get("content-type")
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
body = await request.body()
|
||||
session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=300, connect=10),
|
||||
trust_env=True,
|
||||
)
|
||||
|
||||
try:
|
||||
upstream_response = await session.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
data=body or None,
|
||||
)
|
||||
|
||||
upstream_content_type = upstream_response.headers.get("content-type", "")
|
||||
filtered_headers = {
|
||||
key: value
|
||||
for key, value in upstream_response.headers.items()
|
||||
if key.lower() not in STRIPPED_RESPONSE_HEADERS
|
||||
}
|
||||
|
||||
# Stream binary responses directly
|
||||
if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES):
|
||||
|
||||
async def cleanup():
|
||||
await upstream_response.release()
|
||||
await session.close()
|
||||
|
||||
return StreamingResponse(
|
||||
content=upstream_response.content.iter_any(),
|
||||
status_code=upstream_response.status,
|
||||
headers=filtered_headers,
|
||||
background=BackgroundTask(cleanup),
|
||||
)
|
||||
|
||||
# Buffer text/JSON responses
|
||||
response_body = await upstream_response.read()
|
||||
status_code = upstream_response.status
|
||||
await upstream_response.release()
|
||||
await session.close()
|
||||
|
||||
return Response(
|
||||
content=response_body, status_code=status_code, headers=filtered_headers
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
await session.close()
|
||||
log.exception("Terminal proxy error: %s", error)
|
||||
return JSONResponse(
|
||||
{"error": f"Terminal proxy error: {error}"}, status_code=502
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket proxy for interactive terminal sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _resolve_authenticated_connection(ws: WebSocket, server_id: str):
|
||||
"""Authenticate a WebSocket via first-message auth and resolve the terminal server.
|
||||
|
||||
The client must send ``{"type": "auth", "token": "<jwt>"}`` as its first
|
||||
message after connecting.
|
||||
|
||||
Returns ``(user, connection)`` on success, or ``None`` after closing *ws*
|
||||
with an appropriate error code.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from open_webui.utils.auth import decode_token
|
||||
|
||||
# First-message authentication
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0)
|
||||
payload = json.loads(raw)
|
||||
if payload.get("type") != "auth":
|
||||
await ws.close(code=4001, reason="Expected auth message")
|
||||
return None
|
||||
token = payload.get("token", "")
|
||||
data = decode_token(token)
|
||||
if data is None or "id" not in data:
|
||||
await ws.close(code=4001, reason="Invalid token")
|
||||
return None
|
||||
user = Users.get_user_by_id(data["id"])
|
||||
if user is None:
|
||||
await ws.close(code=4001, reason="User not found")
|
||||
return None
|
||||
except (asyncio.TimeoutError, json.JSONDecodeError):
|
||||
await ws.close(code=4001, reason="Auth timeout or invalid payload")
|
||||
return None
|
||||
except Exception:
|
||||
await ws.close(code=4001, reason="Invalid token")
|
||||
return None
|
||||
|
||||
# Resolve terminal server
|
||||
connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connection = next((c for c in connections if c.get("id") == server_id), None)
|
||||
|
||||
if connection is None:
|
||||
await ws.close(code=4004, reason="Terminal server not found")
|
||||
return None
|
||||
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
if not has_connection_access(user, connection, user_group_ids):
|
||||
await ws.close(code=4003, reason="Access denied")
|
||||
return None
|
||||
|
||||
return user, connection
|
||||
|
||||
|
||||
@router.websocket("/{server_id}/api/terminals/{session_id}")
|
||||
async def ws_terminal(
|
||||
ws: WebSocket,
|
||||
server_id: str,
|
||||
session_id: str,
|
||||
):
|
||||
"""Proxy an interactive WebSocket terminal session to a terminal server.
|
||||
|
||||
Uses first-message auth: the client sends ``{"type": "auth", "token": "<jwt>"}``
|
||||
as its first message. The proxy validates the JWT, then connects to the
|
||||
upstream terminal server and authenticates with the server's API key.
|
||||
"""
|
||||
await ws.accept()
|
||||
|
||||
result = await _resolve_authenticated_connection(ws, server_id)
|
||||
if result is None:
|
||||
return
|
||||
user, connection = result
|
||||
|
||||
base_url = (connection.get("url") or "").rstrip("/")
|
||||
if not base_url:
|
||||
await ws.close(code=4003, reason="Terminal server URL not configured")
|
||||
return
|
||||
|
||||
# Build upstream WebSocket URL (no token in URL)
|
||||
ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
auth_type = connection.get("auth_type", "bearer")
|
||||
upstream_params = {}
|
||||
# For orchestrator-backed servers, pass user_id
|
||||
upstream_params["user_id"] = user.id
|
||||
|
||||
import urllib.parse
|
||||
|
||||
upstream_url = f"{ws_base}/api/terminals/{session_id}"
|
||||
if upstream_params:
|
||||
upstream_url += f"?{urllib.parse.urlencode(upstream_params)}"
|
||||
|
||||
session = aiohttp.ClientSession()
|
||||
try:
|
||||
async with session.ws_connect(upstream_url) as upstream:
|
||||
import asyncio
|
||||
import json as _json
|
||||
|
||||
# First-message auth to upstream terminal server
|
||||
auth_type = connection.get("auth_type", "bearer")
|
||||
if auth_type == "bearer":
|
||||
key = connection.get("key", "")
|
||||
await upstream.send_str(_json.dumps({"type": "auth", "token": key}))
|
||||
|
||||
async def _client_to_upstream():
|
||||
"""Forward client → upstream."""
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
elif "bytes" in msg and msg["bytes"]:
|
||||
await upstream.send_bytes(msg["bytes"])
|
||||
elif "text" in msg and msg["text"]:
|
||||
await upstream.send_str(msg["text"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _upstream_to_client():
|
||||
"""Forward upstream → client."""
|
||||
try:
|
||||
async for msg in upstream:
|
||||
if msg.type == aiohttp.WSMsgType.BINARY:
|
||||
await ws.send_bytes(msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await ws.send_text(msg.data)
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(
|
||||
_client_to_upstream(),
|
||||
_upstream_to_client(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception("Terminal WebSocket proxy error: %s", e)
|
||||
finally:
|
||||
await session.close()
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -21,7 +21,7 @@ from open_webui.models.tools import (
|
||||
ToolAccessResponse,
|
||||
Tools,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.plugin import (
|
||||
load_tool_module_by_id,
|
||||
replace_imports,
|
||||
@@ -30,7 +30,7 @@ from open_webui.utils.plugin import (
|
||||
)
|
||||
from open_webui.utils.tools import get_tool_specs
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_access, has_permission
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.utils.tools import get_tool_servers
|
||||
|
||||
from open_webui.config import CACHE_DIR, BYPASS_ADMIN_ACCESS_CONTROL
|
||||
@@ -64,13 +64,19 @@ async def get_tools(
|
||||
tools = []
|
||||
|
||||
# Local Tools
|
||||
for tool in Tools.get_tools(db=db):
|
||||
tool_module = get_tool_module(request, tool.id)
|
||||
for tool in Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = (
|
||||
request.app.state.TOOLS.get(tool.id)
|
||||
if hasattr(request.app.state, "TOOLS")
|
||||
else None
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
"has_user_valves": hasattr(tool_module, "UserValves"),
|
||||
"has_user_valves": (
|
||||
hasattr(tool_module, "UserValves") if tool_module else False
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -196,27 +202,40 @@ async def get_tool_list(
|
||||
user=Depends(get_verified_user), db: Session = Depends(get_session)
|
||||
):
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
tools = Tools.get_tools(db=db)
|
||||
tools = Tools.get_tools(defer_content=True, db=db)
|
||||
else:
|
||||
tools = Tools.get_tools_by_user_id(user.id, "read", db=db)
|
||||
tools = Tools.get_tools_by_user_id(user.id, "read", defer_content=True, db=db)
|
||||
|
||||
return [
|
||||
ToolAccessResponse(
|
||||
**tool.model_dump(),
|
||||
write_access=(
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tool.user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="tool",
|
||||
resource_id=tool.id,
|
||||
permission="write",
|
||||
db=db,
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
|
||||
result = []
|
||||
for tool in tools:
|
||||
has_write = (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == tool.user_id
|
||||
or any(
|
||||
g.permission == "write"
|
||||
and (
|
||||
(
|
||||
g.principal_type == "user"
|
||||
and (g.principal_id == user.id or g.principal_id == "*")
|
||||
)
|
||||
or (
|
||||
g.principal_type == "group" and g.principal_id in user_group_ids
|
||||
)
|
||||
)
|
||||
),
|
||||
for g in tool.access_grants
|
||||
)
|
||||
)
|
||||
for tool in tools
|
||||
]
|
||||
result.append(
|
||||
ToolAccessResponse(
|
||||
**tool.model_dump(),
|
||||
write_access=has_write,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
############################
|
||||
@@ -557,24 +576,13 @@ async def update_tool_access_by_id(
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Strip public sharing if user lacks permission
|
||||
if (
|
||||
user.role != "admin"
|
||||
and has_public_read_access_grant(form_data.access_grants)
|
||||
and not has_permission(
|
||||
user.id,
|
||||
"sharing.public_tools",
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
)
|
||||
):
|
||||
form_data.access_grants = [
|
||||
grant
|
||||
for grant in form_data.access_grants
|
||||
if not (
|
||||
grant.get("principal_type") == "user"
|
||||
and grant.get("principal_id") == "*"
|
||||
)
|
||||
]
|
||||
form_data.access_grants = filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
"sharing.public_tools",
|
||||
)
|
||||
|
||||
AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db)
|
||||
|
||||
|
||||
@@ -196,12 +196,17 @@ class SharingPermissions(BaseModel):
|
||||
public_notes: bool = True
|
||||
|
||||
|
||||
class AccessGrantsPermissions(BaseModel):
|
||||
allow_users: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
controls: bool = True
|
||||
valves: bool = True
|
||||
system_prompt: bool = True
|
||||
params: bool = True
|
||||
file_upload: bool = True
|
||||
web_upload: bool = True
|
||||
delete: bool = True
|
||||
delete_message: bool = True
|
||||
continue_response: bool = True
|
||||
@@ -238,6 +243,7 @@ class SettingsPermissions(BaseModel):
|
||||
class UserPermissions(BaseModel):
|
||||
workspace: WorkspacePermissions
|
||||
sharing: SharingPermissions
|
||||
access_grants: AccessGrantsPermissions
|
||||
chat: ChatPermissions
|
||||
features: FeaturesPermissions
|
||||
settings: SettingsPermissions
|
||||
@@ -252,6 +258,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_
|
||||
"sharing": SharingPermissions(
|
||||
**request.app.state.config.USER_PERMISSIONS.get("sharing", {})
|
||||
),
|
||||
"access_grants": AccessGrantsPermissions(
|
||||
**request.app.state.config.USER_PERMISSIONS.get("access_grants", {})
|
||||
),
|
||||
"chat": ChatPermissions(
|
||||
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
|
||||
),
|
||||
|
||||
@@ -37,6 +37,7 @@ from open_webui.env import (
|
||||
WEBSOCKET_SERVER_PING_INTERVAL,
|
||||
WEBSOCKET_SERVER_LOGGING,
|
||||
WEBSOCKET_SERVER_ENGINEIO_LOGGING,
|
||||
WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
)
|
||||
from open_webui.utils.auth import decode_token
|
||||
from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
|
||||
@@ -511,6 +512,8 @@ async def channel_events(sid, data):
|
||||
async def ydoc_document_join(sid, data):
|
||||
"""Handle user joining a document"""
|
||||
user = SESSION_POOL.get(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
try:
|
||||
document_id = data["document_id"]
|
||||
@@ -683,11 +686,13 @@ async def yjs_document_update(sid, data):
|
||||
skip_sid=sid,
|
||||
)
|
||||
|
||||
user = SESSION_POOL.get(sid)
|
||||
if not user:
|
||||
return
|
||||
|
||||
async def debounced_save():
|
||||
await asyncio.sleep(0.5)
|
||||
await document_save_handler(
|
||||
document_id, data.get("data", {}), SESSION_POOL.get(sid)
|
||||
)
|
||||
await document_save_handler(document_id, data.get("data", {}), user)
|
||||
|
||||
if data.get("data"):
|
||||
await create_task(REDIS, debounced_save(), document_id)
|
||||
@@ -786,21 +791,26 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
room=f"user:{user_id}",
|
||||
)
|
||||
|
||||
if (
|
||||
update_db
|
||||
and message_id
|
||||
and not request_info.get("chat_id", "").startswith("local:")
|
||||
):
|
||||
|
||||
if "type" in event_data and event_data["type"] == "status":
|
||||
Chats.add_message_status_to_chat_by_id_and_message_id(
|
||||
event_type = event_data.get("type")
|
||||
|
||||
if event_type == "status":
|
||||
await asyncio.to_thread(
|
||||
Chats.add_message_status_to_chat_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
event_data.get("data", {}),
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "message":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
elif event_type == "message":
|
||||
message = await asyncio.to_thread(
|
||||
Chats.get_message_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
@@ -809,7 +819,8 @@ def get_event_emitter(request_info, update_db=True):
|
||||
content = message.get("content", "")
|
||||
content += event_data.get("data", {}).get("content", "")
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
await asyncio.to_thread(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
@@ -817,10 +828,11 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "replace":
|
||||
elif event_type == "replace":
|
||||
content = event_data.get("data", {}).get("content", "")
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
await asyncio.to_thread(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
@@ -828,8 +840,9 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "embeds":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
elif event_type == "embeds":
|
||||
message = await asyncio.to_thread(
|
||||
Chats.get_message_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
@@ -837,7 +850,8 @@ def get_event_emitter(request_info, update_db=True):
|
||||
embeds = event_data.get("data", {}).get("embeds", [])
|
||||
embeds.extend(message.get("embeds", []))
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
await asyncio.to_thread(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
@@ -845,8 +859,9 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if "type" in event_data and event_data["type"] == "files":
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
elif event_type == "files":
|
||||
message = await asyncio.to_thread(
|
||||
Chats.get_message_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
@@ -854,7 +869,8 @@ def get_event_emitter(request_info, update_db=True):
|
||||
files = event_data.get("data", {}).get("files", [])
|
||||
files.extend(message.get("files", []))
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
await asyncio.to_thread(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
@@ -862,10 +878,11 @@ def get_event_emitter(request_info, update_db=True):
|
||||
},
|
||||
)
|
||||
|
||||
if event_data.get("type") in ["source", "citation"]:
|
||||
elif event_type in ("source", "citation"):
|
||||
data = event_data.get("data", {})
|
||||
if data.get("type") == None:
|
||||
message = Chats.get_message_by_id_and_message_id(
|
||||
if data.get("type") is None:
|
||||
message = await asyncio.to_thread(
|
||||
Chats.get_message_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
)
|
||||
@@ -873,7 +890,8 @@ def get_event_emitter(request_info, update_db=True):
|
||||
sources = message.get("sources", [])
|
||||
sources.append(data)
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
await asyncio.to_thread(
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id,
|
||||
request_info["chat_id"],
|
||||
request_info["message_id"],
|
||||
{
|
||||
@@ -901,6 +919,7 @@ def get_event_call(request_info):
|
||||
"data": event_data,
|
||||
},
|
||||
to=request_info["session_id"],
|
||||
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
from test.util.abstract_integration_test import AbstractPostgresTest
|
||||
from test.util.mock_user import mock_webui_user
|
||||
|
||||
|
||||
class TestPrompts(AbstractPostgresTest):
|
||||
BASE_PATH = "/api/v1/prompts"
|
||||
|
||||
def test_prompts(self):
|
||||
# Get all prompts
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.get(self.create_url("/"))
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 0
|
||||
|
||||
# Create a two new prompts
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.post(
|
||||
self.create_url("/create"),
|
||||
json={
|
||||
"command": "/my-command",
|
||||
"title": "Hello World",
|
||||
"content": "description",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
with mock_webui_user(id="3"):
|
||||
response = self.fast_api_client.post(
|
||||
self.create_url("/create"),
|
||||
json={
|
||||
"command": "/my-command2",
|
||||
"title": "Hello World 2",
|
||||
"content": "description 2",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get all prompts
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.get(self.create_url("/"))
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
# Get prompt by command
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.get(self.create_url("/command/my-command"))
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["command"] == "/my-command"
|
||||
assert data["title"] == "Hello World"
|
||||
assert data["content"] == "description"
|
||||
assert data["user_id"] == "2"
|
||||
|
||||
# Update prompt
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.post(
|
||||
self.create_url("/command/my-command2/update"),
|
||||
json={
|
||||
"command": "irrelevant for request",
|
||||
"title": "Hello World Updated",
|
||||
"content": "description Updated",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["command"] == "/my-command2"
|
||||
assert data["title"] == "Hello World Updated"
|
||||
assert data["content"] == "description Updated"
|
||||
assert data["user_id"] == "3"
|
||||
|
||||
# Get prompt by command
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.get(self.create_url("/command/my-command2"))
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["command"] == "/my-command2"
|
||||
assert data["title"] == "Hello World Updated"
|
||||
assert data["content"] == "description Updated"
|
||||
assert data["user_id"] == "3"
|
||||
|
||||
# Delete prompt
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.delete(
|
||||
self.create_url("/command/my-command/delete")
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get all prompts
|
||||
with mock_webui_user(id="2"):
|
||||
response = self.fast_api_client.get(self.create_url("/"))
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
@@ -36,6 +36,8 @@ from open_webui.models.chats import Chats
|
||||
from open_webui.models.channels import Channels, ChannelMember, Channel
|
||||
from open_webui.models.messages import Messages, Message
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.memories import Memories
|
||||
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
||||
from open_webui.utils.sanitize import sanitize_code
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -153,8 +155,7 @@ async def search_web(
|
||||
) -> str:
|
||||
"""
|
||||
Search the public web for information. Best for current events, external references,
|
||||
or topics not covered in internal documents. If knowledge base tools are available,
|
||||
consider checking those first for internal information.
|
||||
or topics not covered in internal documents.
|
||||
|
||||
:param query: The search query to look up
|
||||
:param count: Number of results to return (default: 5)
|
||||
@@ -248,11 +249,13 @@ async def generate_image(
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if __chat_id__ and __message_id__ and images:
|
||||
image_files = Chats.add_message_files_by_id_and_message_id(
|
||||
db_files = Chats.add_message_files_by_id_and_message_id(
|
||||
__chat_id__,
|
||||
__message_id__,
|
||||
image_files,
|
||||
)
|
||||
if db_files is not None:
|
||||
image_files = db_files
|
||||
|
||||
# Emit the images to the UI if event emitter is available
|
||||
if __event_emitter__ and image_files:
|
||||
@@ -313,11 +316,13 @@ async def edit_image(
|
||||
|
||||
# Persist files to DB if chat context is available
|
||||
if __chat_id__ and __message_id__ and images:
|
||||
image_files = Chats.add_message_files_by_id_and_message_id(
|
||||
db_files = Chats.add_message_files_by_id_and_message_id(
|
||||
__chat_id__,
|
||||
__message_id__,
|
||||
image_files,
|
||||
)
|
||||
if db_files is not None:
|
||||
image_files = db_files
|
||||
|
||||
# Emit the images to the UI if event emitter is available
|
||||
if __event_emitter__ and image_files:
|
||||
@@ -424,6 +429,9 @@ async def execute_code(
|
||||
"session_id": (
|
||||
__metadata__.get("session_id") if __metadata__ else None
|
||||
),
|
||||
"files": (
|
||||
__metadata__.get("files", []) if __metadata__ else []
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -634,6 +642,79 @@ async def replace_memory_content(
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def delete_memory(
|
||||
memory_id: str,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delete a memory by its ID.
|
||||
|
||||
:param memory_id: The ID of the memory to delete
|
||||
:return: Confirmation that the memory was deleted
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({"error": "Request context not available"})
|
||||
|
||||
try:
|
||||
user = UserModel(**__user__) if __user__ else None
|
||||
|
||||
result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id)
|
||||
|
||||
if result:
|
||||
VECTOR_DB_CLIENT.delete(
|
||||
collection_name=f"user-memory-{user.id}", ids=[memory_id]
|
||||
)
|
||||
return json.dumps(
|
||||
{"status": "success", "message": f"Memory {memory_id} deleted"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
else:
|
||||
return json.dumps({"error": "Memory not found or access denied"})
|
||||
except Exception as e:
|
||||
log.exception(f"delete_memory error: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def list_memories(
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
List all stored memories for the user.
|
||||
|
||||
:return: JSON list of all memories with id, content, and dates
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({"error": "Request context not available"})
|
||||
|
||||
try:
|
||||
user = UserModel(**__user__) if __user__ else None
|
||||
|
||||
memories = Memories.get_memories_by_user_id(user.id)
|
||||
|
||||
if memories:
|
||||
result = [
|
||||
{
|
||||
"id": m.id,
|
||||
"content": m.content,
|
||||
"created_at": time.strftime(
|
||||
"%Y-%m-%d %H:%M", time.localtime(m.created_at)
|
||||
),
|
||||
"updated_at": time.strftime(
|
||||
"%Y-%m-%d %H:%M", time.localtime(m.updated_at)
|
||||
),
|
||||
}
|
||||
for m in memories
|
||||
]
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
else:
|
||||
return json.dumps([])
|
||||
except Exception as e:
|
||||
log.exception(f"list_memories error: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# NOTES TOOLS
|
||||
# =============================================================================
|
||||
@@ -1532,6 +1613,69 @@ async def search_knowledge_files(
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def view_file(
|
||||
file_id: str,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__model_knowledge__: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the full content of a file by its ID.
|
||||
|
||||
:param file_id: The ID of the file to retrieve
|
||||
:return: JSON with the file's id, filename, and full text content
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({"error": "Request context not available"})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({"error": "User context not available"})
|
||||
|
||||
try:
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
|
||||
user_id = __user__.get("id")
|
||||
user_role = __user__.get("role", "user")
|
||||
|
||||
file = Files.get_file_by_id(file_id)
|
||||
if not file:
|
||||
return json.dumps({"error": "File not found"})
|
||||
|
||||
if (
|
||||
file.user_id != user_id
|
||||
and user_role != "admin"
|
||||
and not any(
|
||||
item.get("type") == "file" and item.get("id") == file_id
|
||||
for item in (__model_knowledge__ or [])
|
||||
)
|
||||
and not has_access_to_file(
|
||||
file_id=file_id,
|
||||
access_type="read",
|
||||
user=UserModel(**__user__),
|
||||
)
|
||||
):
|
||||
return json.dumps({"error": "File not found"})
|
||||
|
||||
content = ""
|
||||
if file.data:
|
||||
content = file.data.get("content", "")
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"id": file.id,
|
||||
"filename": file.filename,
|
||||
"content": content,
|
||||
"updated_at": file.updated_at,
|
||||
"created_at": file.created_at,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f"view_file error: {e}")
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def view_knowledge_file(
|
||||
file_id: str,
|
||||
__request__: Request = None,
|
||||
@@ -1693,7 +1837,7 @@ async def query_knowledge_files(
|
||||
elif item_type == "file":
|
||||
# Individual file - use file-{id} as collection name
|
||||
file = Files.get_file_by_id(item_id)
|
||||
if file and (user_role == "admin" or file.user_id == user_id):
|
||||
if file:
|
||||
collection_names.append(f"file-{item_id}")
|
||||
|
||||
elif item_type == "note":
|
||||
|
||||
+85
@@ -153,6 +153,31 @@ def has_access(
|
||||
return False
|
||||
|
||||
|
||||
def has_connection_access(
|
||||
user: UserModel,
|
||||
connection: dict,
|
||||
user_group_ids: Optional[Set[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user can access a server connection (tool server, terminal, etc.)
|
||||
based on ``config.access_grants`` within the connection dict.
|
||||
|
||||
- Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed
|
||||
- Missing, None, or empty access_grants → private, admin-only
|
||||
- access_grants has entries → delegates to ``has_access``
|
||||
"""
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return True
|
||||
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
access_grants = (connection.get("config") or {}).get("access_grants", [])
|
||||
return has_access(user.id, "read", access_grants, user_group_ids)
|
||||
|
||||
|
||||
def migrate_access_control(
|
||||
data: dict, ac_key: str = "access_control", grants_key: str = "access_grants"
|
||||
) -> None:
|
||||
@@ -194,3 +219,63 @@ def migrate_access_control(
|
||||
|
||||
data[grants_key] = grants
|
||||
data.pop(ac_key, None)
|
||||
|
||||
|
||||
from open_webui.models.access_grants import (
|
||||
has_public_read_access_grant,
|
||||
has_user_access_grant,
|
||||
strip_user_access_grants,
|
||||
)
|
||||
|
||||
|
||||
def filter_allowed_access_grants(
|
||||
default_permissions: Dict[str, Any],
|
||||
user_id: str,
|
||||
user_role: str,
|
||||
access_grants: list,
|
||||
public_permission_key: str,
|
||||
db: Optional[Any] = None,
|
||||
) -> list:
|
||||
"""
|
||||
Checks if the user has the required permissions to grant access to a resource.
|
||||
Returns the filtered list of access grants if permissions are missing.
|
||||
"""
|
||||
if user_role == "admin" or not access_grants:
|
||||
return access_grants
|
||||
|
||||
# Check if user can share publicly
|
||||
if has_public_read_access_grant(access_grants) and not has_permission(
|
||||
user_id,
|
||||
public_permission_key,
|
||||
default_permissions,
|
||||
db=db,
|
||||
):
|
||||
access_grants = [
|
||||
grant
|
||||
for grant in access_grants
|
||||
if not (
|
||||
(
|
||||
grant.get("principal_type")
|
||||
if isinstance(grant, dict)
|
||||
else getattr(grant, "principal_type", None)
|
||||
)
|
||||
== "user"
|
||||
and (
|
||||
grant.get("principal_id")
|
||||
if isinstance(grant, dict)
|
||||
else getattr(grant, "principal_id", None)
|
||||
)
|
||||
== "*"
|
||||
)
|
||||
]
|
||||
|
||||
# Strip individual user sharing if user lacks permission
|
||||
if has_user_access_grant(access_grants) and not has_permission(
|
||||
user_id,
|
||||
"access_grants.allow_users",
|
||||
default_permissions,
|
||||
db=db,
|
||||
):
|
||||
access_grants = strip_user_access_grants(access_grants)
|
||||
|
||||
return access_grants
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def has_access_to_file(
|
||||
file_id: Optional[str],
|
||||
access_type: str,
|
||||
user: UserModel,
|
||||
db: Optional[Any] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified access to a file through any of:
|
||||
- Knowledge bases (ownership or access grants)
|
||||
- Shared workspace models that attach the file directly
|
||||
- Channels the user is a member of
|
||||
- Shared chats
|
||||
|
||||
NOTE: This does NOT check direct file ownership — callers should check
|
||||
file.user_id == user.id separately before calling this.
|
||||
"""
|
||||
file = Files.get_file_by_id(file_id, db=db)
|
||||
log.debug(f"Checking if user has {access_type} access to file")
|
||||
if not file:
|
||||
return False
|
||||
|
||||
# Direct ownership
|
||||
if file.user_id == user.id:
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any knowledge bases the user has access to
|
||||
knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.user_id == user.id or AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type="knowledge",
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
return True
|
||||
|
||||
knowledge_base_id = file.meta.get("collection_name") if file.meta else None
|
||||
if knowledge_base_id:
|
||||
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(
|
||||
user.id, access_type, db=db
|
||||
)
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.id == knowledge_base_id:
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
|
||||
if access_type == "read" and channels:
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any chats the user has access to
|
||||
# TODO: Granular access control for chats
|
||||
chats = Chats.get_shared_chats_by_file_id(file_id, db=db)
|
||||
if chats:
|
||||
return True
|
||||
|
||||
# Check if the file is directly attached to a shared workspace model
|
||||
for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||
knowledge_items = getattr(model.meta, "knowledge", None) or []
|
||||
for item in knowledge_items:
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "file"
|
||||
and item.get("id") == file.id
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,534 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
)
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_anthropic_url(url: str) -> bool:
|
||||
"""Check if the URL is an Anthropic API endpoint."""
|
||||
return "api.anthropic.com" in url
|
||||
|
||||
|
||||
async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> dict:
|
||||
"""
|
||||
Fetch models from Anthropic's /v1/models endpoint with pagination.
|
||||
Normalizes the response to OpenAI format.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
all_models = []
|
||||
after_id = None
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
headers = {
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
while True:
|
||||
params = {"limit": 1000}
|
||||
if after_id:
|
||||
params["after_id"] = after_id
|
||||
|
||||
async with session.get(
|
||||
f"{url}/models",
|
||||
headers=headers,
|
||||
params=params,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_detail = f"HTTP Error: {response.status}"
|
||||
try:
|
||||
res = await response.json()
|
||||
if "error" in res:
|
||||
error_detail = f"External Error: {res['error']}"
|
||||
except Exception:
|
||||
pass
|
||||
return {"object": "list", "data": [], "error": error_detail}
|
||||
|
||||
data = await response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
all_models.append(
|
||||
{
|
||||
"id": model.get("id"),
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": "anthropic",
|
||||
"name": model.get("display_name", model.get("id")),
|
||||
}
|
||||
)
|
||||
|
||||
if not data.get("has_more", False):
|
||||
break
|
||||
after_id = data.get("last_id")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Anthropic connection error: {e}")
|
||||
return None
|
||||
|
||||
return {"object": "list", "data": all_models}
|
||||
|
||||
|
||||
##############################
|
||||
#
|
||||
# Anthropic Messages API Conversion Utilities
|
||||
#
|
||||
##############################
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
"""
|
||||
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
|
||||
|
||||
Anthropic format:
|
||||
{model, messages: [{role, content}], system, max_tokens, ...}
|
||||
OpenAI format:
|
||||
{model, messages: [{role, content}], max_tokens, ...}
|
||||
"""
|
||||
openai_payload = {}
|
||||
|
||||
# Model
|
||||
openai_payload["model"] = anthropic_payload.get("model", "")
|
||||
|
||||
# Build messages list
|
||||
messages = []
|
||||
|
||||
# System prompt (Anthropic has it as top-level, OpenAI as a system message)
|
||||
system = anthropic_payload.get("system")
|
||||
if system:
|
||||
if isinstance(system, str):
|
||||
messages.append({"role": "system", "content": system})
|
||||
elif isinstance(system, list):
|
||||
# Anthropic supports system as list of content blocks
|
||||
text_parts = []
|
||||
for block in system:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text_parts.append(block.get("text", ""))
|
||||
elif isinstance(block, str):
|
||||
text_parts.append(block)
|
||||
messages.append({"role": "system", "content": "\n".join(text_parts)})
|
||||
|
||||
# Convert messages
|
||||
for msg in anthropic_payload.get("messages", []):
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content")
|
||||
|
||||
if isinstance(content, str):
|
||||
messages.append({"role": role, "content": content})
|
||||
elif isinstance(content, list):
|
||||
# Convert Anthropic content blocks to OpenAI format
|
||||
openai_content = []
|
||||
tool_calls = []
|
||||
|
||||
for block in content:
|
||||
block_type = block.get("type", "text")
|
||||
|
||||
if block_type == "text":
|
||||
openai_content.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": block.get("text", ""),
|
||||
}
|
||||
)
|
||||
elif block_type == "image":
|
||||
source = block.get("source", {})
|
||||
if source.get("type") == "base64":
|
||||
media_type = source.get("media_type", "image/png")
|
||||
data = source.get("data", "")
|
||||
openai_content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:{media_type};base64,{data}",
|
||||
},
|
||||
}
|
||||
)
|
||||
elif source.get("type") == "url":
|
||||
openai_content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": source.get("url", "")},
|
||||
}
|
||||
)
|
||||
elif block_type == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": block.get("id", ""),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": block.get("name", ""),
|
||||
"arguments": (
|
||||
json.dumps(block.get("input", {}))
|
||||
if isinstance(block.get("input"), dict)
|
||||
else str(block.get("input", "{}"))
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
elif block_type == "tool_result":
|
||||
# Tool results become separate tool messages in OpenAI format
|
||||
tool_content = block.get("content", "")
|
||||
if isinstance(tool_content, list):
|
||||
tool_text_parts = []
|
||||
for tc in tool_content:
|
||||
if isinstance(tc, dict) and tc.get("type") == "text":
|
||||
tool_text_parts.append(tc.get("text", ""))
|
||||
tool_content = "\n".join(tool_text_parts)
|
||||
|
||||
# Propagate error status if present
|
||||
if block.get("is_error"):
|
||||
tool_content = f"Error: {tool_content}"
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": block.get("tool_use_id", ""),
|
||||
"content": tool_content,
|
||||
}
|
||||
)
|
||||
|
||||
# Build the message
|
||||
if tool_calls:
|
||||
# Assistant message with tool calls
|
||||
msg_dict = {"role": role}
|
||||
if openai_content:
|
||||
# If there's only text, flatten it
|
||||
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
|
||||
msg_dict["content"] = openai_content[0]["text"]
|
||||
else:
|
||||
msg_dict["content"] = openai_content
|
||||
else:
|
||||
msg_dict["content"] = ""
|
||||
msg_dict["tool_calls"] = tool_calls
|
||||
messages.append(msg_dict)
|
||||
elif openai_content:
|
||||
# If there's only a single text block, flatten it to a string
|
||||
if len(openai_content) == 1 and openai_content[0]["type"] == "text":
|
||||
messages.append(
|
||||
{"role": role, "content": openai_content[0]["text"]}
|
||||
)
|
||||
else:
|
||||
messages.append({"role": role, "content": openai_content})
|
||||
else:
|
||||
messages.append({"role": role, "content": str(content) if content else ""})
|
||||
|
||||
openai_payload["messages"] = messages
|
||||
|
||||
# max_tokens
|
||||
if "max_tokens" in anthropic_payload:
|
||||
openai_payload["max_tokens"] = anthropic_payload["max_tokens"]
|
||||
|
||||
# Common parameters
|
||||
for param in ("temperature", "top_p", "stop_sequences", "stream"):
|
||||
if param in anthropic_payload:
|
||||
if param == "stop_sequences":
|
||||
openai_payload["stop"] = anthropic_payload[param]
|
||||
else:
|
||||
openai_payload[param] = anthropic_payload[param]
|
||||
|
||||
# Tools conversion: Anthropic → OpenAI
|
||||
if "tools" in anthropic_payload:
|
||||
openai_tools = []
|
||||
for tool in anthropic_payload["tools"]:
|
||||
openai_tools.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.get("name", ""),
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("input_schema", {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
openai_payload["tools"] = openai_tools
|
||||
|
||||
# tool_choice
|
||||
if "tool_choice" in anthropic_payload:
|
||||
tc = anthropic_payload["tool_choice"]
|
||||
if isinstance(tc, dict):
|
||||
tc_type = tc.get("type", "auto")
|
||||
if tc_type == "auto":
|
||||
openai_payload["tool_choice"] = "auto"
|
||||
elif tc_type == "any":
|
||||
openai_payload["tool_choice"] = "required"
|
||||
elif tc_type == "tool":
|
||||
openai_payload["tool_choice"] = {
|
||||
"type": "function",
|
||||
"function": {"name": tc.get("name", "")},
|
||||
}
|
||||
|
||||
return openai_payload
|
||||
|
||||
|
||||
def convert_openai_to_anthropic_response(
|
||||
openai_response: dict, model: str = ""
|
||||
) -> dict:
|
||||
"""
|
||||
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
choice = {}
|
||||
if openai_response.get("choices"):
|
||||
choice = openai_response["choices"][0]
|
||||
|
||||
message = choice.get("message", {})
|
||||
finish_reason = choice.get("finish_reason", "stop")
|
||||
|
||||
# Map finish_reason to stop_reason
|
||||
stop_reason_map = {
|
||||
"stop": "end_turn",
|
||||
"length": "max_tokens",
|
||||
"tool_calls": "tool_use",
|
||||
"content_filter": "end_turn",
|
||||
}
|
||||
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
|
||||
|
||||
# Build content blocks
|
||||
content = []
|
||||
msg_content = message.get("content")
|
||||
if msg_content:
|
||||
content.append({"type": "text", "text": msg_content})
|
||||
|
||||
# Tool calls → tool_use blocks
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
for tc in tool_calls:
|
||||
func = tc.get("function", {})
|
||||
try:
|
||||
tool_input = json.loads(func.get("arguments", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tool_input = {}
|
||||
content.append(
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"),
|
||||
"name": func.get("name", ""),
|
||||
"input": tool_input,
|
||||
}
|
||||
)
|
||||
|
||||
# Usage
|
||||
openai_usage = openai_response.get("usage", {})
|
||||
usage = {
|
||||
"input_tokens": openai_usage.get("prompt_tokens", 0),
|
||||
"output_tokens": openai_usage.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"model": model or openai_response.get("model", ""),
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": None,
|
||||
"usage": usage,
|
||||
}
|
||||
|
||||
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ""):
|
||||
"""
|
||||
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
|
||||
|
||||
OpenAI sends: data: {"choices": [{"delta": {"content": "..."}}]}
|
||||
Anthropic sends: event: content_block_delta\\ndata: {"type": "content_block_delta", ...}
|
||||
|
||||
Handles text content, tool calls, and mixed content with proper
|
||||
multi-block indexing as required by Anthropic's streaming protocol.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
msg_id = f"msg_{_uuid.uuid4().hex[:24]}"
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
stop_reason = "end_turn"
|
||||
|
||||
# Track content blocks with a running index.
|
||||
# Each text block or tool_use block gets its own index.
|
||||
current_block_index = 0
|
||||
text_block_open = False
|
||||
|
||||
# Track tool call state: maps OpenAI tool_call index -> Anthropic block index
|
||||
# This allows handling multiple concurrent tool calls.
|
||||
tool_call_blocks = {} # {openai_tc_index: anthropic_block_index}
|
||||
tool_call_started = {} # {openai_tc_index: bool}
|
||||
|
||||
# Emit message_start
|
||||
message_start = {
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": msg_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": model,
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
}
|
||||
yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode()
|
||||
|
||||
try:
|
||||
async for chunk in openai_stream_generator:
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8", errors="ignore")
|
||||
|
||||
for line in chunk.strip().split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_str = line[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
continue
|
||||
if data_str == "{}":
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
# Check for usage in the final chunk
|
||||
if data.get("usage"):
|
||||
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
|
||||
output_tokens = data["usage"].get(
|
||||
"completion_tokens", output_tokens
|
||||
)
|
||||
continue
|
||||
|
||||
delta = choices[0].get("delta", {})
|
||||
finish_reason = choices[0].get("finish_reason")
|
||||
|
||||
# Update usage if present
|
||||
if data.get("usage"):
|
||||
input_tokens = data["usage"].get("prompt_tokens", input_tokens)
|
||||
output_tokens = data["usage"].get(
|
||||
"completion_tokens", output_tokens
|
||||
)
|
||||
|
||||
# --- Handle text content ---
|
||||
content = delta.get("content")
|
||||
if content is not None:
|
||||
if not text_block_open:
|
||||
# Start a new text content block
|
||||
block_start = {
|
||||
"type": "content_block_start",
|
||||
"index": current_block_index,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
}
|
||||
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
|
||||
text_block_open = True
|
||||
|
||||
# Send text delta
|
||||
block_delta = {
|
||||
"type": "content_block_delta",
|
||||
"index": current_block_index,
|
||||
"delta": {"type": "text_delta", "text": content},
|
||||
}
|
||||
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
|
||||
|
||||
# --- Handle tool calls ---
|
||||
tool_calls = delta.get("tool_calls")
|
||||
if tool_calls:
|
||||
# Close text block if one is open (text comes before tools)
|
||||
if text_block_open:
|
||||
block_stop = {
|
||||
"type": "content_block_stop",
|
||||
"index": current_block_index,
|
||||
}
|
||||
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
|
||||
text_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
for tc in tool_calls:
|
||||
tc_index = tc.get("index", 0)
|
||||
|
||||
if tc_index not in tool_call_started:
|
||||
# First time seeing this tool call — emit content_block_start
|
||||
tool_call_blocks[tc_index] = current_block_index
|
||||
tool_call_started[tc_index] = True
|
||||
|
||||
# Extract tool call ID and name from the first chunk
|
||||
tc_id = tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}")
|
||||
tc_name = tc.get("function", {}).get("name", "")
|
||||
|
||||
block_start = {
|
||||
"type": "content_block_start",
|
||||
"index": current_block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tc_id,
|
||||
"name": tc_name,
|
||||
"input": {},
|
||||
},
|
||||
}
|
||||
yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode()
|
||||
current_block_index += 1
|
||||
|
||||
# Emit argument chunks as input_json_delta
|
||||
args_chunk = tc.get("function", {}).get("arguments", "")
|
||||
if args_chunk:
|
||||
block_delta = {
|
||||
"type": "content_block_delta",
|
||||
"index": tool_call_blocks[tc_index],
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args_chunk,
|
||||
},
|
||||
}
|
||||
yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode()
|
||||
|
||||
# --- Handle finish reason ---
|
||||
if finish_reason is not None:
|
||||
stop_reason_map = {
|
||||
"stop": "end_turn",
|
||||
"length": "max_tokens",
|
||||
"tool_calls": "tool_use",
|
||||
}
|
||||
stop_reason = stop_reason_map.get(finish_reason, "end_turn")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in Anthropic stream conversion: {e}")
|
||||
|
||||
# Close any open text block
|
||||
if text_block_open:
|
||||
block_stop = {"type": "content_block_stop", "index": current_block_index}
|
||||
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
|
||||
|
||||
# Close any open tool call blocks
|
||||
for tc_index, block_index in tool_call_blocks.items():
|
||||
block_stop = {"type": "content_block_stop", "index": block_index}
|
||||
yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode()
|
||||
|
||||
# Emit message_delta with stop reason
|
||||
message_delta = {
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": None,
|
||||
},
|
||||
"usage": {"output_tokens": output_tokens},
|
||||
}
|
||||
yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode()
|
||||
|
||||
# Emit message_stop
|
||||
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode()
|
||||
@@ -290,6 +290,10 @@ async def get_current_user(
|
||||
if token is None and "token" in request.cookies:
|
||||
token = request.cookies.get("token")
|
||||
|
||||
# Fallback to request.state.token (set by middleware, e.g. for x-api-key)
|
||||
if token is None and hasattr(request.state, "token") and request.state.token:
|
||||
token = request.state.token.credentials
|
||||
|
||||
if token is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
@@ -39,11 +39,6 @@ from open_webui.routers.pipelines import (
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
|
||||
|
||||
from open_webui.utils.plugin import (
|
||||
load_function_module_by_id,
|
||||
get_function_module_from_cache,
|
||||
)
|
||||
from open_webui.utils.models import get_all_models, check_model_access
|
||||
from open_webui.utils.payload import convert_payload_openai_to_ollama
|
||||
from open_webui.utils.response import (
|
||||
|
||||
@@ -22,10 +22,14 @@ def get_function_module(request, function_id, load_from_db=True):
|
||||
|
||||
def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
|
||||
def get_priority(function_id):
|
||||
function = Functions.get_function_by_id(function_id)
|
||||
if function is not None:
|
||||
valves = Functions.get_function_valves_by_id(function_id)
|
||||
return valves.get("priority", 0) if valves else 0
|
||||
try:
|
||||
function_module = get_function_module(request, function_id)
|
||||
if function_module and hasattr(function_module, "Valves"):
|
||||
valves_db = Functions.get_function_valves_by_id(function_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, "priority", 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
filter_ids = [function.id for function in Functions.get_global_filter_functions()]
|
||||
@@ -50,7 +54,7 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
|
||||
]
|
||||
|
||||
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
|
||||
filter_ids.sort(key=get_priority)
|
||||
filter_ids.sort(key=lambda fid: (get_priority(fid), fid))
|
||||
|
||||
return filter_ids
|
||||
|
||||
|
||||
@@ -12,13 +12,15 @@ from open_webui.env import (
|
||||
AUDIT_LOG_FILE_ROTATION_SIZE,
|
||||
AUDIT_LOG_LEVEL,
|
||||
GLOBAL_LOG_LEVEL,
|
||||
LOG_FORMAT,
|
||||
AUDIT_UVICORN_LOGGER_NAMES,
|
||||
ENABLE_OTEL,
|
||||
ENABLE_OTEL_LOGS,
|
||||
_LEVEL_MAP,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
from loguru import Message, Record
|
||||
|
||||
|
||||
def stdout_format(record: "Record") -> str:
|
||||
@@ -43,6 +45,29 @@ def stdout_format(record: "Record") -> str:
|
||||
)
|
||||
|
||||
|
||||
def _json_sink(message: "Message") -> None:
|
||||
"""Write log records as single-line JSON to stdout.
|
||||
|
||||
Used as a Loguru sink when LOG_FORMAT is set to "json".
|
||||
"""
|
||||
record = message.record
|
||||
log_entry = {
|
||||
"ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
||||
"level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()),
|
||||
"msg": record["message"],
|
||||
"caller": f"{record['name']}:{record['function']}:{record['line']}",
|
||||
}
|
||||
|
||||
if record["extra"]:
|
||||
log_entry["extra"] = record["extra"]
|
||||
|
||||
if record["exception"] is not None:
|
||||
log_entry["error"] = "".join(record["exception"].format_exception()).rstrip()
|
||||
|
||||
sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
"""
|
||||
Intercepts log records from Python's standard logging module
|
||||
@@ -127,14 +152,22 @@ def start_logger():
|
||||
"""
|
||||
logger.remove()
|
||||
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=GLOBAL_LOG_LEVEL,
|
||||
format=stdout_format,
|
||||
filter=lambda record: (
|
||||
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
|
||||
),
|
||||
audit_filter = lambda record: (
|
||||
True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"]
|
||||
)
|
||||
if LOG_FORMAT == "json":
|
||||
logger.add(
|
||||
_json_sink,
|
||||
level=GLOBAL_LOG_LEVEL,
|
||||
filter=audit_filter,
|
||||
)
|
||||
else:
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level=GLOBAL_LOG_LEVEL,
|
||||
format=stdout_format,
|
||||
filter=audit_filter,
|
||||
)
|
||||
if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE:
|
||||
try:
|
||||
logger.add(
|
||||
|
||||
@@ -9,14 +9,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
import httpx
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
|
||||
|
||||
|
||||
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
||||
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
|
||||
client.verify = False
|
||||
return client
|
||||
"""Create an httpx AsyncClient with SSL verification disabled.
|
||||
|
||||
Note: verify=False must be passed at construction time because httpx
|
||||
configures the SSL context during __init__. Setting client.verify = False
|
||||
after construction does not affect the underlying transport's SSL context.
|
||||
"""
|
||||
kwargs = {
|
||||
"follow_redirects": True,
|
||||
"verify": False,
|
||||
}
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
if auth is not None:
|
||||
kwargs["auth"] = auth
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
|
||||
class MCPClient:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,14 +91,22 @@ def get_message_list(messages_map, message_id):
|
||||
|
||||
# Reconstruct the chain by following the parentId links
|
||||
message_list = []
|
||||
visited_message_ids = set()
|
||||
|
||||
while current_message:
|
||||
message_list.insert(
|
||||
0, current_message
|
||||
) # Insert the message at the beginning of the list
|
||||
message_id = current_message.get("id")
|
||||
if message_id in visited_message_ids:
|
||||
# Cycle detected, break to prevent infinite loop
|
||||
break
|
||||
|
||||
if message_id is not None:
|
||||
visited_message_ids.add(message_id)
|
||||
|
||||
message_list.append(current_message)
|
||||
parent_id = current_message.get("parentId") # Use .get() for safety
|
||||
current_message = messages_map.get(parent_id) if parent_id else None
|
||||
|
||||
message_list.reverse()
|
||||
return message_list
|
||||
|
||||
|
||||
@@ -202,7 +210,12 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
content = ""
|
||||
for part in output_parts:
|
||||
if part.get("type") == "input_text":
|
||||
content += part.get("text", "")
|
||||
output_text = part.get("text", "")
|
||||
content += (
|
||||
str(output_text)
|
||||
if not isinstance(output_text, str)
|
||||
else output_text
|
||||
)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
@@ -269,6 +282,24 @@ def get_last_user_message(messages: list[dict]) -> Optional[str]:
|
||||
return get_content_from_message(message)
|
||||
|
||||
|
||||
def set_last_user_message_content(content: str, messages: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Replace the text content of the last user message in-place.
|
||||
Handles both plain-string and list-of-parts content formats.
|
||||
"""
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user":
|
||||
if isinstance(message.get("content"), list):
|
||||
for item in message["content"]:
|
||||
if item.get("type") == "text":
|
||||
item["text"] = content
|
||||
break
|
||||
else:
|
||||
message["content"] = content
|
||||
break
|
||||
return messages
|
||||
|
||||
|
||||
def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]:
|
||||
for message in reversed(messages):
|
||||
if message["role"] == "assistant":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
@@ -148,63 +149,64 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
]
|
||||
|
||||
custom_models = Models.get_all_models()
|
||||
|
||||
# Single O(1) lookup: Ollama base names first, then exact IDs (exact wins).
|
||||
base_model_lookup = {}
|
||||
for model in models:
|
||||
if model.get("owned_by") == "ollama":
|
||||
base_model_lookup.setdefault(model["id"].split(":")[0], model)
|
||||
base_model_lookup[model["id"]] = model
|
||||
|
||||
existing_ids = {m["id"] for m in models}
|
||||
|
||||
for custom_model in custom_models:
|
||||
if custom_model.base_model_id is None:
|
||||
# Applied directly to a base model
|
||||
for model in models:
|
||||
if custom_model.id == model["id"] or (
|
||||
model.get("owned_by") == "ollama"
|
||||
and custom_model.id
|
||||
== model["id"].split(":")[
|
||||
0
|
||||
] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b')
|
||||
):
|
||||
if custom_model.is_active:
|
||||
model["name"] = custom_model.name
|
||||
model["info"] = custom_model.model_dump()
|
||||
# Override applied directly to a base model (shares the same ID)
|
||||
model = base_model_lookup.get(custom_model.id)
|
||||
|
||||
# Set action_ids and filter_ids
|
||||
action_ids = []
|
||||
filter_ids = []
|
||||
if model:
|
||||
if custom_model.is_active:
|
||||
model["name"] = custom_model.name
|
||||
model["info"] = custom_model.model_dump()
|
||||
|
||||
if "info" in model:
|
||||
if "meta" in model["info"]:
|
||||
action_ids.extend(
|
||||
model["info"]["meta"].get("actionIds", [])
|
||||
)
|
||||
filter_ids.extend(
|
||||
model["info"]["meta"].get("filterIds", [])
|
||||
)
|
||||
action_ids = []
|
||||
filter_ids = []
|
||||
|
||||
if "params" in model["info"]:
|
||||
# Remove params to avoid exposing sensitive info
|
||||
del model["info"]["params"]
|
||||
if "info" in model:
|
||||
if "meta" in model["info"]:
|
||||
action_ids.extend(
|
||||
model["info"]["meta"].get("actionIds", [])
|
||||
)
|
||||
filter_ids.extend(
|
||||
model["info"]["meta"].get("filterIds", [])
|
||||
)
|
||||
|
||||
model["action_ids"] = action_ids
|
||||
model["filter_ids"] = filter_ids
|
||||
else:
|
||||
models.remove(model)
|
||||
if "params" in model["info"]:
|
||||
del model["info"]["params"]
|
||||
|
||||
model["action_ids"] = action_ids
|
||||
model["filter_ids"] = filter_ids
|
||||
else:
|
||||
models.remove(model)
|
||||
|
||||
elif custom_model.is_active:
|
||||
if custom_model.id in existing_ids:
|
||||
continue
|
||||
|
||||
elif custom_model.is_active and (
|
||||
custom_model.id not in [model["id"] for model in models]
|
||||
):
|
||||
# Custom model based on a base model
|
||||
owned_by = "openai"
|
||||
connection_type = None
|
||||
|
||||
pipe = None
|
||||
|
||||
for m in models:
|
||||
if (
|
||||
custom_model.base_model_id == m["id"]
|
||||
or custom_model.base_model_id == m["id"].split(":")[0]
|
||||
):
|
||||
owned_by = m.get("owned_by", "unknown")
|
||||
if "pipe" in m:
|
||||
pipe = m["pipe"]
|
||||
|
||||
connection_type = m.get("connection_type", None)
|
||||
break
|
||||
base_model = base_model_lookup.get(custom_model.base_model_id)
|
||||
if base_model is None:
|
||||
base_model = base_model_lookup.get(
|
||||
custom_model.base_model_id.split(":")[0]
|
||||
)
|
||||
if base_model:
|
||||
owned_by = base_model.get("owned_by", "unknown")
|
||||
if "pipe" in base_model:
|
||||
pipe = base_model["pipe"]
|
||||
connection_type = base_model.get("connection_type", None)
|
||||
|
||||
model = {
|
||||
"id": f"{custom_model.id}",
|
||||
@@ -302,7 +304,48 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
# This ensures each function's DB freshness check runs exactly once,
|
||||
# not once per (model × function) pair.
|
||||
for function_id in all_function_ids:
|
||||
get_function_module_from_cache(request, function_id)
|
||||
try:
|
||||
get_function_module_from_cache(request, function_id)
|
||||
except Exception as e:
|
||||
log.info(f"Failed to load function module for {function_id}: {e}")
|
||||
|
||||
# Apply global model defaults to all models
|
||||
# Per-model overrides take precedence over global defaults
|
||||
default_metadata = (
|
||||
getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {}
|
||||
)
|
||||
|
||||
if default_metadata:
|
||||
for model in models:
|
||||
info = model.get("info")
|
||||
|
||||
if info is None:
|
||||
model["info"] = {"meta": copy.deepcopy(default_metadata)}
|
||||
continue
|
||||
|
||||
meta = info.setdefault("meta", {})
|
||||
for key, value in default_metadata.items():
|
||||
if key == "capabilities":
|
||||
# Merge capabilities: defaults as base, per-model overrides win
|
||||
existing = meta.get("capabilities") or {}
|
||||
meta["capabilities"] = {**value, **existing}
|
||||
elif meta.get(key) is None:
|
||||
meta[key] = copy.deepcopy(value)
|
||||
|
||||
# Batch-fetch all function valves in one query to avoid N+1 DB hits
|
||||
# inside get_action_priority (previously called per action × per model).
|
||||
all_function_valves = Functions.get_function_valves_by_ids(list(all_function_ids))
|
||||
|
||||
def get_action_priority(action_id):
|
||||
try:
|
||||
function_module = request.app.state.FUNCTIONS.get(action_id)
|
||||
if function_module and hasattr(function_module, "Valves"):
|
||||
valves_db = all_function_valves.get(action_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, "priority", 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
for model in models:
|
||||
action_ids = [
|
||||
@@ -310,6 +353,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
for action_id in list(set(model.pop("action_ids", []) + global_action_ids))
|
||||
if action_id in enabled_action_ids
|
||||
]
|
||||
action_ids.sort(key=lambda aid: (get_action_priority(aid), aid))
|
||||
|
||||
filter_ids = [
|
||||
filter_id
|
||||
for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids))
|
||||
@@ -320,9 +365,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
for action_id in action_ids:
|
||||
action_function = functions_by_id.get(action_id)
|
||||
if action_function is None:
|
||||
raise Exception(f"Action not found: {action_id}")
|
||||
log.info(f"Action not found: {action_id}")
|
||||
continue
|
||||
|
||||
function_module = request.app.state.FUNCTIONS.get(action_id)
|
||||
if function_module is None:
|
||||
log.info(f"Failed to load action module: {action_id}")
|
||||
continue
|
||||
model["actions"].extend(
|
||||
get_action_items_from_module(action_function, function_module)
|
||||
)
|
||||
@@ -331,10 +380,13 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
for filter_id in filter_ids:
|
||||
filter_function = functions_by_id.get(filter_id)
|
||||
if filter_function is None:
|
||||
raise Exception(f"Filter not found: {filter_id}")
|
||||
log.info(f"Filter not found: {filter_id}")
|
||||
continue
|
||||
|
||||
function_module = request.app.state.FUNCTIONS.get(filter_id)
|
||||
|
||||
if function_module is None:
|
||||
log.info(f"Failed to load filter module: {filter_id}")
|
||||
continue
|
||||
if getattr(function_module, "toggle", None):
|
||||
model["filters"].extend(
|
||||
get_filter_items_from_module(filter_function, function_module)
|
||||
@@ -385,11 +437,13 @@ def get_filtered_models(models, user, db=None):
|
||||
user.role == "user"
|
||||
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
) and not BYPASS_MODEL_ACCESS_CONTROL:
|
||||
model_ids = [model["id"] for model in models if not model.get("arena")]
|
||||
model_infos = {
|
||||
model_info.id: model_info
|
||||
for model_info in Models.get_models_by_ids(model_ids)
|
||||
}
|
||||
model_infos = {}
|
||||
for model in models:
|
||||
if model.get("arena"):
|
||||
continue
|
||||
info = model.get("info")
|
||||
if info:
|
||||
model_infos[model["id"]] = info
|
||||
|
||||
user_group_ids = {
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id, db=db)
|
||||
@@ -419,12 +473,12 @@ def get_filtered_models(models, user, db=None):
|
||||
filtered_models.append(model)
|
||||
continue
|
||||
|
||||
model_info = model_infos.get(model["id"], None)
|
||||
model_info = model_infos.get(model["id"])
|
||||
if model_info:
|
||||
if (
|
||||
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model_info.user_id
|
||||
or model_info.id in accessible_model_ids
|
||||
or user.id == model_info.get("user_id")
|
||||
or model["id"] in accessible_model_ids
|
||||
):
|
||||
filtered_models.append(model)
|
||||
|
||||
|
||||
@@ -36,11 +36,13 @@ from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupF
|
||||
from open_webui.config import (
|
||||
DEFAULT_USER_ROLE,
|
||||
ENABLE_OAUTH_SIGNUP,
|
||||
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
||||
OAUTH_PROVIDERS,
|
||||
ENABLE_OAUTH_ROLE_MANAGEMENT,
|
||||
ENABLE_OAUTH_GROUP_MANAGEMENT,
|
||||
ENABLE_OAUTH_GROUP_CREATION,
|
||||
OAUTH_GROUP_DEFAULT_SHARE,
|
||||
OAUTH_BLOCKED_GROUPS,
|
||||
OAUTH_GROUPS_SEPARATOR,
|
||||
OAUTH_ROLES_SEPARATOR,
|
||||
@@ -54,6 +56,8 @@ from open_webui.config import (
|
||||
OAUTH_ADMIN_ROLES,
|
||||
OAUTH_ALLOWED_DOMAINS,
|
||||
OAUTH_UPDATE_PICTURE_ON_LOGIN,
|
||||
OAUTH_UPDATE_NAME_ON_LOGIN,
|
||||
OAUTH_UPDATE_EMAIL_ON_LOGIN,
|
||||
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
|
||||
OAUTH_AUDIENCE,
|
||||
WEBHOOK_URL,
|
||||
@@ -69,6 +73,7 @@ from open_webui.env import (
|
||||
ENABLE_OAUTH_ID_TOKEN_COOKIE,
|
||||
ENABLE_OAUTH_EMAIL_FALLBACK,
|
||||
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
|
||||
OAUTH_MAX_SESSIONS_PER_USER,
|
||||
)
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.auth import get_password_hash, create_token
|
||||
@@ -109,10 +114,14 @@ log = logging.getLogger(__name__)
|
||||
auth_manager_config = AppConfig()
|
||||
auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
|
||||
auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
|
||||
auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = (
|
||||
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
|
||||
)
|
||||
auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
|
||||
auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
|
||||
auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
|
||||
auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
|
||||
auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE = OAUTH_GROUP_DEFAULT_SHARE
|
||||
auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
|
||||
auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
|
||||
auth_manager_config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM
|
||||
@@ -126,6 +135,8 @@ auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
|
||||
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
|
||||
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
|
||||
auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN = OAUTH_UPDATE_NAME_ON_LOGIN
|
||||
auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN
|
||||
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
|
||||
|
||||
|
||||
@@ -780,6 +791,16 @@ class OAuthClientManager:
|
||||
if hasattr(client, "client_secret") and client.client_secret:
|
||||
refresh_data["client_secret"] = client.client_secret
|
||||
|
||||
# Add scope if available in client kwargs (some providers require it on refresh)
|
||||
if (
|
||||
hasattr(client, "client_kwargs")
|
||||
and client.client_kwargs.get("scope")
|
||||
and getattr(
|
||||
self.app.state.config, "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", False
|
||||
)
|
||||
):
|
||||
refresh_data["scope"] = client.client_kwargs["scope"]
|
||||
|
||||
# Make refresh request
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.post(
|
||||
@@ -1074,6 +1095,14 @@ class OAuthManager:
|
||||
if hasattr(client, "client_secret") and client.client_secret:
|
||||
refresh_data["client_secret"] = client.client_secret
|
||||
|
||||
# Add scope if available in client kwargs (some providers require it on refresh)
|
||||
if (
|
||||
hasattr(client, "client_kwargs")
|
||||
and client.client_kwargs.get("scope")
|
||||
and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE
|
||||
):
|
||||
refresh_data["scope"] = client.client_kwargs["scope"]
|
||||
|
||||
# Make refresh request
|
||||
async with aiohttp.ClientSession(trust_env=True) as session_http:
|
||||
async with session_http.post(
|
||||
@@ -1245,7 +1274,11 @@ class OAuthManager:
|
||||
name=group_name,
|
||||
description=f"Group '{group_name}' created automatically via OAuth.",
|
||||
permissions=default_permissions, # Use default permissions from function args
|
||||
user_ids=[], # Start with no users, user will be added later by subsequent logic
|
||||
data={
|
||||
"config": {
|
||||
"share": auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE
|
||||
}
|
||||
},
|
||||
)
|
||||
# Use determined creator ID (admin or fallback to current user)
|
||||
created_group = Groups.insert_new_group(
|
||||
@@ -1541,6 +1574,33 @@ class OAuthManager:
|
||||
# Update the user object in memory as well,
|
||||
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
|
||||
user.role = determined_role
|
||||
|
||||
if auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN:
|
||||
username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
|
||||
if username_claim:
|
||||
new_name = user_data.get(username_claim)
|
||||
if new_name and new_name != user.name:
|
||||
Users.update_user_by_id(user.id, {"name": new_name}, db=db)
|
||||
user.name = new_name
|
||||
log.debug(f"Updated name for user {user.email}")
|
||||
|
||||
if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
|
||||
email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
|
||||
if email_claim:
|
||||
new_email = user_data.get(email_claim)
|
||||
if new_email and new_email.lower() != user.email.lower():
|
||||
existing_user = Users.get_user_by_email(new_email, db=db)
|
||||
if existing_user:
|
||||
log.error(
|
||||
f"Cannot update email to {new_email} for user {user.id} because it is already taken."
|
||||
)
|
||||
else:
|
||||
Auths.update_email_by_id(
|
||||
user.id, new_email.lower(), db=db
|
||||
)
|
||||
user.email = new_email.lower()
|
||||
log.debug(f"Updated email for user {user.id}")
|
||||
|
||||
# Update profile picture if enabled and different from current
|
||||
if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
@@ -1679,11 +1739,18 @@ class OAuthManager:
|
||||
if "expires_in" in token and "expires_at" not in token:
|
||||
token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
|
||||
|
||||
# Clean up any existing sessions for this user/provider first
|
||||
# Enforce max concurrent sessions per user/provider to prevent
|
||||
# unbounded growth while allowing multi-device usage
|
||||
sessions = OAuthSessions.get_sessions_by_user_id(user.id, db=db)
|
||||
for session in sessions:
|
||||
if session.provider == provider:
|
||||
OAuthSessions.delete_session_by_id(session.id, db=db)
|
||||
provider_sessions = sorted(
|
||||
[session for session in sessions if session.provider == provider],
|
||||
key=lambda session: session.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
# Keep the newest sessions up to the limit, prune the rest
|
||||
if len(provider_sessions) >= OAUTH_MAX_SESSIONS_PER_USER:
|
||||
for old_session in provider_sessions[OAUTH_MAX_SESSIONS_PER_USER - 1 :]:
|
||||
OAuthSessions.delete_session_by_id(old_session.id, db=db)
|
||||
|
||||
session = OAuthSessions.create_session(
|
||||
user_id=user.id,
|
||||
@@ -1692,17 +1759,22 @@ class OAuthManager:
|
||||
db=db,
|
||||
)
|
||||
|
||||
response.set_cookie(
|
||||
key="oauth_session_id",
|
||||
value=session.id,
|
||||
httponly=True,
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
if session:
|
||||
response.set_cookie(
|
||||
key="oauth_session_id",
|
||||
value=session.id,
|
||||
httponly=True,
|
||||
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
secure=WEBUI_AUTH_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
|
||||
)
|
||||
log.info(
|
||||
f"Stored OAuth session server-side for user {user.id}, provider {provider}"
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
f"Failed to create OAuth session for user {user.id}, provider {provider}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f"Failed to store OAuth session server-side: {e}")
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict:
|
||||
ollama_root_params = {
|
||||
"format": lambda x: parse_json(x),
|
||||
"keep_alive": lambda x: parse_json(x),
|
||||
"think": bool,
|
||||
"think": lambda x: x,
|
||||
}
|
||||
|
||||
for key, value in ollama_root_params.items():
|
||||
@@ -326,7 +326,7 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
|
||||
ollama_root_params = {
|
||||
"format": lambda x: parse_json(x),
|
||||
"keep_alive": lambda x: parse_json(x),
|
||||
"think": bool,
|
||||
"think": lambda x: x,
|
||||
}
|
||||
|
||||
# Ollama's options field can contain parameters that should be at the root level.
|
||||
|
||||
@@ -8,7 +8,12 @@ import tempfile
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import PIP_OPTIONS, PIP_PACKAGE_INDEX_OPTIONS, OFFLINE_MODE
|
||||
from open_webui.env import (
|
||||
PIP_OPTIONS,
|
||||
PIP_PACKAGE_INDEX_OPTIONS,
|
||||
OFFLINE_MODE,
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
|
||||
)
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.tools import Tools
|
||||
|
||||
@@ -401,6 +406,12 @@ def get_function_module_from_cache(request, function_id, load_from_db=True):
|
||||
|
||||
|
||||
def install_frontmatter_requirements(requirements: str):
|
||||
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
|
||||
log.info(
|
||||
"ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements."
|
||||
)
|
||||
return
|
||||
|
||||
if OFFLINE_MODE:
|
||||
log.info("Offline mode enabled, skipping installation of requirements.")
|
||||
return
|
||||
|
||||
@@ -144,6 +144,7 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
|
||||
|
||||
|
||||
async def convert_streaming_response_ollama_to_openai(ollama_streaming_response):
|
||||
has_tool_calls = False
|
||||
async for data in ollama_streaming_response.body_iterator:
|
||||
data = json.loads(data)
|
||||
|
||||
@@ -155,6 +156,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
||||
|
||||
if tool_calls:
|
||||
openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls)
|
||||
has_tool_calls = True
|
||||
|
||||
done = data.get("done", False)
|
||||
|
||||
@@ -166,7 +168,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
|
||||
model, message_content, reasoning_content, openai_tool_calls, usage
|
||||
)
|
||||
|
||||
if done and openai_tool_calls:
|
||||
if done and has_tool_calls:
|
||||
data["choices"][0]["finish_reason"] = "tool_calls"
|
||||
|
||||
line = f"data: {json.dumps(data)}\n\n"
|
||||
|
||||
@@ -28,6 +28,7 @@ def set_security_headers() -> Dict[str, str]:
|
||||
- x-frame-options
|
||||
- x-permitted-cross-domain-policies
|
||||
- content-security-policy
|
||||
- reporting-endpoints
|
||||
|
||||
Each environment variable is associated with a specific setter function
|
||||
that constructs the header. If the environment variable is set, the
|
||||
@@ -47,6 +48,7 @@ def set_security_headers() -> Dict[str, str]:
|
||||
"XFRAME_OPTIONS": set_xframe,
|
||||
"XPERMITTED_CROSS_DOMAIN_POLICIES": set_xpermitted_cross_domain_policies,
|
||||
"CONTENT_SECURITY_POLICY": set_content_security_policy,
|
||||
"REPORTING_ENDPOINTS": set_reporting_endpoints,
|
||||
}
|
||||
|
||||
for env_var, setter in header_setters.items():
|
||||
@@ -131,3 +133,8 @@ def set_xpermitted_cross_domain_policies(value: str):
|
||||
# Set Content-Security-Policy response header
|
||||
def set_content_security_policy(value: str):
|
||||
return {"Content-Security-Policy": value}
|
||||
|
||||
|
||||
# Set Reporting-Endpoints response header
|
||||
def set_reporting_endpoints(value: str):
|
||||
return {"Reporting-Endpoints": value}
|
||||
|
||||
@@ -20,6 +20,7 @@ from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||
from opentelemetry.instrumentation.requests import RequestsInstrumentor
|
||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
|
||||
from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor
|
||||
from opentelemetry.trace import Span, StatusCode
|
||||
from redis import Redis
|
||||
from redis.cluster import RedisCluster
|
||||
@@ -204,6 +205,7 @@ class Instrumentor(BaseInstrumentor):
|
||||
request_hook=aiohttp_request_hook,
|
||||
response_hook=aiohttp_response_hook,
|
||||
)
|
||||
SystemMetricsInstrumentor().instrument()
|
||||
|
||||
def _uninstrument(self, **kwargs):
|
||||
if getattr(self, "instrumentors", None) is None:
|
||||
|
||||
@@ -120,12 +120,12 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
|
||||
# Instruments
|
||||
request_counter = meter.create_counter(
|
||||
name="http.server.requests",
|
||||
description="Total HTTP requests",
|
||||
description="Counts the total number of inbound HTTP requests.",
|
||||
unit="1",
|
||||
)
|
||||
duration_histogram = meter.create_histogram(
|
||||
name="http.server.duration",
|
||||
description="HTTP request duration",
|
||||
description="Measures the duration of inbound HTTP requests.",
|
||||
unit="ms",
|
||||
)
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ from open_webui.models.users import UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.plugin import load_tool_module_by_id
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.utils.access_control import has_access, has_connection_access
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
@@ -60,6 +60,8 @@ from open_webui.tools.builtin import (
|
||||
search_memories,
|
||||
add_memory,
|
||||
replace_memory_content,
|
||||
delete_memory,
|
||||
list_memories,
|
||||
get_current_timestamp,
|
||||
calculate_timestamp,
|
||||
search_notes,
|
||||
@@ -77,6 +79,7 @@ from open_webui.tools.builtin import (
|
||||
query_knowledge_bases,
|
||||
search_knowledge_files,
|
||||
query_knowledge_files,
|
||||
view_file,
|
||||
view_knowledge_file,
|
||||
view_skill,
|
||||
)
|
||||
@@ -141,25 +144,13 @@ def get_updated_tool_function(function: Callable, extra_params: dict):
|
||||
return function
|
||||
|
||||
|
||||
def has_tool_server_access(
|
||||
user: UserModel, server_connection: dict, user_group_ids: set = None
|
||||
) -> bool:
|
||||
"""Check if user has access to a tool server (MCP or OpenAPI)."""
|
||||
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return True
|
||||
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
|
||||
server_config = server_connection.get("config", {})
|
||||
access_grants = server_config.get("access_grants", [])
|
||||
return has_access(user.id, "read", access_grants, user_group_ids)
|
||||
|
||||
|
||||
async def get_tools(
|
||||
request: Request, tool_ids: list[str], user: UserModel, extra_params: dict
|
||||
) -> dict[str, dict]:
|
||||
"""Load tools for the given tool_ids, checking access control."""
|
||||
if not tool_ids:
|
||||
return {}
|
||||
|
||||
tools_dict = {}
|
||||
|
||||
# Get user's group memberships for access control checks
|
||||
@@ -291,7 +282,7 @@ async def get_tools(
|
||||
)
|
||||
|
||||
# Check access control for tool server
|
||||
if not has_tool_server_access(
|
||||
if not has_connection_access(
|
||||
user, tool_server_connection, user_group_ids
|
||||
):
|
||||
log.warning(
|
||||
@@ -388,7 +379,7 @@ async def get_tools(
|
||||
tool_dict = {
|
||||
"tool_id": tool_id,
|
||||
"callable": callable,
|
||||
"spec": spec,
|
||||
"spec": clean_openai_tool_schema(spec),
|
||||
# Misc info
|
||||
"type": "external",
|
||||
}
|
||||
@@ -441,10 +432,20 @@ def get_builtin_tools(
|
||||
# If model has attached knowledge (any type), only provide query_knowledge_files
|
||||
# Otherwise, provide all KB browsing tools
|
||||
model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", [])
|
||||
# Merge folder-attached knowledge so builtin tools can search it
|
||||
folder_knowledge = extra_params.get("__metadata__", {}).get("folder_knowledge")
|
||||
if folder_knowledge:
|
||||
model_knowledge = list(model_knowledge or []) + list(folder_knowledge)
|
||||
if is_builtin_tool_enabled("knowledge"):
|
||||
if model_knowledge:
|
||||
# Model has attached knowledge - only allow semantic search within it
|
||||
builtin_functions.append(query_knowledge_files)
|
||||
|
||||
knowledge_types = {item.get("type") for item in model_knowledge}
|
||||
if "file" in knowledge_types or "collection" in knowledge_types:
|
||||
builtin_functions.append(view_file)
|
||||
if "note" in knowledge_types:
|
||||
builtin_functions.append(view_note)
|
||||
else:
|
||||
# No model knowledge - allow full KB browsing
|
||||
builtin_functions.extend(
|
||||
@@ -464,7 +465,15 @@ def get_builtin_tools(
|
||||
|
||||
# Add memory tools if builtin category enabled AND enabled for this chat
|
||||
if is_builtin_tool_enabled("memory") and features.get("memory"):
|
||||
builtin_functions.extend([search_memories, add_memory, replace_memory_content])
|
||||
builtin_functions.extend(
|
||||
[
|
||||
search_memories,
|
||||
add_memory,
|
||||
replace_memory_content,
|
||||
delete_memory,
|
||||
list_memories,
|
||||
]
|
||||
)
|
||||
|
||||
# Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability
|
||||
if (
|
||||
@@ -543,6 +552,7 @@ def get_builtin_tools(
|
||||
# Generate spec from function
|
||||
pydantic_model = convert_function_to_pydantic_model(func)
|
||||
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
|
||||
spec = clean_openai_tool_schema(spec)
|
||||
|
||||
tools_dict[func.__name__] = {
|
||||
"tool_id": f"builtin:{func.__name__}",
|
||||
@@ -651,6 +661,44 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
|
||||
return model
|
||||
|
||||
|
||||
def clean_properties(schema: dict):
|
||||
if not isinstance(schema, dict):
|
||||
return
|
||||
|
||||
if "anyOf" in schema:
|
||||
non_null_types = [t for t in schema["anyOf"] if t.get("type") != "null"]
|
||||
if len(non_null_types) == 1:
|
||||
schema.update(non_null_types[0])
|
||||
del schema["anyOf"]
|
||||
else:
|
||||
schema["anyOf"] = non_null_types
|
||||
|
||||
if "default" in schema and schema["default"] is None:
|
||||
del schema["default"]
|
||||
|
||||
# fix missing type
|
||||
if "type" not in schema and "anyOf" not in schema and "properties" not in schema:
|
||||
schema["type"] = "string"
|
||||
|
||||
if "properties" in schema:
|
||||
for prop_name, prop_schema in schema["properties"].items():
|
||||
clean_properties(prop_schema)
|
||||
|
||||
if "items" in schema:
|
||||
clean_properties(schema["items"])
|
||||
|
||||
|
||||
def clean_openai_tool_schema(spec: dict) -> dict:
|
||||
import copy
|
||||
|
||||
cleaned_spec = copy.deepcopy(spec)
|
||||
|
||||
if "parameters" in cleaned_spec:
|
||||
clean_properties(cleaned_spec["parameters"])
|
||||
|
||||
return cleaned_spec
|
||||
|
||||
|
||||
def get_functions_from_tool(tool: object) -> list[Callable]:
|
||||
return [
|
||||
getattr(tool, func)
|
||||
@@ -673,7 +721,9 @@ def get_tool_specs(tool_module: object) -> list[dict]:
|
||||
)
|
||||
|
||||
specs = [
|
||||
convert_pydantic_model_to_openai_function_spec(function_model)
|
||||
clean_openai_tool_schema(
|
||||
convert_pydantic_model_to_openai_function_spec(function_model)
|
||||
)
|
||||
for function_model in function_models
|
||||
]
|
||||
|
||||
@@ -749,7 +799,7 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
f". Possible values: {', '.join(param_schema.get('enum'))}"
|
||||
)
|
||||
param_property = {
|
||||
"type": param_schema.get("type"),
|
||||
"type": param_schema.get("type") or "string",
|
||||
"description": description,
|
||||
}
|
||||
|
||||
@@ -757,6 +807,11 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
if param_schema.get("type") == "array" and "items" in param_schema:
|
||||
param_property["items"] = param_schema["items"]
|
||||
|
||||
# Filter out None values to prevent schema validation errors
|
||||
param_property = {
|
||||
k: v for k, v in param_property.items() if v is not None
|
||||
}
|
||||
|
||||
tool["parameters"]["properties"][param_name] = param_property
|
||||
if param.get("required"):
|
||||
tool["parameters"]["required"].append(param_name)
|
||||
@@ -820,6 +875,180 @@ async def get_tool_servers(request: Request):
|
||||
return tool_servers
|
||||
|
||||
|
||||
async def get_terminal_cwd(
|
||||
base_url: str,
|
||||
headers: dict,
|
||||
cookies: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""Fetch the current working directory from a terminal server."""
|
||||
try:
|
||||
cwd_url = f"{base_url.rstrip('/')}/files/cwd"
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
trust_env=True,
|
||||
) as session:
|
||||
async with session.get(
|
||||
cwd_url, headers=headers, cookies=cookies or {}
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data.get("cwd")
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to fetch terminal CWD: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def set_terminal_servers(request: Request):
|
||||
"""Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS."""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
|
||||
# Build server configs compatible with get_tool_servers_data
|
||||
# Terminal connections store id/name at top level; translate to info dict
|
||||
server_configs = []
|
||||
for connection in connections:
|
||||
if not connection.get("url"):
|
||||
continue
|
||||
|
||||
enabled = connection.get("enabled", True)
|
||||
|
||||
server_configs.append(
|
||||
{
|
||||
"url": connection.get("url", ""),
|
||||
"key": connection.get("key", ""),
|
||||
"auth_type": connection.get("auth_type", "bearer"),
|
||||
"path": connection.get("path", "/openapi.json"),
|
||||
"spec_type": "url",
|
||||
# get_tool_servers_data reads config.enable to filter active servers
|
||||
"config": {"enable": enabled},
|
||||
"info": {
|
||||
"id": connection.get("id", ""),
|
||||
"name": connection.get("name", ""),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs)
|
||||
|
||||
if request.app.state.redis is not None:
|
||||
await request.app.state.redis.set(
|
||||
"terminal_servers", json.dumps(request.app.state.TERMINAL_SERVERS)
|
||||
)
|
||||
|
||||
return request.app.state.TERMINAL_SERVERS
|
||||
|
||||
|
||||
async def get_terminal_servers(request: Request):
|
||||
"""Return cached terminal server specs, loading if needed."""
|
||||
terminal_servers = []
|
||||
if request.app.state.redis is not None:
|
||||
try:
|
||||
terminal_servers = json.loads(
|
||||
await request.app.state.redis.get("terminal_servers")
|
||||
)
|
||||
request.app.state.TERMINAL_SERVERS = terminal_servers
|
||||
except Exception as e:
|
||||
log.error(f"Error fetching terminal_servers from Redis: {e}")
|
||||
|
||||
if not terminal_servers:
|
||||
terminal_servers = await set_terminal_servers(request)
|
||||
|
||||
return terminal_servers
|
||||
|
||||
|
||||
async def get_terminal_tools(
|
||||
request: Request,
|
||||
terminal_id: str,
|
||||
user: UserModel,
|
||||
extra_params: dict,
|
||||
) -> dict[str, dict]:
|
||||
"""Resolve tools for a terminal server identified by terminal_id.
|
||||
|
||||
- Finds the connection in TERMINAL_SERVER_CONNECTIONS
|
||||
- Checks access_grants
|
||||
- Loads specs from cache
|
||||
- Builds callables that route through the terminal proxy
|
||||
"""
|
||||
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
|
||||
connection = next((c for c in connections if c.get("id") == terminal_id), None)
|
||||
if connection is None:
|
||||
log.warning(f"Terminal server not found: {terminal_id}")
|
||||
return {}
|
||||
|
||||
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)}
|
||||
if not has_connection_access(user, connection, user_group_ids):
|
||||
log.warning(f"Access denied to terminal {terminal_id} for user {user.id}")
|
||||
return {}
|
||||
|
||||
# Find the cached spec data for this terminal
|
||||
terminal_servers = await get_terminal_servers(request)
|
||||
server_data = next(
|
||||
(s for s in terminal_servers if s.get("id") == terminal_id), None
|
||||
)
|
||||
if server_data is None:
|
||||
log.warning(f"Terminal server spec not found for {terminal_id}")
|
||||
return {}
|
||||
|
||||
specs = server_data.get("specs", [])
|
||||
if not specs:
|
||||
return {}
|
||||
|
||||
# Build auth headers
|
||||
auth_type = connection.get("auth_type", "bearer")
|
||||
cookies = {}
|
||||
headers = {"Content-Type": "application/json", "X-User-Id": user.id}
|
||||
|
||||
if auth_type == "bearer":
|
||||
headers["Authorization"] = f"Bearer {connection.get('key', '')}"
|
||||
elif auth_type == "session":
|
||||
cookies = request.cookies
|
||||
headers["Authorization"] = f"Bearer {request.state.token.credentials}"
|
||||
elif auth_type == "system_oauth":
|
||||
cookies = request.cookies
|
||||
oauth_token = extra_params.get("__oauth_token__", None)
|
||||
if oauth_token:
|
||||
headers["Authorization"] = f"Bearer {oauth_token.get('access_token', '')}"
|
||||
# auth_type == "none": no Authorization header
|
||||
|
||||
terminal_cwd = await get_terminal_cwd(connection.get("url", ""), headers, cookies)
|
||||
|
||||
tools_dict = {}
|
||||
for spec in specs:
|
||||
function_name = spec["name"]
|
||||
|
||||
# Inject CWD into run_command description
|
||||
tool_spec = clean_openai_tool_schema(spec)
|
||||
if function_name == "run_command" and terminal_cwd:
|
||||
tool_spec["description"] = (
|
||||
tool_spec.get("description", "")
|
||||
+ f"\n\nThe current working directory is: {terminal_cwd}"
|
||||
)
|
||||
|
||||
def make_tool_function(fn_name, srv_data, hdrs, cks):
|
||||
async def tool_function(**kwargs):
|
||||
return await execute_tool_server(
|
||||
url=srv_data["url"],
|
||||
headers=hdrs,
|
||||
cookies=cks,
|
||||
name=fn_name,
|
||||
params=kwargs,
|
||||
server_data=srv_data,
|
||||
)
|
||||
|
||||
return tool_function
|
||||
|
||||
tool_function = make_tool_function(function_name, server_data, headers, cookies)
|
||||
callable = get_async_tool_function_and_apply_extra_params(tool_function, {})
|
||||
|
||||
tools_dict[function_name] = {
|
||||
"tool_id": f"terminal:{terminal_id}",
|
||||
"callable": callable,
|
||||
"spec": tool_spec,
|
||||
"type": "terminal",
|
||||
}
|
||||
|
||||
return tools_dict
|
||||
|
||||
|
||||
async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]:
|
||||
_headers = {
|
||||
"Accept": "application/json",
|
||||
@@ -936,6 +1165,11 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
log.error(f"Failed to connect to {url} OpenAPI tool server")
|
||||
continue
|
||||
|
||||
# Guard against invalid or non-OpenAPI specs (e.g., MCP-style configs)
|
||||
if not isinstance(response, dict) or "paths" not in response:
|
||||
log.warning(f"Invalid OpenAPI spec from {url}: missing 'paths'")
|
||||
continue
|
||||
|
||||
response = {
|
||||
"openapi": response,
|
||||
"info": response.get("info", {}),
|
||||
@@ -956,7 +1190,7 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
|
||||
{
|
||||
"id": str(id),
|
||||
"idx": idx,
|
||||
"url": server.get("url"),
|
||||
"url": (server.get("url") or "").rstrip("/"),
|
||||
"openapi": openapi_data,
|
||||
"info": response.get("info"),
|
||||
"specs": response.get("specs"),
|
||||
@@ -1017,9 +1251,10 @@ async def execute_tool_server(
|
||||
if param_in == "path":
|
||||
path_params[param_name] = params[param_name]
|
||||
elif param_in == "query":
|
||||
query_params[param_name] = params[param_name]
|
||||
if params[param_name] is not None:
|
||||
query_params[param_name] = params[param_name]
|
||||
|
||||
final_url = f"{url}{route_path}"
|
||||
final_url = f"{url.rstrip('/')}{route_path}"
|
||||
for key, value in path_params.items():
|
||||
final_url = final_url.replace(f"{{{key}}}", str(value))
|
||||
|
||||
@@ -1089,6 +1324,8 @@ def get_tool_server_url(url: Optional[str], path: str) -> str:
|
||||
if "://" in path:
|
||||
# If it contains "://", it's a full URL
|
||||
return path
|
||||
if url:
|
||||
url = url.rstrip("/")
|
||||
if not path.startswith("/"):
|
||||
# Ensure the path starts with a slash
|
||||
path = f"/{path}"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Minimal requirements for backend to run
|
||||
# WIP: use this as a reference to build a minimal docker image
|
||||
|
||||
fastapi==0.128.5
|
||||
uvicorn[standard]==0.40.0
|
||||
fastapi==0.135.1
|
||||
uvicorn[standard]==0.41.0
|
||||
pydantic==2.12.5
|
||||
python-multipart==0.0.22
|
||||
itsdangerous==2.2.0
|
||||
@@ -13,7 +13,7 @@ cryptography
|
||||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.11.0
|
||||
authlib==1.6.7
|
||||
authlib==1.6.9
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
@@ -25,12 +25,12 @@ Brotli==1.1.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
|
||||
sqlalchemy==2.0.46
|
||||
alembic==1.18.3
|
||||
sqlalchemy==2.0.48
|
||||
alembic==1.18.4
|
||||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.46
|
||||
pycrdt==0.12.47
|
||||
redis
|
||||
|
||||
APScheduler==3.11.2
|
||||
@@ -42,14 +42,15 @@ asgiref==3.11.1
|
||||
mcp==1.26.0
|
||||
openai
|
||||
|
||||
langchain==1.2.9
|
||||
langchain==1.2.10
|
||||
langchain-community==0.4.1
|
||||
langchain-classic==1.0.1
|
||||
langchain-text-splitters==1.1.0
|
||||
langchain-text-splitters==1.1.1
|
||||
|
||||
fake-useragent==2.2.0
|
||||
|
||||
chromadb==1.4.1
|
||||
chromadb==1.5.2
|
||||
black==26.1.0
|
||||
pydub
|
||||
chardet==5.2.0
|
||||
beautifulsoup4
|
||||
|
||||
+40
-39
@@ -1,5 +1,5 @@
|
||||
fastapi==0.128.5
|
||||
uvicorn[standard]==0.40.0
|
||||
fastapi==0.135.1
|
||||
uvicorn[standard]==0.41.0
|
||||
pydantic==2.12.5
|
||||
python-multipart==0.0.22
|
||||
itsdangerous==2.2.0
|
||||
@@ -10,7 +10,7 @@ cryptography
|
||||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.11.0
|
||||
authlib==1.6.7
|
||||
authlib==1.6.9
|
||||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
@@ -23,17 +23,17 @@ httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
starsessions[redis]==2.2.1
|
||||
python-mimeparse==2.0.0
|
||||
|
||||
sqlalchemy==2.0.46
|
||||
alembic==1.18.3
|
||||
sqlalchemy==2.0.48
|
||||
alembic==1.18.4
|
||||
peewee==3.19.0
|
||||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.46
|
||||
pycrdt==0.12.47
|
||||
redis
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.1
|
||||
pytz==2025.2
|
||||
pytz==2026.1.post1
|
||||
|
||||
loguru==0.7.3
|
||||
asgiref==3.11.1
|
||||
@@ -44,37 +44,38 @@ mcp==1.26.0
|
||||
|
||||
openai
|
||||
anthropic
|
||||
google-genai==1.62.0
|
||||
google-genai==1.66.0
|
||||
|
||||
langchain==1.2.9
|
||||
langchain==1.2.10
|
||||
langchain-community==0.4.1
|
||||
langchain-classic==1.0.1
|
||||
langchain-text-splitters==1.1.0
|
||||
langchain-text-splitters==1.1.1
|
||||
|
||||
fake-useragent==2.2.0
|
||||
chromadb==1.4.1
|
||||
weaviate-client==4.19.2
|
||||
chromadb==1.5.2
|
||||
weaviate-client==4.20.3
|
||||
opensearch-py==3.1.0
|
||||
|
||||
transformers==5.1.0
|
||||
sentence-transformers==5.2.2
|
||||
transformers==5.3.0
|
||||
sentence-transformers==5.2.3
|
||||
accelerate
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.2
|
||||
|
||||
ftfy==6.3.1
|
||||
chardet==5.2.0
|
||||
pypdf==6.7.0
|
||||
fpdf2==2.8.5
|
||||
pymdown-extensions==10.20.1
|
||||
pypdf==6.7.5
|
||||
fpdf2==2.8.7
|
||||
pymdown-extensions==10.21
|
||||
docx2txt==0.9
|
||||
python-pptx==1.0.2
|
||||
unstructured==0.18.31
|
||||
msoffcrypto-tool==6.0.0
|
||||
nltk==3.9.2
|
||||
Markdown==3.10.1
|
||||
nltk==3.9.3
|
||||
Markdown==3.10.2
|
||||
beautifulsoup4
|
||||
pypandoc==1.16.2
|
||||
pandas==3.0.0
|
||||
pandas==3.0.1
|
||||
openpyxl==3.1.5
|
||||
pyxlsb==1.0.10
|
||||
xlrd==2.0.2
|
||||
@@ -83,12 +84,12 @@ psutil
|
||||
sentencepiece
|
||||
soundfile==0.13.1
|
||||
|
||||
pillow==12.1.0
|
||||
pillow==12.1.1
|
||||
opencv-python-headless==4.13.0.92
|
||||
rapidocr-onnxruntime==1.4.4
|
||||
rank-bm25==0.2.2
|
||||
|
||||
onnxruntime==1.24.1
|
||||
onnxruntime==1.24.3
|
||||
faster-whisper==1.2.1
|
||||
|
||||
black==26.1.0
|
||||
@@ -96,10 +97,10 @@ youtube-transcript-api==1.2.4
|
||||
pytube==15.0.0
|
||||
|
||||
pydub
|
||||
ddgs==9.10.0
|
||||
ddgs==9.11.2
|
||||
|
||||
azure-ai-documentintelligence==1.0.2
|
||||
azure-identity==1.25.1
|
||||
azure-identity==1.25.2
|
||||
azure-storage-blob==12.28.0
|
||||
azure-search-documents==11.6.0
|
||||
|
||||
@@ -117,10 +118,10 @@ psycopg2-binary==2.9.11
|
||||
pgvector==0.4.2
|
||||
|
||||
PyMySQL==1.1.2
|
||||
boto3==1.42.44
|
||||
boto3==1.42.62
|
||||
|
||||
pymilvus==2.6.8
|
||||
qdrant-client==1.16.2
|
||||
pymilvus==2.6.9
|
||||
qdrant-client==1.17.0
|
||||
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
elasticsearch==9.3.0
|
||||
pinecone==6.0.2
|
||||
@@ -140,17 +141,17 @@ pytest-docker~=3.2.5
|
||||
ldap3==2.9.1
|
||||
|
||||
## Firecrawl
|
||||
firecrawl-py==4.14.0
|
||||
firecrawl-py==4.18.0
|
||||
|
||||
## Trace
|
||||
opentelemetry-api==1.39.1
|
||||
opentelemetry-sdk==1.39.1
|
||||
opentelemetry-exporter-otlp==1.39.1
|
||||
opentelemetry-instrumentation==0.60b1
|
||||
opentelemetry-instrumentation-fastapi==0.60b1
|
||||
opentelemetry-instrumentation-sqlalchemy==0.60b1
|
||||
opentelemetry-instrumentation-redis==0.60b1
|
||||
opentelemetry-instrumentation-requests==0.60b1
|
||||
opentelemetry-instrumentation-logging==0.60b1
|
||||
opentelemetry-instrumentation-httpx==0.60b1
|
||||
opentelemetry-instrumentation-aiohttp-client==0.60b1
|
||||
opentelemetry-api==1.40.0
|
||||
opentelemetry-sdk==1.40.0
|
||||
opentelemetry-exporter-otlp==1.40.0
|
||||
opentelemetry-instrumentation==0.61b0
|
||||
opentelemetry-instrumentation-fastapi==0.61b0
|
||||
opentelemetry-instrumentation-sqlalchemy==0.61b0
|
||||
opentelemetry-instrumentation-redis==0.61b0
|
||||
opentelemetry-instrumentation-requests==0.61b0
|
||||
opentelemetry-instrumentation-logging==0.61b0
|
||||
opentelemetry-instrumentation-httpx==0.61b0
|
||||
opentelemetry-instrumentation-aiohttp-client==0.61b0
|
||||
|
||||
+29
-22
@@ -26,33 +26,40 @@ We appreciate the community's interest in identifying potential vulnerabilities.
|
||||
|
||||
2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
|
||||
|
||||
3. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts.
|
||||
3. **In-Depth Understanding**: Reports must reflect a clear understanding of the codebase, how Open WebUI is used and provide specific details about the vulnerability, including the affected components and potential impacts.
|
||||
|
||||
4. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence may be disregarded.
|
||||
|
||||
> [!NOTE]
|
||||
> A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show:
|
||||
>
|
||||
> 1. What security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
|
||||
> 2. How this vulnerability was abused
|
||||
> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
|
||||
> 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.)
|
||||
> 3. What actions the attacker can now perform
|
||||
>
|
||||
> **Examples of valid PoCs:**
|
||||
>
|
||||
> - Step-by-step reproduction instructions with exact commands
|
||||
> - Complete exploit code with detailed execution instructions
|
||||
> - Screenshots/videos demonstrating the exploit (supplementary to written steps)
|
||||
> 4. What data/action becomes possible that should not be possible
|
||||
> 5. Exact steps and commands to reproduce (copy/paste runnable where possible), expected result vs. actual result
|
||||
>
|
||||
> **Failure to provide a reproducible PoC may lead to closure of the report**
|
||||
>
|
||||
> We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC.
|
||||
> If we cannot reproduce the issue from your PoC, we may ask for clarification or improvements
|
||||
> However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed.
|
||||
|
||||
5. **Required Patch or Actionable Remediation Plan Submission**: Along with the PoC, reporters must provide a patch or some actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly.
|
||||
5. **Remediation is required**:
|
||||
|
||||
6. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider provided patches for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security.
|
||||
Along with the PoC, you must provide **either**:
|
||||
|
||||
7. **Default Configuration Testing**: All vulnerability reports MUST be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception:
|
||||
1. **A patch/PR**, **or**
|
||||
2. **a remediation plan** ("actionable steps") that a maintainer can apply without guesswork.
|
||||
|
||||
Your remediation guidance can include, for example:
|
||||
|
||||
- The **likely root cause** (what's wrong and where)
|
||||
- The **location(s)** to change (file/module/function names if known)
|
||||
- The **recommended fix approach** (validation/sanitization rules, auth checks, safe defaults, etc.)
|
||||
- Any **security tradeoffs** or potential regressions to watch for
|
||||
|
||||
6. **Default Configuration Testing**: All vulnerability reports must be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception:
|
||||
|
||||
> [!NOTE]
|
||||
> **Note**: If you believe you have found a security issue that
|
||||
@@ -61,26 +68,26 @@ We appreciate the community's interest in identifying potential vulnerabilities.
|
||||
> 2. represents a genuine bypass of intended security controls, **or**
|
||||
> 3. works only with non-default configurations, **but the configuration in question is likely to be used by production deployments**, **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research.
|
||||
|
||||
8. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection.
|
||||
7. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, extensible, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection.
|
||||
|
||||
9. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection.
|
||||
8. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection.
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report.
|
||||
|
||||
10. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.**
|
||||
9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.**
|
||||
|
||||
> [!NOTE]
|
||||
> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions,
|
||||
> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research.
|
||||
|
||||
11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it.
|
||||
10. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **you MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it.
|
||||
|
||||
> [!NOTE]
|
||||
> AI-aided vulnerability reports **will not be rejected by us by default**. But:
|
||||
>
|
||||
> - If we suspect you used AI (but you did not disclose it to us), we will be asking tough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself.
|
||||
> - If we suspect you used AI (but you did not disclose it to us), we will be asking thorough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself.
|
||||
> - If we suspect you used AI (but you did not disclose it to us) **and** your report ends up being invalid/not a vulnerability/not reproducible, then you **may be banned** from reporting future vulnerabilities.
|
||||
>
|
||||
> This measure was necessary due to the extreme rise in clearly AI written vulnerability reports, where the vast majority of them
|
||||
@@ -91,9 +98,9 @@ We appreciate the community's interest in identifying potential vulnerabilities.
|
||||
> - violated any of the rules outlined here
|
||||
> - had a clear lack of understanding of Open WebUI
|
||||
> - wrote comments with conflicting information
|
||||
> - used illogical arguments
|
||||
> - used illogical and conflicting arguments
|
||||
|
||||
**Non-compliant submissions will be closed, and repeat extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
|
||||
**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
|
||||
|
||||
## Where to report the vulnerability
|
||||
|
||||
@@ -119,12 +126,12 @@ If your concern does not meet the vulnerability requirements outlined above, is
|
||||
- Feature requests for optional security enhancements (2FA, audit logging, etc.)
|
||||
- General security questions about production deployment
|
||||
|
||||
Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the Documentation Repository, and feature requests into the Main Repository as an issue or discussion.
|
||||
Please use the adequate channel for your specific issue - e.g. best-practice guidance or **dditional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs)**, and **feature requests into the Main Repository as an issue or discussion**.
|
||||
|
||||
We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon.
|
||||
|
||||
For any other immediate concerns, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s).
|
||||
For any other immediate concerns and questions, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s).
|
||||
|
||||
---
|
||||
|
||||
_Last updated on **2025-11-06**._
|
||||
_Last updated on **2026-02-25**._
|
||||
|
||||
Generated
+570
-467
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.8.2",
|
||||
"version": "0.8.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
@@ -66,6 +66,7 @@
|
||||
"@sveltejs/svelte-virtual-list": "^3.0.1",
|
||||
"@tiptap/core": "^3.0.7",
|
||||
"@tiptap/extension-bubble-menu": "^2.26.1",
|
||||
"@tiptap/extension-code": "^3.0.7",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.0.7",
|
||||
"@tiptap/extension-drag-handle": "^3.4.5",
|
||||
"@tiptap/extension-file-handler": "^3.0.7",
|
||||
@@ -82,6 +83,9 @@
|
||||
"@tiptap/pm": "^3.0.7",
|
||||
"@tiptap/starter-kit": "^3.0.7",
|
||||
"@tiptap/suggestion": "^3.4.2",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/svelte": "^0.1.19",
|
||||
"alpinejs": "^3.15.0",
|
||||
"async": "^3.2.5",
|
||||
@@ -107,6 +111,7 @@
|
||||
"idb": "^7.1.1",
|
||||
"js-sha256": "^0.10.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.22",
|
||||
"kokoro-js": "^1.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -130,8 +135,10 @@
|
||||
"prosemirror-tables": "^1.7.1",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"pyodide": "^0.28.2",
|
||||
"shiki": "^4.0.1",
|
||||
"socket.io-client": "^4.2.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"sql.js": "^1.14.1",
|
||||
"svelte-sonner": "^0.3.19",
|
||||
"tippy.js": "^6.3.7",
|
||||
"turndown": "^7.2.0",
|
||||
|
||||
+28
-28
@@ -6,8 +6,8 @@ authors = [
|
||||
]
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"fastapi==0.128.5",
|
||||
"uvicorn[standard]==0.40.0",
|
||||
"fastapi==0.135.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"pydantic==2.12.5",
|
||||
"python-multipart==0.0.22",
|
||||
"itsdangerous==2.2.0",
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
"bcrypt==5.0.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.11.0",
|
||||
"authlib==1.6.7",
|
||||
"authlib==1.6.9",
|
||||
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
|
||||
@@ -31,15 +31,15 @@ dependencies = [
|
||||
"starsessions[redis]==2.2.1",
|
||||
"python-mimeparse==2.0.0",
|
||||
|
||||
"sqlalchemy==2.0.46",
|
||||
"alembic==1.18.3",
|
||||
"sqlalchemy==2.0.48",
|
||||
"alembic==1.18.4",
|
||||
"peewee==3.19.0",
|
||||
"peewee-migrate==1.14.3",
|
||||
|
||||
"pycrdt==0.12.46",
|
||||
"pycrdt==0.12.47",
|
||||
"redis",
|
||||
|
||||
"pytz==2025.2",
|
||||
"pytz==2026.1.post1",
|
||||
"APScheduler==3.11.2",
|
||||
"RestrictedPython==8.1",
|
||||
|
||||
@@ -51,38 +51,38 @@ dependencies = [
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google-genai==1.62.0",
|
||||
"google-genai==1.66.0",
|
||||
|
||||
"langchain==1.2.9",
|
||||
"langchain==1.2.10",
|
||||
"langchain-community==0.4.1",
|
||||
"langchain-classic==1.0.1",
|
||||
"langchain-text-splitters==1.1.0",
|
||||
"langchain-text-splitters==1.1.1",
|
||||
|
||||
"fake-useragent==2.2.0",
|
||||
"chromadb==1.4.1",
|
||||
"chromadb==1.5.2",
|
||||
"opensearch-py==3.1.0",
|
||||
"PyMySQL==1.1.2",
|
||||
"boto3==1.42.44",
|
||||
"boto3==1.42.62",
|
||||
|
||||
"transformers==5.1.0",
|
||||
"sentence-transformers==5.2.2",
|
||||
"transformers==5.3.0",
|
||||
"sentence-transformers==5.2.3",
|
||||
"accelerate",
|
||||
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
"einops==0.8.2",
|
||||
|
||||
"ftfy==6.3.1",
|
||||
"chardet==5.2.0",
|
||||
"pypdf==6.7.0",
|
||||
"fpdf2==2.8.5",
|
||||
"pymdown-extensions==10.20.1",
|
||||
"pypdf==6.7.5",
|
||||
"fpdf2==2.8.7",
|
||||
"pymdown-extensions==10.21",
|
||||
"docx2txt==0.9",
|
||||
"python-pptx==1.0.2",
|
||||
"unstructured==0.18.31",
|
||||
"msoffcrypto-tool==6.0.0",
|
||||
"nltk==3.9.2",
|
||||
"Markdown==3.10.1",
|
||||
"nltk==3.9.3",
|
||||
"Markdown==3.10.2",
|
||||
"pypandoc==1.16.2",
|
||||
"pandas==3.0.0",
|
||||
"pandas==3.0.1",
|
||||
"openpyxl==3.1.5",
|
||||
"pyxlsb==1.0.10",
|
||||
"xlrd==2.0.2",
|
||||
@@ -92,12 +92,12 @@ dependencies = [
|
||||
"soundfile==0.13.1",
|
||||
"azure-ai-documentintelligence==1.0.2",
|
||||
|
||||
"pillow==12.1.0",
|
||||
"pillow==12.1.1",
|
||||
"opencv-python-headless==4.13.0.92",
|
||||
"rapidocr-onnxruntime==1.4.4",
|
||||
"rank-bm25==0.2.2",
|
||||
|
||||
"onnxruntime==1.24.1",
|
||||
"onnxruntime==1.24.3",
|
||||
"faster-whisper==1.2.1",
|
||||
|
||||
"black==26.1.0",
|
||||
@@ -105,7 +105,7 @@ dependencies = [
|
||||
"pytube==15.0.0",
|
||||
|
||||
"pydub",
|
||||
"ddgs==9.10.0",
|
||||
"ddgs==9.11.2",
|
||||
|
||||
"google-api-python-client",
|
||||
"google-auth-httplib2",
|
||||
@@ -114,7 +114,7 @@ dependencies = [
|
||||
"googleapis-common-protos==1.72.0",
|
||||
"google-cloud-storage==3.9.0",
|
||||
|
||||
"azure-identity==1.25.1",
|
||||
"azure-identity==1.25.2",
|
||||
"azure-storage-blob==12.28.0",
|
||||
|
||||
"ldap3==2.9.1",
|
||||
@@ -150,15 +150,15 @@ all = [
|
||||
"playwright==1.58.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
|
||||
"elasticsearch==9.3.0",
|
||||
|
||||
"qdrant-client==1.16.2",
|
||||
"qdrant-client==1.17.0",
|
||||
|
||||
"weaviate-client==4.19.2",
|
||||
"pymilvus==2.6.8",
|
||||
"weaviate-client==4.20.3",
|
||||
"pymilvus==2.6.9",
|
||||
"pinecone==6.0.2",
|
||||
"oracledb==3.4.2",
|
||||
"colbert-ai==0.2.22",
|
||||
|
||||
"firecrawl-py==4.14.0",
|
||||
"firecrawl-py==4.18.0",
|
||||
"azure-search-documents==11.6.0",
|
||||
]
|
||||
|
||||
|
||||
+6
-12
@@ -332,12 +332,9 @@ input[type='number'] {
|
||||
}
|
||||
|
||||
.codespan {
|
||||
color: #eb5757;
|
||||
border-width: 0px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
@apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5;
|
||||
padding: 0.15rem 0.3rem;
|
||||
font-size: 0.85em;
|
||||
@apply font-mono rounded-md text-gray-800 bg-gray-100 dark:text-gray-200 dark:bg-gray-800 mx-0.5;
|
||||
}
|
||||
|
||||
.svelte-flow {
|
||||
@@ -566,12 +563,9 @@ input[type='number'] {
|
||||
}
|
||||
|
||||
.tiptap p code {
|
||||
color: #eb5757;
|
||||
border-width: 0px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
@apply rounded-md dark:bg-gray-800 bg-gray-50 mx-0.5;
|
||||
padding: 0.15rem 0.3rem;
|
||||
font-size: 0.85em;
|
||||
@apply font-mono rounded-md text-gray-800 bg-gray-50 dark:text-gray-200 dark:bg-gray-800 mx-0.5;
|
||||
}
|
||||
|
||||
/* Code styling */
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
|
||||
@@ -172,6 +172,63 @@ export const setToolServerConnections = async (token: string, connections: objec
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getTerminalServerConnections = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, {
|
||||
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.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const setTerminalServerConnections = async (token: string, connections: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...connections
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const verifyToolServerConnection = async (token: string, connection: object) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ type FolderForm = {
|
||||
name?: string;
|
||||
data?: Record<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
parent_id?: string | null;
|
||||
};
|
||||
|
||||
export const createNewFolder = async (token: string, folderForm: FolderForm) => {
|
||||
|
||||
@@ -241,7 +241,8 @@ export const imageEdits = async (
|
||||
prompt: string,
|
||||
model?: string,
|
||||
size?: string,
|
||||
n?: number
|
||||
n?: number,
|
||||
background?: string
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
@@ -258,7 +259,8 @@ export const imageEdits = async (
|
||||
prompt,
|
||||
...(model && { model }),
|
||||
...(size && { size }),
|
||||
...(n && { n })
|
||||
...(n && { n }),
|
||||
...(background && { background })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -281,7 +281,12 @@ export const updateModelById = async (token: string, id: string, model: object)
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateModelAccessGrants = async (token: string, id: string, accessGrants: any[]) => {
|
||||
export const updateModelAccessGrants = async (
|
||||
token: string,
|
||||
id: string,
|
||||
name: string,
|
||||
accessGrants: any[]
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/models/model/access/update`, {
|
||||
@@ -291,7 +296,7 @@ export const updateModelAccessGrants = async (token: string, id: string, accessG
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ id, access_grants: accessGrants })
|
||||
body: JSON.stringify({ id, name, access_grants: accessGrants })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
|
||||
@@ -395,6 +395,34 @@ export const setProductionPromptVersion = async (
|
||||
return res;
|
||||
};
|
||||
|
||||
export const togglePromptById = async (token: string, promptId: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/id/${promptId}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deletePromptById = async (token: string, promptId: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
export type FileEntry = {
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
modified?: number;
|
||||
};
|
||||
|
||||
export type ListeningPort = {
|
||||
port: number;
|
||||
pid: number | null;
|
||||
process: string | null;
|
||||
};
|
||||
|
||||
export type TerminalFeatures = {
|
||||
terminal?: boolean;
|
||||
};
|
||||
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export type TerminalServer = {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const getTerminalServers = async (token: string): Promise<TerminalServer[]> => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/terminals/`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return [];
|
||||
return res.json().catch(() => []);
|
||||
};
|
||||
|
||||
export const getTerminalConfig = async (
|
||||
baseUrl: string,
|
||||
apiKey: string
|
||||
): Promise<{ features: TerminalFeatures } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/api/config`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return null;
|
||||
return res.json().catch(() => null);
|
||||
};
|
||||
|
||||
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return null;
|
||||
const json = await res.json().catch(() => null);
|
||||
return json?.cwd ?? null;
|
||||
};
|
||||
|
||||
export const listFiles = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string = '/'
|
||||
): Promise<FileEntry[] | null> => {
|
||||
// The endpoint uses `directory` as the query param name
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal listFiles error:', err);
|
||||
return null;
|
||||
});
|
||||
return res?.entries ?? null;
|
||||
};
|
||||
|
||||
export const readFile = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<string | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch((err) => {
|
||||
console.error('open-terminal readFile error:', err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!res || !res.ok) return null;
|
||||
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
if (contentType.startsWith('image/') || contentType.startsWith('application/octet')) {
|
||||
// Binary — return a placeholder
|
||||
return `[Binary file: ${contentType}]`;
|
||||
}
|
||||
|
||||
// Text files: endpoint returns JSON { path, total_lines, content }
|
||||
// Binary image files: endpoint returns raw bytes (handled above)
|
||||
const json = await res.json().catch(() => null);
|
||||
return json?.content ?? null;
|
||||
};
|
||||
|
||||
export const downloadFileBlob = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ blob: Blob; filename: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res || !res.ok) return null;
|
||||
|
||||
const filename = path.split('/').pop() ?? 'file';
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const uploadToTerminal = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
directory: string,
|
||||
file: File
|
||||
): Promise<{ path: string; size: number } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal uploadToTerminal error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createDirectory = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ path: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal createDirectory error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteEntry = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ path: string; type: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal deleteEntry error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const setCwd = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ cwd: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal setCwd error:', err);
|
||||
return null;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const moveEntry = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
source: string,
|
||||
destination: string
|
||||
): Promise<{ source: string; destination: string } | { error: string }> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/move`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ source, destination })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal moveEntry error:', err);
|
||||
return { error: err?.detail ?? 'Move failed' };
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getListeningPorts = async (
|
||||
baseUrl: string,
|
||||
apiKey: string
|
||||
): Promise<ListeningPort[]> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/ports`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
if (!res || !res.ok) return [];
|
||||
const json = await res.json().catch(() => null);
|
||||
return json?.ports ?? [];
|
||||
};
|
||||
|
||||
export const getPortProxyUrl = (baseUrl: string, port: number, path: string = ''): string => {
|
||||
return `${baseUrl.replace(/\/$/, '')}/proxy/${port}/${path}`;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notebook execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createNotebookSession = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
): Promise<{ id: string; kernel: string; status: string } | { error: string }> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/notebooks`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return { error: body?.detail ?? `HTTP ${res.status}` };
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal createNotebookSession error:', err);
|
||||
return { error: 'Connection failed' };
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const executeNotebookCell = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
sessionId: string,
|
||||
cellIndex: number,
|
||||
source?: string
|
||||
): Promise<{ status: string; execution_count?: number; outputs: any[] } | { error: string }> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}/execute`;
|
||||
const body: Record<string, any> = { cell_index: cellIndex };
|
||||
if (source !== undefined) body.source = source;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return { error: body?.detail ?? `HTTP ${res.status}` };
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('open-terminal executeNotebookCell error:', err);
|
||||
return { error: 'Connection failed' };
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export const stopNotebookSession = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
sessionId: string
|
||||
): Promise<boolean> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/notebooks/${sessionId}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
return res?.ok ?? false;
|
||||
};
|
||||
@@ -309,14 +309,27 @@
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('API Base URL')}
|
||||
autocomplete="off"
|
||||
list={ollama ? undefined : 'suggestions'}
|
||||
required
|
||||
/>
|
||||
|
||||
{#if !ollama}
|
||||
<datalist id="suggestions">
|
||||
<option value="https://api.openai.com/v1" />
|
||||
<option value="https://api.anthropic.com/v1" />
|
||||
<option value="https://generativelanguage.googleapis.com/v1beta/openai" />
|
||||
<option value="https://api.mistral.ai/v1" />
|
||||
<option value="https://api.groq.com/openai/v1" />
|
||||
<option value="https://openrouter.ai/api/v1" />
|
||||
<option value="https://api.x.ai/v1" />
|
||||
</datalist>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('Verify Connection')} className="self-end -mb-1">
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
on:click={() => {
|
||||
verifyHandler();
|
||||
}}
|
||||
@@ -686,7 +699,7 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
@@ -695,9 +708,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</script>
|
||||
|
||||
<div class="px-3">
|
||||
<div class="text-center dark:text-white text-2xl font-medium z-50">
|
||||
<div class="text-center dark:text-white text-2xl font-medium z-50" role="heading" aria-level="2">
|
||||
{#if title}
|
||||
{title}
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
|
||||
export let show = false;
|
||||
export let edit = false;
|
||||
export let admin = false;
|
||||
export let connection = null;
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
export let onDelete: () => void = () => {};
|
||||
|
||||
let url = '';
|
||||
let key = '';
|
||||
let name = '';
|
||||
let id = '';
|
||||
let auth_type = 'bearer';
|
||||
let path = '/openapi.json';
|
||||
let enabled = false;
|
||||
let showAdvanced = false;
|
||||
let showAccessControlModal = false;
|
||||
let accessGrants: any[] = [];
|
||||
|
||||
const init = () => {
|
||||
if (connection) {
|
||||
id = connection?.id ?? '';
|
||||
url = connection.url;
|
||||
key = connection?.key ?? '';
|
||||
name = connection?.name ?? '';
|
||||
auth_type = connection?.auth_type ?? 'bearer';
|
||||
path = connection?.path ?? '/openapi.json';
|
||||
enabled = connection?.enabled ?? true;
|
||||
accessGrants = connection?.config?.access_grants ?? [];
|
||||
} else {
|
||||
id = '';
|
||||
url = '';
|
||||
key = '';
|
||||
name = '';
|
||||
auth_type = 'bearer';
|
||||
path = '/openapi.json';
|
||||
enabled = false;
|
||||
accessGrants = [];
|
||||
}
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
|
||||
const submitHandler = () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
url = url.replace(/\/$/, '');
|
||||
|
||||
const result = {
|
||||
...(admin && id.trim() ? { id: id.trim() } : {}),
|
||||
url,
|
||||
key,
|
||||
name,
|
||||
path,
|
||||
auth_type,
|
||||
enabled: enabled,
|
||||
config: {
|
||||
...(admin ? { access_grants: accessGrants } : {})
|
||||
}
|
||||
};
|
||||
|
||||
onSubmit(result);
|
||||
show = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<h1 class="text-lg font-medium self-center font-primary">
|
||||
{#if edit}
|
||||
{$i18n.t('Edit Terminal Connection')}
|
||||
{:else}
|
||||
{$i18n.t('Add Terminal Connection')}
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form class="flex flex-col w-full" on:submit|preventDefault={submitHandler}>
|
||||
<div class="px-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="terminal-name"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="terminal-name"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('My Terminal')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if admin}
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="terminal-id"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('ID')}
|
||||
<span class="opacity-50">({$i18n.t('optional')})</span></label
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="terminal-id"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="auto"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="terminal-url"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="terminal-url"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder="http://localhost:9900"
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
|
||||
on:click={() => (showAdvanced = !showAdvanced)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$i18n.t('Advanced')}
|
||||
</button>
|
||||
|
||||
{#if admin}
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
|
||||
|
||||
<div class="text-xs font-medium shrink-0">
|
||||
{$i18n.t('Access')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAdvanced}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('OpenAPI Spec')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-1 items-center">
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="openapi-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="openapi-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Auth')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
<option value="bearer">{$i18n.t('Bearer')}</option>
|
||||
{#if admin}
|
||||
<option value="session">{$i18n.t('Session')}</option>
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if auth_type === 'bearer'}
|
||||
<SensitiveInput
|
||||
bind:value={key}
|
||||
placeholder={$i18n.t('API Key')}
|
||||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
|
||||
<div></div>
|
||||
<div class="flex gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
onDelete();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<AccessControlModal bind:show={showAccessControlModal} bind:accessGrants />
|
||||
@@ -19,7 +19,8 @@
|
||||
import Tags from './common/Tags.svelte';
|
||||
import { getToolServerData } from '$lib/apis';
|
||||
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
|
||||
import AccessControl from './workspace/common/AccessControl.svelte';
|
||||
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Textarea from './common/Textarea.svelte';
|
||||
@@ -58,6 +59,8 @@
|
||||
|
||||
let enable = true;
|
||||
let loading = false;
|
||||
let showAdvanced = false;
|
||||
let showAccessControlModal = false;
|
||||
|
||||
const registerOAuthClientHandler = async () => {
|
||||
if (url === '') {
|
||||
@@ -439,30 +442,94 @@
|
||||
}}
|
||||
>
|
||||
<div class="px-1">
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mb-1.5">
|
||||
<div class="flex w-full justify-between items-center">
|
||||
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
|
||||
<div class="flex gap-2 mb-1.5">
|
||||
<div class="flex w-full justify-between items-center">
|
||||
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
|
||||
|
||||
<div class="">
|
||||
<button
|
||||
on:click={() => {
|
||||
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
|
||||
}}
|
||||
type="button"
|
||||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
{$i18n.t('OpenAPI')}
|
||||
{:else if type === 'mcp'}
|
||||
{$i18n.t('MCP')}
|
||||
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="">
|
||||
<button
|
||||
on:click={() => {
|
||||
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
|
||||
}}
|
||||
type="button"
|
||||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
{$i18n.t('OpenAPI')}
|
||||
{:else if type === 'mcp'}
|
||||
{$i18n.t('MCP')}
|
||||
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="enter-name"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}</label
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="enter-name"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Enter name')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if !direct}
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="enter-id"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('ID')}
|
||||
{#if type !== 'mcp'}<span class="opacity-50">({$i18n.t('optional')})</span
|
||||
>{/if}</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="enter-id"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="auto"
|
||||
autocomplete="off"
|
||||
required={type === 'mcp'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full mt-1 mb-1.5">
|
||||
<label
|
||||
for="description"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Description')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="description"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$i18n.t('Enter description')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
@@ -490,7 +557,7 @@
|
||||
className="shrink-0 flex items-center mr-1"
|
||||
>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
on:click={() => {
|
||||
verifyHandler();
|
||||
}}
|
||||
@@ -520,81 +587,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('OpenAPI Spec')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={spec_type}
|
||||
>
|
||||
<option value="url">{$i18n.t('URL')}</option>
|
||||
<option value="json">{$i18n.t('JSON')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if spec_type === 'url'}
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="url-or-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if spec_type === 'json'}
|
||||
<div
|
||||
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
|
||||
<textarea
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
|
||||
bind:value={spec}
|
||||
placeholder={$i18n.t('JSON Spec')}
|
||||
autocomplete="off"
|
||||
required
|
||||
rows="5"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ['', 'url'].includes(spec_type)}
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -702,104 +694,152 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="enter-id"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('ID')}
|
||||
|
||||
{#if type !== 'mcp'}
|
||||
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
|
||||
>{$i18n.t('Optional')}</span
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="enter-id"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder={$i18n.t('Enter ID')}
|
||||
autocomplete="off"
|
||||
required={type === 'mcp'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="enter-name"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}
|
||||
</label>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="enter-name"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Enter name')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
for="description"
|
||||
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
|
||||
>{$i18n.t('Description')}</label
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
|
||||
on:click={() => (showAdvanced = !showAdvanced)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="description"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$i18n.t('Enter description')}
|
||||
autocomplete="off"
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$i18n.t('Advanced')}
|
||||
</button>
|
||||
|
||||
{#if !direct}
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
|
||||
|
||||
<div class="text-xs font-medium shrink-0">
|
||||
{$i18n.t('Access')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAdvanced}
|
||||
{#if ['', 'openapi'].includes(type)}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t('OpenAPI Spec')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={spec_type}
|
||||
>
|
||||
<option value="url">{$i18n.t('URL')}</option>
|
||||
<option value="json">{$i18n.t('JSON')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
{#if spec_type === 'url'}
|
||||
<div class="flex-1 flex items-center">
|
||||
<label for="url-or-path" class="sr-only"
|
||||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
placeholder={$i18n.t('openapi.json URL or Path')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if spec_type === 'json'}
|
||||
<div
|
||||
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
|
||||
<textarea
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
|
||||
bind:value={spec}
|
||||
placeholder={$i18n.t('JSON Spec')}
|
||||
autocomplete="off"
|
||||
required
|
||||
rows="5"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ['', 'url'].includes(spec_type)}
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !direct}
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
@@ -819,12 +859,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="my-2">
|
||||
<AccessControl bind:accessGrants />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -864,7 +898,7 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
@@ -873,9 +907,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -885,3 +919,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<AccessControlModal bind:show={showAccessControlModal} bind:accessGrants />
|
||||
|
||||
@@ -38,15 +38,13 @@
|
||||
<Modal bind:show size="xl">
|
||||
<div class="px-6 pt-5 dark:text-white text-black">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="text-xl font-medium">
|
||||
<h2 class="text-xl font-medium m-0">
|
||||
{$i18n.t("What's New in")}
|
||||
{$WEBUI_NAME}
|
||||
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
|
||||
</div>
|
||||
</h2>
|
||||
<button class="self-center" on:click={closeModal} aria-label={$i18n.t('Close')}>
|
||||
<XMark className={'size-5'}>
|
||||
<p class="sr-only">{$i18n.t('Close')}</p>
|
||||
</XMark>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
@@ -64,9 +62,9 @@
|
||||
{#if changelog}
|
||||
{#each Object.keys(changelog) as version}
|
||||
<div class=" mb-3 pr-2">
|
||||
<div class="font-semibold text-xl mb-1 dark:text-white">
|
||||
<h3 class="font-semibold text-xl mb-1 dark:text-white m-0">
|
||||
v{version} - {changelog[version].date}
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
@@ -103,7 +104,7 @@
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
@@ -112,9 +113,9 @@
|
||||
{$i18n.t('Import')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { marked } from 'marked';
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
@@ -82,9 +82,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="group relative flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
|
||||
on:dragstart|preventDefault
|
||||
on:pointerdown={onPointerDown}
|
||||
@@ -118,7 +118,7 @@
|
||||
{/if}
|
||||
|
||||
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
|
||||
{@html DOMPurify.sanitize(marked(content))}
|
||||
{@html DOMPurify.sanitize(marked(DOMPurify.sanitize(content, { ALLOWED_TAGS: [] })))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,15 +87,15 @@
|
||||
<div class="flex justify-center mt-8">
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<button
|
||||
aria-labelledby="get-started"
|
||||
aria-label={$i18n.t('Get started')}
|
||||
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
|
||||
on:click={() => {
|
||||
getStartedHandler();
|
||||
}}
|
||||
>
|
||||
<ArrowRightCircle className="size-6" />
|
||||
<ArrowRightCircle className="size-6" aria-hidden="true" />
|
||||
</button>
|
||||
<div id="get-started" class="mt-1.5 font-primary text-base font-medium">
|
||||
<div class="mt-1.5 font-primary text-base font-medium" aria-hidden="true">
|
||||
{$i18n.t(`Get started`)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
// Time period - persist in localStorage
|
||||
let selectedPeriod =
|
||||
(typeof localStorage !== 'undefined' && localStorage.getItem('analyticsPeriod')) || '7d';
|
||||
const periods = [
|
||||
{ value: '24h', label: 'Last 24 hours' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: '90d', label: 'Last 90 days' },
|
||||
{ value: 'all', label: 'All time' }
|
||||
$: periods = [
|
||||
{ value: '24h', label: $i18n.t('Last 24 hours') },
|
||||
{ value: '7d', label: $i18n.t('Last 7 days') },
|
||||
{ value: '30d', label: $i18n.t('Last 30 days') },
|
||||
{ value: '90d', label: $i18n.t('Last 90 days') },
|
||||
{ value: 'all', label: $i18n.t('All time') }
|
||||
];
|
||||
|
||||
// User group filter
|
||||
@@ -158,6 +158,11 @@
|
||||
if (modelOrderBy === 'name') {
|
||||
return modelDirection === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
|
||||
}
|
||||
if (modelOrderBy === 'tokens') {
|
||||
const aTokens = tokenStats[a.model_id]?.total_tokens ?? 0;
|
||||
const bTokens = tokenStats[b.model_id]?.total_tokens ?? 0;
|
||||
return modelDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
|
||||
}
|
||||
return modelDirection === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
@@ -167,6 +172,11 @@
|
||||
const nameB = b.name || b.user_id;
|
||||
return userDirection === 'asc' ? nameA.localeCompare(nameB) : nameB.localeCompare(nameA);
|
||||
}
|
||||
if (userOrderBy === 'tokens') {
|
||||
const aTokens = a.total_tokens ?? 0;
|
||||
const bTokens = b.total_tokens ?? 0;
|
||||
return userDirection === 'asc' ? aTokens - bTokens : bTokens - aTokens;
|
||||
}
|
||||
return userDirection === 'asc' ? a.count - b.count : b.count - a.count;
|
||||
});
|
||||
|
||||
@@ -191,7 +201,7 @@
|
||||
{#if groups.length > 0}
|
||||
<select
|
||||
bind:value={selectedGroupId}
|
||||
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||
>
|
||||
<option value={null}>{$i18n.t('All Users')}</option>
|
||||
{#each groups as group}
|
||||
@@ -201,10 +211,10 @@
|
||||
{/if}
|
||||
<select
|
||||
bind:value={selectedPeriod}
|
||||
class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||
class="w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-none text-right"
|
||||
>
|
||||
{#each periods as period}
|
||||
<option value={period.value}>{$i18n.t(period.label)}</option>
|
||||
<option value={period.value}>{period.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -264,7 +274,7 @@
|
||||
{@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5">
|
||||
{$i18n.t(selectedPeriod === '24h' ? 'Hourly Messages' : 'Daily Messages')}
|
||||
{selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')}
|
||||
</div>
|
||||
<ChartLine
|
||||
data={dailyStats}
|
||||
@@ -329,8 +339,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right w-16">%</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleModelSort('tokens')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Tokens')}
|
||||
{#if modelOrderBy === 'tokens'}
|
||||
<span class="font-normal">
|
||||
{#if modelDirection === 'asc'}<ChevronUp
|
||||
className="size-2"
|
||||
/>{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right w-16"
|
||||
on:click={() => toggleModelSort('percentage')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
%
|
||||
{#if modelOrderBy === 'percentage'}
|
||||
<span class="font-normal">
|
||||
{#if modelDirection === 'asc'}<ChevronUp
|
||||
className="size-2"
|
||||
/>{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -349,6 +393,9 @@
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
|
||||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
on:error={(e) => {
|
||||
e.target.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
<span class="truncate max-w-[150px]">{model.name}</span>
|
||||
</div>
|
||||
@@ -422,7 +469,24 @@
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right">{$i18n.t('Tokens')}</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right"
|
||||
on:click={() => toggleUserSort('tokens')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Tokens')}
|
||||
{#if userOrderBy === 'tokens'}
|
||||
<span class="font-normal">
|
||||
{#if userDirection === 'asc'}<ChevronUp
|
||||
className="size-2"
|
||||
/>{:else}<ChevronDown className="size-2" />{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -435,6 +499,9 @@
|
||||
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
|
||||
alt={user.name || 'User'}
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
on:error={(e) => {
|
||||
e.target.src = '/user.png';
|
||||
}}
|
||||
/>
|
||||
<span class="truncate max-w-[150px]"
|
||||
>{user.name || user.email || user.user_id.substring(0, 8)}</span
|
||||
|
||||
@@ -114,7 +114,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
|
||||
on:click={() => toggleSort('percentage')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Share')}
|
||||
{#if orderBy === 'percentage'}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
|
||||
className="size-2"
|
||||
/>{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -130,7 +145,10 @@
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
|
||||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover"
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
on:error={(e) => {
|
||||
e.target.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-2.5 py-2 text-right w-24">{$i18n.t('Share')}</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
|
||||
on:click={() => toggleSort('percentage')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
{$i18n.t('Share')}
|
||||
{#if orderBy === 'percentage'}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
|
||||
className="size-2"
|
||||
/>{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -54,15 +54,14 @@
|
||||
id="users-tabs-container"
|
||||
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
>
|
||||
<button
|
||||
<a
|
||||
id="leaderboard"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
|
||||
href="/admin/evaluations/leaderboard"
|
||||
draggable="false"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
|
||||
'leaderboard'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
on:click={() => {
|
||||
goto('/admin/evaluations/leaderboard');
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<svg
|
||||
@@ -79,17 +78,16 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<button
|
||||
<a
|
||||
id="feedback"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition {selectedTab ===
|
||||
href="/admin/evaluations/feedback"
|
||||
draggable="false"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
|
||||
'feedback'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
on:click={() => {
|
||||
goto('/admin/evaluations/feedback');
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<svg
|
||||
@@ -106,7 +104,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t('Feedback')}</div>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
dispatch('delete');
|
||||
show = false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user