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 |
@@ -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
|
||||
|
||||
+177
@@ -5,6 +5,183 @@ 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
|
||||
|
||||
@@ -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._**
|
||||
@@ -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",
|
||||
@@ -642,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"
|
||||
@@ -1171,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
|
||||
####################################
|
||||
@@ -1433,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"
|
||||
@@ -1590,6 +1627,9 @@ 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,
|
||||
@@ -1926,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:
|
||||
@@ -2256,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."""
|
||||
|
||||
|
||||
####################################
|
||||
@@ -3177,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(
|
||||
|
||||
@@ -788,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"
|
||||
|
||||
@@ -1041,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")
|
||||
|
||||
|
||||
+60
-35
@@ -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,
|
||||
@@ -524,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,
|
||||
@@ -690,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
|
||||
|
||||
@@ -775,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
|
||||
@@ -1381,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)
|
||||
@@ -1540,6 +1563,7 @@ app.include_router(
|
||||
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:
|
||||
@@ -2169,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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -734,13 +734,13 @@ 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
|
||||
@@ -793,13 +793,13 @@ 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
|
||||
@@ -854,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)
|
||||
@@ -892,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
|
||||
)
|
||||
|
||||
@@ -1039,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()
|
||||
|
||||
@@ -1188,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
|
||||
@@ -1309,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()),
|
||||
@@ -504,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()),
|
||||
)
|
||||
|
||||
@@ -613,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -192,6 +195,7 @@ class OAuthSessionTable:
|
||||
.first()
|
||||
)
|
||||
if session:
|
||||
db.expunge(session)
|
||||
session.token = self._decrypt_token(session.token)
|
||||
return OAuthSessionModel.model_validate(session)
|
||||
|
||||
@@ -211,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:
|
||||
@@ -245,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)
|
||||
|
||||
|
||||
@@ -1306,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
|
||||
|
||||
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -57,64 +57,7 @@ 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -565,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
|
||||
|
||||
@@ -283,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)
|
||||
@@ -357,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
|
||||
|
||||
@@ -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,24 +473,13 @@ 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -728,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
|
||||
@@ -1054,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
|
||||
@@ -1971,6 +1975,7 @@ async def process_web(
|
||||
docs,
|
||||
collection_name,
|
||||
overwrite=overwrite,
|
||||
add=(not overwrite),
|
||||
user=user,
|
||||
)
|
||||
else:
|
||||
@@ -2489,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
|
||||
@@ -2504,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
|
||||
@@ -2578,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
|
||||
@@ -2756,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(
|
||||
|
||||
@@ -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
|
||||
@@ -576,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,6 +196,10 @@ class SharingPermissions(BaseModel):
|
||||
public_notes: bool = True
|
||||
|
||||
|
||||
class AccessGrantsPermissions(BaseModel):
|
||||
allow_users: bool = True
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
controls: bool = True
|
||||
valves: bool = True
|
||||
@@ -239,6 +243,7 @@ class SettingsPermissions(BaseModel):
|
||||
class UserPermissions(BaseModel):
|
||||
workspace: WorkspacePermissions
|
||||
sharing: SharingPermissions
|
||||
access_grants: AccessGrantsPermissions
|
||||
chat: ChatPermissions
|
||||
features: FeaturesPermissions
|
||||
settings: SettingsPermissions
|
||||
@@ -253,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
|
||||
@@ -790,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"],
|
||||
)
|
||||
@@ -813,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"],
|
||||
{
|
||||
@@ -821,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"],
|
||||
{
|
||||
@@ -832,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"],
|
||||
)
|
||||
@@ -841,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"],
|
||||
{
|
||||
@@ -849,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"],
|
||||
)
|
||||
@@ -858,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"],
|
||||
{
|
||||
@@ -866,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"],
|
||||
)
|
||||
@@ -877,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"],
|
||||
{
|
||||
@@ -905,6 +919,7 @@ def get_event_call(request_info):
|
||||
"data": event_data,
|
||||
},
|
||||
to=request_info["session_id"],
|
||||
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -155,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)
|
||||
@@ -250,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:
|
||||
@@ -315,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:
|
||||
@@ -426,6 +429,9 @@ async def execute_code(
|
||||
"session_id": (
|
||||
__metadata__.get("session_id") if __metadata__ else None
|
||||
),
|
||||
"files": (
|
||||
__metadata__.get("files", []) if __metadata__ else []
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1627,7 +1633,7 @@ async def view_file(
|
||||
|
||||
try:
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.routers.files import has_access_to_file
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
|
||||
user_id = __user__.get("id")
|
||||
user_role = __user__.get("role", "user")
|
||||
@@ -1831,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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ def start_logger():
|
||||
logger.remove()
|
||||
|
||||
audit_filter = lambda record: (
|
||||
"auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True
|
||||
True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"]
|
||||
)
|
||||
if LOG_FORMAT == "json":
|
||||
logger.add(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
@@ -86,10 +87,12 @@ from open_webui.utils.misc import (
|
||||
get_message_list,
|
||||
add_or_update_system_message,
|
||||
add_or_update_user_message,
|
||||
set_last_user_message_content,
|
||||
get_last_user_message,
|
||||
get_last_user_message_item,
|
||||
get_last_assistant_message,
|
||||
get_system_message,
|
||||
replace_system_message_content,
|
||||
prepend_to_first_user_message_content,
|
||||
convert_logit_bias_input_to_json,
|
||||
get_content_from_message,
|
||||
@@ -98,8 +101,9 @@ from open_webui.utils.misc import (
|
||||
from open_webui.utils.tools import (
|
||||
get_tools,
|
||||
get_updated_tool_function,
|
||||
has_tool_server_access,
|
||||
get_terminal_tools,
|
||||
)
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
from open_webui.utils.filter import (
|
||||
get_sorted_filter_ids,
|
||||
@@ -116,6 +120,7 @@ from open_webui.config import (
|
||||
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE,
|
||||
DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
DEFAULT_CODE_INTERPRETER_PROMPT,
|
||||
CODE_INTERPRETER_PYODIDE_PROMPT,
|
||||
CODE_INTERPRETER_BLOCKED_MODULES,
|
||||
)
|
||||
from open_webui.env import (
|
||||
@@ -157,6 +162,55 @@ def output_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid4().hex[:24]}"
|
||||
|
||||
|
||||
def _split_tool_calls(
|
||||
tool_calls: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Expand tool calls whose arguments contain multiple back-to-back JSON objects.
|
||||
|
||||
Some models (e.g. GPT-5.4) send multiple complete JSON argument objects
|
||||
under the same tool call index, producing concatenated invalid JSON like:
|
||||
'{"query":"A","count":5}{"query":"B","count":5}'
|
||||
|
||||
Each such tool call is split into separate entries so each gets executed
|
||||
independently. Single-object arguments pass through unchanged.
|
||||
"""
|
||||
|
||||
def split_json_objects(raw: str) -> list[str]:
|
||||
decoder = json.JSONDecoder()
|
||||
results = []
|
||||
position = 0
|
||||
|
||||
while position < len(raw):
|
||||
while position < len(raw) and raw[position].isspace():
|
||||
position += 1
|
||||
if position >= len(raw):
|
||||
break
|
||||
try:
|
||||
_, end = decoder.raw_decode(raw, position)
|
||||
results.append(raw[position:end].strip())
|
||||
position = end
|
||||
except json.JSONDecodeError:
|
||||
return [raw]
|
||||
|
||||
return results or [raw]
|
||||
|
||||
expanded = []
|
||||
for tool_call in tool_calls:
|
||||
arguments = tool_call.get("function", {}).get("arguments", "")
|
||||
split_arguments = split_json_objects(arguments)
|
||||
|
||||
if len(split_arguments) <= 1:
|
||||
expanded.append(tool_call)
|
||||
else:
|
||||
for argument in split_arguments:
|
||||
cloned = copy.deepcopy(tool_call)
|
||||
cloned["id"] = f"call_{uuid4().hex[:24]}"
|
||||
cloned["function"]["arguments"] = argument
|
||||
expanded.append(cloned)
|
||||
|
||||
return expanded
|
||||
|
||||
|
||||
def get_citation_source_from_tool_result(
|
||||
tool_name: str, tool_params: dict, tool_result: str, tool_id: str = ""
|
||||
) -> list[dict]:
|
||||
@@ -170,6 +224,9 @@ def get_citation_source_from_tool_result(
|
||||
|
||||
Returns a list of sources (usually one, but query_knowledge_files may return multiple).
|
||||
"""
|
||||
_EXPECTS_LIST = {"search_web", "query_knowledge_files"}
|
||||
_EXPECTS_DICT = {"view_knowledge_file"}
|
||||
|
||||
try:
|
||||
try:
|
||||
tool_result = json.loads(tool_result)
|
||||
@@ -178,6 +235,12 @@ def get_citation_source_from_tool_result(
|
||||
if isinstance(tool_result, dict) and "error" in tool_result:
|
||||
return []
|
||||
|
||||
# Validate tool_result type based on what the branch expects
|
||||
if tool_name in _EXPECTS_LIST and not isinstance(tool_result, list):
|
||||
return []
|
||||
elif tool_name in _EXPECTS_DICT and not isinstance(tool_result, dict):
|
||||
return []
|
||||
|
||||
if tool_name == "search_web":
|
||||
# Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}]
|
||||
results = tool_result
|
||||
@@ -373,9 +436,14 @@ def serialize_output(output: list) -> str:
|
||||
result_item = tool_outputs.get(call_id)
|
||||
if result_item:
|
||||
result_text = ""
|
||||
for out in result_item.get("output", []):
|
||||
if "text" in out:
|
||||
result_text += out.get("text", "")
|
||||
for result_output in result_item.get("output", []):
|
||||
if "text" in result_output:
|
||||
output_text = result_output.get("text", "")
|
||||
result_text += (
|
||||
str(output_text)
|
||||
if not isinstance(output_text, str)
|
||||
else output_text
|
||||
)
|
||||
files = result_item.get("files")
|
||||
embeds = result_item.get("embeds", "")
|
||||
|
||||
@@ -822,6 +890,32 @@ def handle_responses_streaming_event(
|
||||
return current_output, None
|
||||
|
||||
|
||||
def get_source_context(
|
||||
sources: list, source_ids: dict = None, include_content: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Build <source> tag context string from citation sources.
|
||||
"""
|
||||
context_string = ""
|
||||
if source_ids is None:
|
||||
source_ids = {}
|
||||
for source in sources:
|
||||
for doc, meta in zip(source.get("document", []), source.get("metadata", [])):
|
||||
source_id = (
|
||||
meta.get("source") or source.get("source", {}).get("id") or "N/A"
|
||||
)
|
||||
if source_id not in source_ids:
|
||||
source_ids[source_id] = len(source_ids) + 1
|
||||
src_name = source.get("source", {}).get("name")
|
||||
body = doc if include_content else ""
|
||||
context_string += (
|
||||
f'<source id="{source_ids[source_id]}"'
|
||||
+ (f' name="{src_name}"' if src_name else "")
|
||||
+ f">{body}</source>\n"
|
||||
)
|
||||
return context_string
|
||||
|
||||
|
||||
def apply_source_context_to_messages(
|
||||
request: Request,
|
||||
messages: list,
|
||||
@@ -840,39 +934,21 @@ def apply_source_context_to_messages(
|
||||
if not sources or not user_message:
|
||||
return messages
|
||||
|
||||
context_string = ""
|
||||
citation_idx = {}
|
||||
context = get_source_context(sources, include_content=include_content)
|
||||
|
||||
for source in sources:
|
||||
for doc, meta in zip(source.get("document", []), source.get("metadata", [])):
|
||||
src_id = meta.get("source") or source.get("source", {}).get("id") or "N/A"
|
||||
if src_id not in citation_idx:
|
||||
citation_idx[src_id] = len(citation_idx) + 1
|
||||
src_name = source.get("source", {}).get("name")
|
||||
body = doc if include_content else ""
|
||||
context_string += (
|
||||
f'<source id="{citation_idx[src_id]}"'
|
||||
+ (f' name="{src_name}"' if src_name else "")
|
||||
+ f">{body}</source>\n"
|
||||
)
|
||||
|
||||
context_string = context_string.strip()
|
||||
if not context_string:
|
||||
context = context.strip()
|
||||
if not context:
|
||||
return messages
|
||||
|
||||
if RAG_SYSTEM_CONTEXT:
|
||||
return add_or_update_system_message(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE, context_string, user_message
|
||||
),
|
||||
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
messages,
|
||||
append=True,
|
||||
)
|
||||
else:
|
||||
return add_or_update_user_message(
|
||||
rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE, context_string, user_message
|
||||
),
|
||||
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
messages,
|
||||
append=False,
|
||||
)
|
||||
@@ -888,6 +964,7 @@ def process_tool_result(
|
||||
user=None,
|
||||
):
|
||||
tool_result_embeds = []
|
||||
EXTERNAL_TOOL_TYPES = ("external", "action", "terminal")
|
||||
|
||||
if isinstance(tool_result, HTMLResponse):
|
||||
content_disposition = tool_result.headers.get("Content-Disposition", "")
|
||||
@@ -922,7 +999,7 @@ def process_tool_result(
|
||||
else:
|
||||
tool_result = tool_result.body.decode("utf-8", "replace")
|
||||
|
||||
elif (tool_type in ("external", "action") and isinstance(tool_result, tuple)) or (
|
||||
elif (tool_type in EXTERNAL_TOOL_TYPES and isinstance(tool_result, tuple)) or (
|
||||
direct_tool and isinstance(tool_result, list) and len(tool_result) == 2
|
||||
):
|
||||
tool_result, tool_response_headers = tool_result
|
||||
@@ -1018,9 +1095,77 @@ def process_tool_result(
|
||||
if isinstance(tool_result, dict) or isinstance(tool_result, list):
|
||||
tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False)
|
||||
|
||||
# Safety: ensure tool_result is always a string (or None) to prevent
|
||||
# downstream TypeError when concatenating (e.g. if an upstream callable
|
||||
# returned a tuple that was not unpacked by the branches above).
|
||||
if tool_result is not None and not isinstance(tool_result, str):
|
||||
if isinstance(tool_result, tuple):
|
||||
# execute_tool_server returns (data, headers); unpack the data part
|
||||
tool_result = (
|
||||
json.dumps(tool_result[0], indent=2, ensure_ascii=False)
|
||||
if len(tool_result) > 0
|
||||
else ""
|
||||
)
|
||||
else:
|
||||
tool_result = str(tool_result)
|
||||
|
||||
return tool_result, tool_result_files, tool_result_embeds
|
||||
|
||||
|
||||
async def terminal_event_handler(
|
||||
tool_function_name: str,
|
||||
tool_function_params: dict,
|
||||
tool_result,
|
||||
event_emitter,
|
||||
):
|
||||
"""Emit terminal:* events for Open Terminal tools.
|
||||
|
||||
- display_file → emits 'terminal:display_file' to open the file preview.
|
||||
- write_file / replace_file_content → emits 'terminal:write_file' to refresh.
|
||||
- run_command → emits 'terminal:run_command' with cwd to refresh if relevant.
|
||||
"""
|
||||
if not event_emitter:
|
||||
return
|
||||
|
||||
if tool_function_name == "display_file":
|
||||
path = tool_function_params.get("path", "")
|
||||
if not path:
|
||||
return
|
||||
# Only emit if the file actually exists
|
||||
parsed = tool_result
|
||||
if isinstance(parsed, str):
|
||||
try:
|
||||
parsed = json.loads(parsed)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if isinstance(parsed, dict) and parsed.get("exists") is False:
|
||||
return
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
"type": f"terminal:{tool_function_name}",
|
||||
"data": {"path": path},
|
||||
}
|
||||
)
|
||||
elif tool_function_name in ("write_file", "replace_file_content"):
|
||||
path = tool_function_params.get("path", "")
|
||||
if not path:
|
||||
return
|
||||
await event_emitter(
|
||||
{
|
||||
"type": f"terminal:{tool_function_name}",
|
||||
"data": {"path": path},
|
||||
}
|
||||
)
|
||||
elif tool_function_name == "run_command":
|
||||
await event_emitter(
|
||||
{
|
||||
"type": "terminal:run_command",
|
||||
"data": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def chat_completion_tools_handler(
|
||||
request: Request, body: dict, extra_params: dict, user: UserModel, models, tools
|
||||
) -> tuple[dict, dict]:
|
||||
@@ -1175,6 +1320,13 @@ async def chat_completion_tools_handler(
|
||||
)
|
||||
|
||||
if event_emitter:
|
||||
await terminal_event_handler(
|
||||
tool_function_name,
|
||||
tool_function_params,
|
||||
tool_result,
|
||||
event_emitter,
|
||||
)
|
||||
|
||||
if tool_result_files:
|
||||
await event_emitter(
|
||||
{
|
||||
@@ -1977,7 +2129,7 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
|
||||
for message in messages:
|
||||
if message.get("role") == "assistant" and message.get("output"):
|
||||
# Use output items for clean OpenAI-format messages
|
||||
output_messages = convert_output_to_messages(message["output"])
|
||||
output_messages = convert_output_to_messages(message["output"], raw=True)
|
||||
if output_messages:
|
||||
processed.extend(output_messages)
|
||||
continue
|
||||
@@ -2097,10 +2249,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
folder.data["system_prompt"], form_data, metadata, user
|
||||
)
|
||||
if "files" in folder.data:
|
||||
form_data["files"] = [
|
||||
*folder.data["files"],
|
||||
*form_data.get("files", []),
|
||||
]
|
||||
if metadata.get("params", {}).get("function_calling") != "native":
|
||||
form_data["files"] = [
|
||||
*folder.data["files"],
|
||||
*form_data.get("files", []),
|
||||
]
|
||||
else:
|
||||
# Native FC: skip RAG injection, builtin tools
|
||||
# will read folder knowledge from metadata.
|
||||
metadata["folder_knowledge"] = folder.data["files"]
|
||||
|
||||
# Model "Knowledge" handling
|
||||
user_message = get_last_user_message(form_data["messages"])
|
||||
@@ -2210,20 +2367,38 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
)
|
||||
|
||||
if "code_interpreter" in features and features["code_interpreter"]:
|
||||
engine = getattr(
|
||||
request.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide"
|
||||
)
|
||||
|
||||
# Skip XML-tag prompt injection when native FC is enabled —
|
||||
# execute_code will be injected as a builtin tool instead
|
||||
if metadata.get("params", {}).get("function_calling") != "native":
|
||||
form_data["messages"] = add_or_update_user_message(
|
||||
(
|
||||
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
!= ""
|
||||
else DEFAULT_CODE_INTERPRETER_PROMPT
|
||||
),
|
||||
form_data["messages"],
|
||||
prompt = (
|
||||
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != ""
|
||||
else DEFAULT_CODE_INTERPRETER_PROMPT
|
||||
)
|
||||
|
||||
# Append filesystem awareness only for pyodide engine
|
||||
if engine != "jupyter":
|
||||
prompt += CODE_INTERPRETER_PYODIDE_PROMPT
|
||||
|
||||
form_data["messages"] = add_or_update_user_message(
|
||||
prompt,
|
||||
form_data["messages"],
|
||||
)
|
||||
else:
|
||||
# Native FC: tool docstring can't be dynamic, so inject
|
||||
# filesystem context into messages for pyodide engine
|
||||
if engine != "jupyter":
|
||||
form_data["messages"] = add_or_update_user_message(
|
||||
CODE_INTERPRETER_PYODIDE_PROMPT,
|
||||
form_data["messages"],
|
||||
)
|
||||
|
||||
tool_ids = form_data.pop("tool_ids", None)
|
||||
terminal_id = form_data.pop("terminal_id", None)
|
||||
files = form_data.pop("files", None)
|
||||
|
||||
# Caller-provided OpenAI-style tools take precedence over server-side
|
||||
@@ -2297,6 +2472,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
metadata = {
|
||||
**metadata,
|
||||
"tool_ids": tool_ids,
|
||||
"terminal_id": terminal_id,
|
||||
"files": files,
|
||||
}
|
||||
form_data["metadata"] = metadata
|
||||
@@ -2341,7 +2517,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
continue
|
||||
|
||||
# Check access control for MCP server
|
||||
if not has_tool_server_access(user, mcp_server_connection):
|
||||
if not has_connection_access(user, mcp_server_connection):
|
||||
log.warning(
|
||||
f"Access denied to MCP server {server_id} for user {user.id}"
|
||||
)
|
||||
@@ -2478,6 +2654,21 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
if mcp_tools_dict:
|
||||
tools_dict = {**tools_dict, **mcp_tools_dict}
|
||||
|
||||
# Resolve terminal tools if terminal_id is set (outside tool_ids check
|
||||
# so system terminals work even when no other tools are selected)
|
||||
if terminal_id:
|
||||
try:
|
||||
terminal_tools = await get_terminal_tools(
|
||||
request,
|
||||
terminal_id,
|
||||
user,
|
||||
extra_params,
|
||||
)
|
||||
if terminal_tools:
|
||||
tools_dict = {**tools_dict, **terminal_tools}
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
if direct_tool_servers:
|
||||
for tool_server in direct_tool_servers:
|
||||
tool_specs = tool_server.pop("specs", [])
|
||||
@@ -2530,16 +2721,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
{"type": "function", "function": tool.get("spec", {})}
|
||||
for tool in tools_dict.values()
|
||||
]
|
||||
|
||||
else:
|
||||
# If the function calling is not native, then call the tools function calling handler
|
||||
try:
|
||||
form_data, flags = await chat_completion_tools_handler(
|
||||
request, form_data, extra_params, user, models, tools_dict
|
||||
)
|
||||
sources.extend(flags.get("sources", []))
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
else:
|
||||
# If the function calling is not native, then call the tools function calling handler
|
||||
try:
|
||||
form_data, flags = await chat_completion_tools_handler(
|
||||
request, form_data, extra_params, user, models, tools_dict
|
||||
)
|
||||
sources.extend(flags.get("sources", []))
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
# Check if file context extraction is enabled for this model (default True)
|
||||
file_context_enabled = (
|
||||
@@ -2555,6 +2745,16 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
# Save the pre-RAG message state so the native tool call loop can
|
||||
# restore to the true original (before file-source injection) rather
|
||||
# than a snapshot that already has the RAG template baked in.
|
||||
system_message = get_system_message(form_data["messages"])
|
||||
metadata["system_prompt"] = (
|
||||
get_content_from_message(system_message) if system_message else None
|
||||
)
|
||||
metadata["user_prompt"] = get_last_user_message(form_data["messages"])
|
||||
metadata["sources"] = sources[:] if sources else []
|
||||
|
||||
# If context is not empty, insert it into the messages
|
||||
if sources and prompt:
|
||||
form_data["messages"] = apply_source_context_to_messages(
|
||||
@@ -2993,6 +3193,8 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
)
|
||||
|
||||
# Save message in the database
|
||||
usage = normalize_usage(response_data.get("usage", {}) or {})
|
||||
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
@@ -3000,6 +3202,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"output": response_output,
|
||||
**({"usage": usage} if usage else {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3629,7 +3832,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
if delta_name:
|
||||
current_response_tool_call[
|
||||
"function"
|
||||
]["name"] += delta_name
|
||||
]["name"] = delta_name
|
||||
|
||||
if delta_arguments:
|
||||
current_response_tool_call[
|
||||
@@ -3677,14 +3880,17 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
delta.get("images", []), request, metadata, user
|
||||
)
|
||||
if image_urls:
|
||||
image_file_list = [
|
||||
{"type": "image", "url": url}
|
||||
for url in image_urls
|
||||
]
|
||||
message_files = Chats.add_message_files_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
[
|
||||
{"type": "image", "url": url}
|
||||
for url in image_urls
|
||||
],
|
||||
image_file_list,
|
||||
)
|
||||
if message_files is None:
|
||||
message_files = image_file_list
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
@@ -3989,7 +4195,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
reasoning_item["status"] = "completed"
|
||||
|
||||
if response_tool_calls:
|
||||
tool_calls.append(response_tool_calls)
|
||||
tool_calls.append(_split_tool_calls(response_tool_calls))
|
||||
|
||||
if response.background:
|
||||
await response.background()
|
||||
@@ -4001,6 +4207,23 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
all_tool_call_sources = [] # Accumulated sources across all iterations
|
||||
user_message = get_last_user_message(form_data["messages"])
|
||||
|
||||
# Check if citations are enabled for this model
|
||||
citations_enabled = (
|
||||
model.get("info", {}).get("meta", {}).get("capabilities") or {}
|
||||
).get("citations", True)
|
||||
|
||||
# Use the pre-RAG system content captured before the
|
||||
# initial file-source injection in process_chat_payload.
|
||||
# This ensures restore truly undoes the RAG template.
|
||||
original_system_content = metadata.get("system_prompt")
|
||||
if original_system_content is None:
|
||||
original_system_message = get_system_message(form_data["messages"])
|
||||
original_system_content = (
|
||||
get_content_from_message(original_system_message)
|
||||
if original_system_message
|
||||
else None
|
||||
)
|
||||
|
||||
while (
|
||||
len(tool_calls) > 0
|
||||
and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES
|
||||
@@ -4047,18 +4270,26 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
tool_args = tool_call.get("function", {}).get("arguments", "{}")
|
||||
|
||||
tool_function_params = {}
|
||||
try:
|
||||
# json.loads cannot be used because some models do not produce valid JSON
|
||||
tool_function_params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
# Fallback to JSON parsing
|
||||
if tool_args and tool_args.strip():
|
||||
try:
|
||||
tool_function_params = json.loads(tool_args)
|
||||
# json.loads cannot be used because some models do not produce valid JSON
|
||||
tool_function_params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error parsing tool call arguments: {tool_args}"
|
||||
)
|
||||
log.debug(e)
|
||||
# Fallback to JSON parsing
|
||||
try:
|
||||
tool_function_params = json.loads(tool_args)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Error parsing tool call arguments: {tool_args}"
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": f"Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure arguments are valid JSON for downstream LLM integrations
|
||||
log.debug(
|
||||
@@ -4139,9 +4370,17 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
)
|
||||
)
|
||||
|
||||
await terminal_event_handler(
|
||||
tool_function_name,
|
||||
tool_function_params,
|
||||
tool_result,
|
||||
event_emitter,
|
||||
)
|
||||
|
||||
# Extract citation sources from tool results
|
||||
if (
|
||||
tool_function_name
|
||||
citations_enabled
|
||||
and tool_function_name
|
||||
in [
|
||||
"search_web",
|
||||
"fetch_url",
|
||||
@@ -4164,7 +4403,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
results.append(
|
||||
{
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": tool_result or "",
|
||||
"content": str(tool_result) if tool_result else "",
|
||||
**(
|
||||
{"files": tool_result_files}
|
||||
if tool_result_files
|
||||
@@ -4231,27 +4470,62 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
}
|
||||
)
|
||||
|
||||
# Emit citation sources for UI display
|
||||
for source in tool_call_sources:
|
||||
await event_emitter({"type": "source", "data": source})
|
||||
# Emit citation sources to the frontend for display
|
||||
if citations_enabled:
|
||||
for source in tool_call_sources:
|
||||
await event_emitter({"type": "source", "data": source})
|
||||
|
||||
# Apply source context to messages for model
|
||||
# Use metadata_only=True to avoid duplicating content
|
||||
# that is already in the tool result message.
|
||||
all_tool_call_sources.extend(tool_call_sources)
|
||||
if all_tool_call_sources and user_message:
|
||||
# Restore original user message before re-applying to avoid recursive nesting
|
||||
form_data["messages"] = add_or_update_user_message(
|
||||
user_message, form_data["messages"], append=False
|
||||
)
|
||||
form_data["messages"] = apply_source_context_to_messages(
|
||||
request,
|
||||
form_data["messages"],
|
||||
all_tool_call_sources,
|
||||
user_message,
|
||||
include_content=False,
|
||||
)
|
||||
tool_call_sources.clear()
|
||||
# Apply tool source context to messages for the model.
|
||||
# Restoring to pre-RAG original prevents duplicating
|
||||
# the RAG template across file and tool sources.
|
||||
all_tool_call_sources.extend(tool_call_sources)
|
||||
if all_tool_call_sources and user_message:
|
||||
# Restore pre-RAG message state before re-applying
|
||||
# to prevent RAG template duplication.
|
||||
original_user_message = (
|
||||
metadata.get("user_prompt") or user_message
|
||||
)
|
||||
set_last_user_message_content(
|
||||
original_user_message,
|
||||
form_data["messages"],
|
||||
)
|
||||
replace_system_message_content(
|
||||
original_system_content or "",
|
||||
form_data["messages"],
|
||||
)
|
||||
|
||||
# Build context: file sources with content,
|
||||
# tool sources as citation markers only.
|
||||
source_ids = {}
|
||||
source_context = get_source_context(
|
||||
metadata.get("sources", []), source_ids
|
||||
) + get_source_context(
|
||||
all_tool_call_sources,
|
||||
source_ids,
|
||||
include_content=False,
|
||||
)
|
||||
source_context = source_context.strip()
|
||||
if source_context:
|
||||
rag_content = rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
source_context,
|
||||
user_message,
|
||||
)
|
||||
if RAG_SYSTEM_CONTEXT:
|
||||
form_data["messages"] = (
|
||||
add_or_update_system_message(
|
||||
rag_content,
|
||||
form_data["messages"],
|
||||
append=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
form_data["messages"] = add_or_update_user_message(
|
||||
rag_content,
|
||||
form_data["messages"],
|
||||
append=False,
|
||||
)
|
||||
tool_call_sources.clear()
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
@@ -4353,6 +4627,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
"session_id": metadata.get(
|
||||
"session_id", None
|
||||
),
|
||||
"files": metadata.get("files", []),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -210,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(
|
||||
{
|
||||
@@ -277,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":
|
||||
|
||||
@@ -149,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}",
|
||||
@@ -331,12 +332,29 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
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 = [
|
||||
action_id
|
||||
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))
|
||||
|
||||
@@ -36,6 +36,7 @@ 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,
|
||||
@@ -55,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,
|
||||
@@ -111,6 +114,9 @@ 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
|
||||
@@ -129,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
|
||||
|
||||
|
||||
@@ -783,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(
|
||||
@@ -1077,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(
|
||||
@@ -1548,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
|
||||
@@ -1706,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}")
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -144,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
|
||||
@@ -294,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(
|
||||
@@ -391,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",
|
||||
}
|
||||
@@ -444,6 +432,10 @@ 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
|
||||
@@ -560,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__}",
|
||||
@@ -668,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)
|
||||
@@ -690,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
|
||||
]
|
||||
|
||||
@@ -766,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,
|
||||
}
|
||||
|
||||
@@ -774,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)
|
||||
@@ -837,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",
|
||||
@@ -953,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", {}),
|
||||
@@ -973,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"),
|
||||
@@ -1034,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))
|
||||
|
||||
@@ -1106,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.5",
|
||||
"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 */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -699,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"
|
||||
@@ -708,9 +708,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
@@ -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 />
|
||||
|
||||
@@ -104,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"
|
||||
@@ -113,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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -214,7 +214,7 @@
|
||||
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>
|
||||
@@ -274,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}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import Evaluations from './Settings/Evaluations.svelte';
|
||||
import CodeExecution from './Settings/CodeExecution.svelte';
|
||||
import Tools from './Settings/Tools.svelte';
|
||||
import Integrations from './Settings/Integrations.svelte';
|
||||
|
||||
import ChartBar from '../icons/ChartBar.svelte';
|
||||
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
|
||||
@@ -40,7 +40,7 @@
|
||||
'connections',
|
||||
'models',
|
||||
'evaluations',
|
||||
'tools',
|
||||
'integrations',
|
||||
'documents',
|
||||
'web',
|
||||
'code-execution',
|
||||
@@ -132,10 +132,10 @@
|
||||
keywords: ['evaluations', 'feedback', 'rating', 'arena', 'leaderboard', 'preference']
|
||||
},
|
||||
{
|
||||
id: 'tools',
|
||||
title: 'External Tools',
|
||||
route: '/admin/settings/tools',
|
||||
keywords: ['tools', 'plugins', 'extensions', 'functions', 'openapi', 'server']
|
||||
id: 'integrations',
|
||||
title: 'Integrations',
|
||||
route: '/admin/settings/integrations',
|
||||
keywords: ['tools', 'integrations', 'plugins', 'extensions', 'functions', 'openapi', 'server']
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
@@ -311,7 +311,7 @@
|
||||
<!-- {$i18n.t('Connections')} -->
|
||||
<!-- {$i18n.t('Models')} -->
|
||||
<!-- {$i18n.t('Evaluations')} -->
|
||||
<!-- {$i18n.t('External Tools')} -->
|
||||
<!-- {$i18n.t('Integrations')} -->
|
||||
<!-- {$i18n.t('Documents')} -->
|
||||
<!-- {$i18n.t('Web Search')} -->
|
||||
<!-- {$i18n.t('Code Execution')} -->
|
||||
@@ -370,7 +370,7 @@
|
||||
</svg>
|
||||
{:else if tab.id === 'evaluations'}
|
||||
<DocumentChartBar />
|
||||
{:else if tab.id === 'tools'}
|
||||
{:else if tab.id === 'integrations'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -525,8 +525,8 @@
|
||||
<Models />
|
||||
{:else if selectedTab === 'evaluations'}
|
||||
<Evaluations />
|
||||
{:else if selectedTab === 'tools'}
|
||||
<Tools />
|
||||
{:else if selectedTab === 'integrations'}
|
||||
<Integrations />
|
||||
{:else if selectedTab === 'documents'}
|
||||
<Documents
|
||||
on:save={async () => {
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
>
|
||||
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
|
||||
{#each engines as engine}
|
||||
<option value={engine}>{engine}</option>
|
||||
<option value={engine}>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -193,7 +194,9 @@
|
||||
>
|
||||
<option disabled selected value="">{$i18n.t('Select a engine')}</option>
|
||||
{#each engines as engine}
|
||||
<option value={engine}>{engine}</option>
|
||||
<option value={engine}
|
||||
>{engine}{engine === 'jupyter' ? ' (Legacy)' : ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -221,6 +221,12 @@
|
||||
|
||||
const res = await updateRAGConfig(localStorage.token, {
|
||||
...RAGConfig,
|
||||
// Convert null (from cleared number inputs) to empty string so the backend
|
||||
// can distinguish "clear this field" from "don't change this field"
|
||||
FILE_MAX_SIZE: RAGConfig.FILE_MAX_SIZE ?? '',
|
||||
FILE_MAX_COUNT: RAGConfig.FILE_MAX_COUNT ?? '',
|
||||
FILE_IMAGE_COMPRESSION_WIDTH: RAGConfig.FILE_IMAGE_COMPRESSION_WIDTH ?? '',
|
||||
FILE_IMAGE_COMPRESSION_HEIGHT: RAGConfig.FILE_IMAGE_COMPRESSION_HEIGHT ?? '',
|
||||
ALLOWED_FILE_EXTENSIONS: RAGConfig.ALLOWED_FILE_EXTENSIONS.split(',')
|
||||
.map((ext) => ext.trim())
|
||||
.filter((ext) => ext !== ''),
|
||||
|
||||
@@ -420,9 +420,8 @@
|
||||
/>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<!-- https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints -->
|
||||
<a
|
||||
href="https://docs.openwebui.com/getting-started/api-endpoints"
|
||||
href="https://docs.openwebui.com/reference/api-endpoints"
|
||||
target="_blank"
|
||||
class=" text-gray-300 font-medium underline"
|
||||
>
|
||||
@@ -463,7 +462,7 @@
|
||||
<span class=" font-medium">{$i18n.t('Warning')}:</span>
|
||||
<span
|
||||
><a
|
||||
href="https://docs.openwebui.com/getting-started/env-configuration#jwt_expires_in"
|
||||
href="https://docs.openwebui.com/reference/env-configuration#jwt_expires_in"
|
||||
target="_blank"
|
||||
class=" underline"
|
||||
>{$i18n.t('No expiration can pose security risks.')}
|
||||
|
||||
@@ -1270,7 +1270,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"
|
||||
@@ -1279,9 +1279,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getModels as _getModels } from '$lib/apis';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { models, settings, user, terminalServers } from '$lib/stores';
|
||||
import { getTerminalServers } from '$lib/apis/terminal';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import Cloud from '$lib/components/icons/Cloud.svelte';
|
||||
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
|
||||
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
|
||||
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import {
|
||||
getToolServerConnections,
|
||||
setToolServerConnections,
|
||||
getTerminalServerConnections,
|
||||
setTerminalServerConnections
|
||||
} from '$lib/apis/configs';
|
||||
|
||||
export let saveSettings: Function;
|
||||
|
||||
let servers = null;
|
||||
let showConnectionModal = false;
|
||||
|
||||
// Terminal server admin connections
|
||||
let terminalConnections = [];
|
||||
let showAddTerminalModal = false;
|
||||
let editTerminalIdx: number | null = null;
|
||||
let showDeleteTerminalConfirm = false;
|
||||
let deleteTerminalIdx: number | null = null;
|
||||
|
||||
const addConnectionHandler = async (server) => {
|
||||
servers = [...servers, server];
|
||||
await updateHandler();
|
||||
};
|
||||
|
||||
const updateHandler = async () => {
|
||||
const res = await setToolServerConnections(localStorage.token, {
|
||||
TOOL_SERVER_CONNECTIONS: servers
|
||||
}).catch((err) => {
|
||||
toast.error($i18n.t('Failed to save connections'));
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Connections saved successfully'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveTerminalServers = async () => {
|
||||
const res = await setTerminalServerConnections(localStorage.token, {
|
||||
TERMINAL_SERVER_CONNECTIONS: terminalConnections
|
||||
}).catch((err) => {
|
||||
toast.error($i18n.t('Failed to save terminal servers'));
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Terminal servers saved'));
|
||||
|
||||
// Refresh the terminalServers store so changes are reflected immediately
|
||||
// Preserve user direct terminals, refresh system terminals from backend
|
||||
const existingDirectTerminals = ($terminalServers ?? []).filter((t) => !t.id);
|
||||
const systemTerminals = await getTerminalServers(localStorage.token);
|
||||
const systemEntries = systemTerminals.map((t) => ({
|
||||
id: t.id,
|
||||
url: `${WEBUI_API_BASE_URL}/terminals/${t.id}`,
|
||||
name: t.name,
|
||||
key: localStorage.token
|
||||
}));
|
||||
terminalServers.set([...existingDirectTerminals, ...systemEntries]);
|
||||
}
|
||||
};
|
||||
|
||||
const addTerminalConnection = (server) => {
|
||||
terminalConnections = [...terminalConnections, { ...server, id: server.id ?? uuidv4() }];
|
||||
saveTerminalServers();
|
||||
};
|
||||
|
||||
const updateTerminalConnection = (idx: number, updated) => {
|
||||
terminalConnections = terminalConnections.map((c, i) =>
|
||||
i === idx ? { ...c, ...updated, id: updated.id ?? c.id } : c
|
||||
);
|
||||
saveTerminalServers();
|
||||
};
|
||||
|
||||
const removeTerminalConnection = (idx: number) => {
|
||||
terminalConnections = terminalConnections.filter((_, i) => i !== idx);
|
||||
saveTerminalServers();
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const res = await getToolServerConnections(localStorage.token);
|
||||
servers = res.TOOL_SERVER_CONNECTIONS;
|
||||
|
||||
// Load terminal server connections
|
||||
try {
|
||||
const terminalRes = await getTerminalServerConnections(localStorage.token);
|
||||
if (terminalRes?.TERMINAL_SERVER_CONNECTIONS) {
|
||||
terminalConnections = terminalRes.TERMINAL_SERVER_CONNECTIONS;
|
||||
}
|
||||
} catch {
|
||||
// Not configured yet
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
|
||||
<AddTerminalServerModal
|
||||
admin
|
||||
bind:show={showAddTerminalModal}
|
||||
edit={editTerminalIdx !== null}
|
||||
connection={editTerminalIdx !== null ? terminalConnections[editTerminalIdx] : null}
|
||||
onSubmit={(c) => {
|
||||
if (editTerminalIdx !== null) {
|
||||
updateTerminalConnection(editTerminalIdx, c);
|
||||
editTerminalIdx = null;
|
||||
} else {
|
||||
addTerminalConnection(c);
|
||||
}
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (editTerminalIdx !== null) {
|
||||
deleteTerminalIdx = editTerminalIdx;
|
||||
showDeleteTerminalConfirm = true;
|
||||
editTerminalIdx = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteTerminalConfirm}
|
||||
on:confirm={() => {
|
||||
if (deleteTerminalIdx !== null) {
|
||||
removeTerminalConnection(deleteTerminalIdx);
|
||||
deleteTerminalIdx = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<form
|
||||
class="flex flex-col h-full justify-between text-sm"
|
||||
on:submit|preventDefault={() => {
|
||||
updateHandler();
|
||||
}}
|
||||
>
|
||||
<div class=" overflow-y-scroll scrollbar-hidden h-full">
|
||||
{#if servers !== null}
|
||||
<div class="">
|
||||
<div class="mb-3">
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class="mb-2.5 flex flex-col w-full justify-between">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
|
||||
|
||||
<Tooltip content={$i18n.t(`Add Connection`)}>
|
||||
<button
|
||||
class="px-1"
|
||||
on:click={() => {
|
||||
showConnectionModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each servers as server, idx}
|
||||
<Connection
|
||||
bind:connection={server}
|
||||
onSubmit={() => {
|
||||
updateHandler();
|
||||
}}
|
||||
onDelete={() => {
|
||||
servers = servers.filter((_, i) => i !== idx);
|
||||
updateHandler();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if servers.length === 0}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('No tool server connections configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-1.5">
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
|
||||
|
||||
<div class="mb-2.5 flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium">{$i18n.t('Open Terminal')}</div>
|
||||
<span
|
||||
class="text-[0.65rem] font-medium uppercase px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>{$i18n.t('Experimental')}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('Add Connection')}>
|
||||
<button
|
||||
class="px-1"
|
||||
on:click={() => {
|
||||
editTerminalIdx = null;
|
||||
showAddTerminalModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each terminalConnections as connection, idx}
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
<Tooltip className="w-full relative" content={''} placement="top-start">
|
||||
<div class="flex w-full">
|
||||
<div
|
||||
class="flex-1 relative flex gap-1.5 items-center {connection?.enabled ===
|
||||
false
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
>
|
||||
<Tooltip content={$i18n.t('Terminal')}>
|
||||
<Cloud className="size-4" strokeWidth="1.5" />
|
||||
</Tooltip>
|
||||
|
||||
<div class="outline-hidden w-full bg-transparent text-sm">
|
||||
{connection.name || connection.url || $i18n.t('New Terminal')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<Tooltip content={$i18n.t('Configure')}>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
on:click={() => {
|
||||
editTerminalIdx = idx;
|
||||
showAddTerminalModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Cog6 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={connection?.enabled !== false
|
||||
? $i18n.t('Enabled')
|
||||
: $i18n.t('Disabled')}
|
||||
>
|
||||
<Switch
|
||||
state={connection?.enabled !== false}
|
||||
on:change={() => {
|
||||
terminalConnections = terminalConnections.map((c, i) =>
|
||||
i === idx ? { ...c, enabled: !(c?.enabled !== false) } : c
|
||||
);
|
||||
saveTerminalServers();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if terminalConnections.length === 0}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('No terminal connections configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1.5">
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t(
|
||||
'Connect to Open Terminal instances. All users will have access to file browsing and terminal tools through these servers.'
|
||||
)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300 mt-1">
|
||||
<a
|
||||
class="underline"
|
||||
href="https://github.com/open-webui/open-terminal"
|
||||
target="_blank">{$i18n.t('Learn more about Open Terminal')} ↗</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full justify-center">
|
||||
<div class="my-auto">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -64,9 +64,7 @@
|
||||
bind:value={banner.type}
|
||||
required
|
||||
>
|
||||
{#if banner.type == ''}
|
||||
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
|
||||
{/if}
|
||||
<option value="" disabled hidden class="text-gray-900">{$i18n.t('Type')}</option>
|
||||
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
|
||||
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
|
||||
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
|
||||
import Eye from '$lib/components/icons/Eye.svelte';
|
||||
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
@@ -69,6 +71,12 @@
|
||||
const perPage = 30;
|
||||
let currentPage = 1;
|
||||
|
||||
const isPublicModel = (model) => {
|
||||
return (model?.access_grants ?? []).some(
|
||||
(g) => g.principal_type === 'user' && g.principal_id === '*' && g.permission === 'read'
|
||||
);
|
||||
};
|
||||
|
||||
$: if (models) {
|
||||
filteredModels = models
|
||||
.filter((m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase()))
|
||||
@@ -77,6 +85,8 @@
|
||||
if (viewOption === 'disabled') return !(m?.is_active ?? true);
|
||||
if (viewOption === 'visible') return !(m?.meta?.hidden ?? false);
|
||||
if (viewOption === 'hidden') return m?.meta?.hidden === true;
|
||||
if (viewOption === 'public') return isPublicModel(m);
|
||||
if (viewOption === 'private') return !isPublicModel(m);
|
||||
return true; // All
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -110,6 +120,30 @@
|
||||
);
|
||||
};
|
||||
|
||||
const showAllHandler = async () => {
|
||||
const modelsToShow = filteredModels.filter((m) => m?.meta?.hidden === true);
|
||||
// Optimistic UI update
|
||||
modelsToShow.forEach((m) => {
|
||||
m.meta = { ...m.meta, hidden: false };
|
||||
});
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToShow.map((model) => upsertModelHandler(model, false)));
|
||||
toast.success($i18n.t('All models are now visible'));
|
||||
};
|
||||
|
||||
const hideAllHandler = async () => {
|
||||
const modelsToHide = filteredModels.filter((m) => !(m?.meta?.hidden ?? false));
|
||||
// Optimistic UI update
|
||||
modelsToHide.forEach((m) => {
|
||||
m.meta = { ...m.meta, hidden: true };
|
||||
});
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToHide.map((model) => upsertModelHandler(model, false)));
|
||||
toast.success($i18n.t('All models are now hidden'));
|
||||
};
|
||||
|
||||
const downloadModels = async (models) => {
|
||||
let blob = new Blob([JSON.stringify(models)], {
|
||||
type: 'application/json'
|
||||
@@ -143,7 +177,7 @@
|
||||
});
|
||||
};
|
||||
|
||||
const upsertModelHandler = async (model) => {
|
||||
const upsertModelHandler = async (model, showToast = true) => {
|
||||
model.base_model_id = null;
|
||||
|
||||
if (workspaceModels.find((m) => m.id === model.id)) {
|
||||
@@ -151,7 +185,7 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
if (res && showToast) {
|
||||
toast.success($i18n.t('Model updated successfully'));
|
||||
}
|
||||
} else {
|
||||
@@ -167,7 +201,7 @@
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
if (res && !silent) {
|
||||
toast.success($i18n.t('Model updated successfully'));
|
||||
}
|
||||
}
|
||||
@@ -215,6 +249,8 @@
|
||||
|
||||
console.debug(model);
|
||||
|
||||
upsertModelHandler(model, false);
|
||||
|
||||
toast.success(
|
||||
model.meta.hidden
|
||||
? $i18n.t(`Model {{name}} is now hidden`, {
|
||||
@@ -224,8 +260,6 @@
|
||||
name: model.id
|
||||
})
|
||||
);
|
||||
|
||||
upsertModelHandler(model);
|
||||
};
|
||||
|
||||
const copyLinkHandler = async (model) => {
|
||||
@@ -474,7 +508,7 @@
|
||||
enableAllHandler();
|
||||
}}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
<CheckCircle className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Enable All')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -484,9 +518,31 @@
|
||||
disableAllHandler();
|
||||
}}
|
||||
>
|
||||
<EyeSlash className="size-4" />
|
||||
<Minus className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Disable All')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<hr class="border-gray-100 dark:border-gray-800 my-1" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
showAllHandler();
|
||||
}}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Show All')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
hideAllHandler();
|
||||
}}
|
||||
>
|
||||
<EyeSlash className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Hide All')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
{ value: 'enabled', label: $i18n.t('Enabled') },
|
||||
{ value: 'disabled', label: $i18n.t('Disabled') },
|
||||
{ value: 'visible', label: $i18n.t('Visible') },
|
||||
{ value: 'hidden', label: $i18n.t('Hidden') }
|
||||
{ value: 'hidden', label: $i18n.t('Hidden') },
|
||||
{ value: 'public', label: $i18n.t('Public') },
|
||||
{ value: 'private', label: $i18n.t('Private') }
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
import { createEventDispatcher, getContext, onMount } from 'svelte';
|
||||
import { createEventDispatcher, getContext, onDestroy, onMount, tick } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { models } from '$lib/stores';
|
||||
@@ -14,20 +14,27 @@
|
||||
let modelListElement = null;
|
||||
|
||||
const positionChangeHandler = () => {
|
||||
const modelList = Array.from(modelListElement.children).map((child) =>
|
||||
// Read new order from DOM
|
||||
const newOrder = Array.from(modelListElement.children).map((child) =>
|
||||
child.id.replace('model-item-', '')
|
||||
);
|
||||
|
||||
modelIds = modelList;
|
||||
// Revert SortableJS DOM manipulation so Svelte stays in control of the DOM
|
||||
if (sortable) {
|
||||
sortable.sort(
|
||||
modelIds.map((id) => `model-item-${id}`),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Update reactive data — Svelte will re-render with the new order
|
||||
modelIds = newOrder;
|
||||
};
|
||||
|
||||
$: if (modelIds) {
|
||||
init();
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
const initSortable = () => {
|
||||
if (sortable) {
|
||||
sortable.destroy();
|
||||
sortable = null;
|
||||
}
|
||||
|
||||
if (modelListElement) {
|
||||
@@ -40,11 +47,24 @@
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// Wait a tick for the {#if} block to render and bind modelListElement
|
||||
tick().then(() => {
|
||||
initSortable();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (sortable) {
|
||||
sortable.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if modelIds.length > 0}
|
||||
<div class="flex flex-col -translate-x-1" bind:this={modelListElement}>
|
||||
{#each modelIds as modelId, modelIdx (`${modelId}-${modelIdx}`)}
|
||||
{#each modelIds as modelId (modelId)}
|
||||
<div class=" flex gap-2 w-full justify-between items-center" id="model-item-{modelId}">
|
||||
<Tooltip content={modelId} placement="top-start">
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -436,7 +436,7 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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"
|
||||
@@ -445,9 +445,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
|
||||
import { getModels as _getModels } from '$lib/apis';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { models, settings, user } from '$lib/stores';
|
||||
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
|
||||
|
||||
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
|
||||
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
|
||||
|
||||
export let saveSettings: Function;
|
||||
|
||||
let servers = null;
|
||||
let showConnectionModal = false;
|
||||
|
||||
const addConnectionHandler = async (server) => {
|
||||
servers = [...servers, server];
|
||||
await updateHandler();
|
||||
};
|
||||
|
||||
const updateHandler = async () => {
|
||||
const res = await setToolServerConnections(localStorage.token, {
|
||||
TOOL_SERVER_CONNECTIONS: servers
|
||||
}).catch((err) => {
|
||||
toast.error($i18n.t('Failed to save connections'));
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Connections saved successfully'));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const res = await getToolServerConnections(localStorage.token);
|
||||
servers = res.TOOL_SERVER_CONNECTIONS;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
|
||||
<form
|
||||
class="flex flex-col h-full justify-between text-sm"
|
||||
on:submit|preventDefault={() => {
|
||||
updateHandler();
|
||||
}}
|
||||
>
|
||||
<div class=" overflow-y-scroll scrollbar-hidden h-full">
|
||||
{#if servers !== null}
|
||||
<div class="">
|
||||
<div class="mb-3">
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('General')}</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class="mb-2.5 flex flex-col w-full justify-between">
|
||||
<!-- {$i18n.t(`Failed to connect to {{URL}} OpenAPI tool server`, {
|
||||
URL: 'server?.url'
|
||||
})} -->
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="font-medium">{$i18n.t('Manage Tool Servers')}</div>
|
||||
|
||||
<Tooltip content={$i18n.t(`Add Connection`)}>
|
||||
<button
|
||||
class="px-1"
|
||||
on:click={() => {
|
||||
showConnectionModal = true;
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each servers as server, idx}
|
||||
<Connection
|
||||
bind:connection={server}
|
||||
onSubmit={() => {
|
||||
updateHandler();
|
||||
}}
|
||||
onDelete={() => {
|
||||
servers = servers.filter((_, i) => i !== idx);
|
||||
updateHandler();
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="my-1.5">
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('Connect to your own OpenAPI compatible external tool servers.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="mb-2.5 flex w-full justify-between">
|
||||
<div class=" text-xs font-medium">{$i18n.t('Arena Models')}</div>
|
||||
|
||||
<Tooltip content={$i18n.t(`Message rating should be enabled to use this feature`)}>
|
||||
<Switch bind:state={evaluationConfig.ENABLE_EVALUATION_ARENA_MODELS} />
|
||||
</Tooltip>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full justify-center">
|
||||
<div class="my-auto">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="submit"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -64,6 +64,7 @@
|
||||
permissions = {
|
||||
workspace: { ...DEFAULT_PERMISSIONS.workspace, ...loadedPermissions.workspace },
|
||||
sharing: { ...DEFAULT_PERMISSIONS.sharing, ...loadedPermissions.sharing },
|
||||
access_grants: { ...DEFAULT_PERMISSIONS.access_grants, ...loadedPermissions.access_grants },
|
||||
chat: { ...DEFAULT_PERMISSIONS.chat, ...loadedPermissions.chat },
|
||||
features: { ...DEFAULT_PERMISSIONS.features, ...loadedPermissions.features },
|
||||
settings: { ...DEFAULT_PERMISSIONS.settings, ...loadedPermissions.settings }
|
||||
@@ -218,7 +219,7 @@
|
||||
{#if ['general', 'permissions'].includes(selectedTab)}
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
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"
|
||||
@@ -227,9 +228,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="flex items-center gap-2 p-1">
|
||||
<select
|
||||
class="text-sm bg-transparent outline-hidden rounded-lg px-2"
|
||||
value={data?.config?.share ?? true}
|
||||
value={data?.config?.share ?? 'members'}
|
||||
on:change={(e) => {
|
||||
const value = e.target.value;
|
||||
let shareValue;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
...obj,
|
||||
workspace: { ...defaults.workspace, ...obj.workspace },
|
||||
sharing: { ...defaults.sharing, ...obj.sharing },
|
||||
access_grants: { ...defaults.access_grants, ...obj.access_grants },
|
||||
chat: { ...defaults.chat, ...obj.chat },
|
||||
features: { ...defaults.features, ...obj.features },
|
||||
settings: { ...defaults.settings, ...obj.settings }
|
||||
@@ -395,6 +396,28 @@
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
|
||||
<div>
|
||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('Access Grants')}</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex w-full justify-between my-1">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow Sharing With Users')}
|
||||
</div>
|
||||
<Switch bind:state={permissions.access_grants.allow_users} />
|
||||
</div>
|
||||
{#if defaultPermissions?.access_grants?.allow_users && !permissions.access_grants.allow_users}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
|
||||
<div>
|
||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
let orderBy = 'created_at'; // default sort key
|
||||
let orderBy = groupId ? `group_id:${groupId}` : 'last_active_at'; // default sort key
|
||||
let direction = 'desc'; // default sort order
|
||||
|
||||
let page = 1;
|
||||
|
||||
@@ -285,7 +285,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"
|
||||
@@ -294,9 +294,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { tick, getContext, onMount, onDestroy } from 'svelte';
|
||||
import { tick, getContext, onMount } from 'svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -510,7 +510,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e) => {
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Check if a file is being draggedOver.
|
||||
@@ -525,7 +525,7 @@
|
||||
draggedOver = false;
|
||||
};
|
||||
|
||||
const onDrop = async (e) => {
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.dataTransfer?.files && acceptFiles) {
|
||||
@@ -567,7 +567,7 @@
|
||||
onChange();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
suggestions = [
|
||||
{
|
||||
char: '@',
|
||||
@@ -633,25 +633,33 @@
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
await tick();
|
||||
|
||||
const dropzoneElement = document.getElementById('channel-container');
|
||||
let isDestroyed = false;
|
||||
let dropzoneElement: HTMLElement | null = null;
|
||||
const initialize = async () => {
|
||||
await tick();
|
||||
if (isDestroyed) return;
|
||||
|
||||
dropzoneElement?.addEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.addEventListener('drop', onDrop);
|
||||
dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
||||
});
|
||||
dropzoneElement = document.getElementById('channel-container');
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement.addEventListener('dragover', onDragOver);
|
||||
dropzoneElement.addEventListener('drop', onDrop);
|
||||
dropzoneElement.addEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
};
|
||||
initialize();
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
isDestroyed = true;
|
||||
|
||||
const dropzoneElement = document.getElementById('channel-container');
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement?.removeEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.removeEventListener('drop', onDrop);
|
||||
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement.removeEventListener('dragover', onDragOver);
|
||||
dropzoneElement.removeEventListener('drop', onDrop);
|
||||
dropzoneElement.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -98,16 +98,17 @@
|
||||
return onKeyDown(event);
|
||||
}
|
||||
|
||||
const keydownListener = (e) => {
|
||||
// required to prevent the default enter behavior
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
select(selectedIndex);
|
||||
}
|
||||
};
|
||||
onMount(() => {
|
||||
const keydownListener = (e: KeyboardEvent) => {
|
||||
// required to prevent the default enter behavior
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
select(selectedIndex);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('keydown', keydownListener);
|
||||
|
||||
if (channelSuggestions) {
|
||||
// Add a dummy channel item
|
||||
_channels = [
|
||||
@@ -117,17 +118,17 @@
|
||||
];
|
||||
} else {
|
||||
if (userSuggestions) {
|
||||
await getUserList();
|
||||
getUserList();
|
||||
}
|
||||
|
||||
if (modelSuggestions) {
|
||||
_models = [...$models.map((m) => ({ type: 'model', id: m.id, label: m.name, data: m }))];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', keydownListener);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keydownListener);
|
||||
};
|
||||
});
|
||||
|
||||
const hasPublicReadGrant = (grants: any) =>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
<div class="flex justify-end text-sm font-medium gap-1.5">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {isSaving
|
||||
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 {isSaving
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
@@ -168,9 +168,9 @@
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
{#if isSaving}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -90,24 +90,32 @@
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
artifactCode.subscribe((value) => {
|
||||
const unsubscribeArtifactCode = artifactCode.subscribe((value) => {
|
||||
if (contents) {
|
||||
const codeIdx = contents.findIndex((content) => content.content.includes(value));
|
||||
selectedContentIdx = codeIdx !== -1 ? codeIdx : 0;
|
||||
}
|
||||
});
|
||||
|
||||
artifactContents.subscribe((value) => {
|
||||
contents = value;
|
||||
console.log('Artifact contents updated:', contents);
|
||||
const unsubscribeArtifactContents = artifactContents.subscribe((value) => {
|
||||
const newContents = value ?? [];
|
||||
console.log('Artifact contents updated:', newContents);
|
||||
|
||||
if (contents.length === 0) {
|
||||
if (newContents.length === 0) {
|
||||
showControls.set(false);
|
||||
showArtifacts.set(false);
|
||||
selectedContentIdx = 0;
|
||||
} else if (newContents.length > contents.length) {
|
||||
selectedContentIdx = newContents.length - 1;
|
||||
}
|
||||
|
||||
selectedContentIdx = contents ? contents.length - 1 : 0;
|
||||
contents = newContents;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeArtifactCode();
|
||||
unsubscribeArtifactContents();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+257
-148
@@ -33,18 +33,23 @@
|
||||
currentChatPage,
|
||||
temporaryChatEnabled,
|
||||
mobile,
|
||||
showOverview,
|
||||
chatTitle,
|
||||
showArtifacts,
|
||||
artifactContents,
|
||||
tools,
|
||||
toolServers,
|
||||
terminalServers,
|
||||
functions,
|
||||
selectedFolder,
|
||||
pinnedChats,
|
||||
showEmbeds
|
||||
showEmbeds,
|
||||
selectedTerminalId,
|
||||
showFileNavPath,
|
||||
showFileNavDir
|
||||
} from '$lib/stores';
|
||||
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
import {
|
||||
convertMessagesToHistory,
|
||||
copyToClipboard,
|
||||
@@ -54,11 +59,13 @@
|
||||
processDetails,
|
||||
removeAllDetails,
|
||||
getCodeBlockContents,
|
||||
isYoutubeUrl
|
||||
isYoutubeUrl,
|
||||
displayFileHandler
|
||||
} from '$lib/utils';
|
||||
import { AudioQueue } from '$lib/utils/audio';
|
||||
|
||||
import {
|
||||
archiveChatById,
|
||||
createNewChat,
|
||||
getAllTags,
|
||||
getChatById,
|
||||
@@ -92,21 +99,23 @@
|
||||
import ChatControls from './ChatControls.svelte';
|
||||
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import Placeholder from './Placeholder.svelte';
|
||||
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
||||
import NotificationToast from '../NotificationToast.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Sidebar from '../icons/Sidebar.svelte';
|
||||
import Image from '../common/Image.svelte';
|
||||
import { getBanners } from '$lib/apis/configs';
|
||||
|
||||
export let chatIdProp = '';
|
||||
|
||||
let loading = true;
|
||||
|
||||
const eventTarget = new EventTarget();
|
||||
let controlPane;
|
||||
let controlPaneComponent;
|
||||
let controlPane: Pane | undefined;
|
||||
let controlPaneComponent: ChatControls | undefined;
|
||||
|
||||
let messageInput;
|
||||
let messageInput: MessageInput | undefined;
|
||||
|
||||
let autoScroll = true;
|
||||
let processing = '';
|
||||
@@ -123,8 +132,6 @@
|
||||
let eventConfirmationInputType = '';
|
||||
let eventCallback = null;
|
||||
|
||||
let chatIdUnsubscriber: Unsubscriber | undefined;
|
||||
|
||||
let selectedModels = [''];
|
||||
let atSelectedModel: Model | undefined;
|
||||
let selectedModelIds = [];
|
||||
@@ -136,6 +143,7 @@
|
||||
|
||||
let selectedToolIds = [];
|
||||
let selectedFilterIds = [];
|
||||
|
||||
let imageGenerationEnabled = false;
|
||||
let webSearchEnabled = false;
|
||||
let codeInterpreterEnabled = false;
|
||||
@@ -143,6 +151,7 @@
|
||||
let showCommands = false;
|
||||
|
||||
let generating = false;
|
||||
let dragged = false;
|
||||
let generationController = null;
|
||||
|
||||
let chat = null;
|
||||
@@ -284,7 +293,7 @@
|
||||
|
||||
const onSelectedModelIdsChange = () => {
|
||||
resetInput();
|
||||
oldSelectedModelIds = JSON.parse(JSON.stringify(selectedModelIds));
|
||||
oldSelectedModelIds = structuredClone(selectedModelIds);
|
||||
};
|
||||
|
||||
const resetInput = () => {
|
||||
@@ -319,6 +328,10 @@
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
||||
)
|
||||
];
|
||||
} else if ($settings?.tools) {
|
||||
selectedToolIds = $settings.tools;
|
||||
} else {
|
||||
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
|
||||
}
|
||||
|
||||
// Set Default Filters (Toggleable only)
|
||||
@@ -393,6 +406,18 @@
|
||||
saveChatHandler(_chatId, history);
|
||||
};
|
||||
|
||||
const terminalEventHandler = (type: string, data: any) => {
|
||||
if (type === 'terminal:display_file') {
|
||||
if (!data?.path) return;
|
||||
displayFileHandler(data.path, { showControls, showFileNavPath });
|
||||
} else if (type === 'terminal:write_file' || type === 'terminal:replace_file_content') {
|
||||
if (!data?.path) return;
|
||||
showFileNavDir.set(data.path);
|
||||
} else if (type === 'terminal:run_command') {
|
||||
showFileNavDir.set('/');
|
||||
}
|
||||
};
|
||||
|
||||
const chatEventHandler = async (event, cb) => {
|
||||
console.log(event);
|
||||
|
||||
@@ -526,6 +551,8 @@
|
||||
eventConfirmationInputPlaceholder = data.placeholder;
|
||||
eventConfirmationInputValue = data?.value ?? '';
|
||||
eventConfirmationInputType = data?.type ?? '';
|
||||
} else if (type.startsWith('terminal:')) {
|
||||
terminalEventHandler(type, data);
|
||||
} else {
|
||||
console.log('Unknown message type', data);
|
||||
}
|
||||
@@ -592,74 +619,54 @@
|
||||
savedModelIds();
|
||||
}
|
||||
|
||||
let pageSubscribe = null;
|
||||
let showControlsSubscribe = null;
|
||||
let selectedFolderSubscribe = null;
|
||||
|
||||
const stopAudio = () => {
|
||||
try {
|
||||
speechSynthesis.cancel();
|
||||
$audioQueue.stop();
|
||||
$audioQueue?.stop();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
loading = true;
|
||||
console.log('mounted');
|
||||
window.addEventListener('message', onMessageHandler);
|
||||
$socket?.on('events', chatEventHandler);
|
||||
|
||||
audioQueue.set(new AudioQueue(document.getElementById('audioElement')));
|
||||
$audioQueue?.destroy();
|
||||
|
||||
pageSubscribe = page.subscribe(async (p) => {
|
||||
const audioQueueInstance = new AudioQueue(document.getElementById('audioElement'));
|
||||
audioQueue.set(audioQueueInstance);
|
||||
|
||||
// Reset direct terminal enabled states — selectedTerminalId starts null on every page load
|
||||
if ($settings?.terminalServers?.some((s) => s.enabled)) {
|
||||
settings.set({
|
||||
...$settings,
|
||||
terminalServers: ($settings.terminalServers ?? []).map((s) => ({ ...s, enabled: false }))
|
||||
});
|
||||
}
|
||||
|
||||
const pageSubscribe = page.subscribe(async (p) => {
|
||||
if (p.url.pathname === '/') {
|
||||
await tick();
|
||||
initNewChat();
|
||||
|
||||
// Re-fetch banners on navigation to homepage so newly configured banners appear
|
||||
try {
|
||||
banners.set(await getBanners(localStorage.token).catch(() => []));
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh banners:', e);
|
||||
}
|
||||
}
|
||||
|
||||
stopAudio();
|
||||
});
|
||||
|
||||
const storageChatInput = sessionStorage.getItem(
|
||||
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
||||
);
|
||||
|
||||
if (!chatIdProp) {
|
||||
loading = false;
|
||||
const showControlsSubscribe = showControls.subscribe(async (value) => {
|
||||
await tick();
|
||||
}
|
||||
|
||||
if (storageChatInput) {
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
|
||||
try {
|
||||
const input = JSON.parse(storageChatInput);
|
||||
|
||||
if (!$temporaryChatEnabled) {
|
||||
messageInput?.setText(input.prompt);
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
selectedFilterIds = input.selectedFilterIds;
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
codeInterpreterEnabled = input.codeInterpreterEnabled;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
showControlsSubscribe = showControls.subscribe(async (value) => {
|
||||
if (controlPane && !$mobile) {
|
||||
try {
|
||||
if (value) {
|
||||
controlPaneComponent.openPane();
|
||||
controlPaneComponent?.openPane();
|
||||
} else {
|
||||
controlPane.collapse();
|
||||
}
|
||||
@@ -670,13 +677,13 @@
|
||||
|
||||
if (!value) {
|
||||
showCallOverlay.set(false);
|
||||
showOverview.set(false);
|
||||
showArtifacts.set(false);
|
||||
showEmbeds.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
|
||||
const selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
|
||||
await tick();
|
||||
if (
|
||||
folder?.data?.model_ids &&
|
||||
JSON.stringify(selectedModels) !== JSON.stringify(folder.data.model_ids)
|
||||
@@ -687,22 +694,60 @@
|
||||
}
|
||||
});
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
});
|
||||
const storageChatInput = sessionStorage.getItem(
|
||||
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
||||
);
|
||||
|
||||
onDestroy(() => {
|
||||
try {
|
||||
pageSubscribe();
|
||||
showControlsSubscribe();
|
||||
selectedFolderSubscribe();
|
||||
chatIdUnsubscriber?.();
|
||||
window.removeEventListener('message', onMessageHandler);
|
||||
$socket?.off('events', chatEventHandler);
|
||||
$audioQueue?.destroy();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const init = async () => {
|
||||
if (!chatIdProp) {
|
||||
loading = false;
|
||||
await tick();
|
||||
}
|
||||
|
||||
if (storageChatInput) {
|
||||
prompt = '';
|
||||
messageInput?.setText('');
|
||||
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
|
||||
try {
|
||||
const input = JSON.parse(storageChatInput);
|
||||
|
||||
if (!$temporaryChatEnabled) {
|
||||
messageInput?.setText(input.prompt);
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
selectedFilterIds = input.selectedFilterIds;
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
codeInterpreterEnabled = input.codeInterpreterEnabled;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
chatInput?.focus();
|
||||
};
|
||||
init();
|
||||
|
||||
return () => {
|
||||
try {
|
||||
pageSubscribe();
|
||||
showControlsSubscribe();
|
||||
selectedFolderSubscribe();
|
||||
window.removeEventListener('message', onMessageHandler);
|
||||
$socket?.off('events', chatEventHandler);
|
||||
audioQueueInstance?.destroy();
|
||||
audioQueue.set(null);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// File upload functions
|
||||
@@ -889,11 +934,19 @@
|
||||
}
|
||||
};
|
||||
|
||||
$: if (history) {
|
||||
getContents();
|
||||
} else {
|
||||
artifactContents.set([]);
|
||||
}
|
||||
const onHistoryChange = (history) => {
|
||||
if (history) {
|
||||
cancelAnimationFrame(contentsRAF);
|
||||
contentsRAF = requestAnimationFrame(() => {
|
||||
getContents();
|
||||
contentsRAF = null;
|
||||
});
|
||||
} else {
|
||||
artifactContents.set([]);
|
||||
}
|
||||
};
|
||||
|
||||
$: onHistoryChange(history);
|
||||
|
||||
const getContents = () => {
|
||||
const messages = history ? createMessagesList(history, history.currentId) : [];
|
||||
@@ -1053,9 +1106,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
await showControls.set(false);
|
||||
if ($mobile) {
|
||||
await showControls.set(false);
|
||||
}
|
||||
await showCallOverlay.set(false);
|
||||
await showOverview.set(false);
|
||||
await showArtifacts.set(false);
|
||||
|
||||
if ($page.url.pathname.includes('/c/')) {
|
||||
@@ -1168,7 +1222,7 @@
|
||||
selectedModels = selectedModels.length > 0 ? [selectedModels[0]] : [''];
|
||||
}
|
||||
|
||||
oldSelectedModelIds = JSON.parse(JSON.stringify(selectedModels));
|
||||
oldSelectedModelIds = structuredClone(selectedModels);
|
||||
|
||||
history =
|
||||
(chatContent?.history ?? undefined) !== undefined
|
||||
@@ -1217,6 +1271,17 @@
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let scrollRAF = null;
|
||||
let contentsRAF = null;
|
||||
const scheduleScrollToBottom = () => {
|
||||
if (!scrollRAF) {
|
||||
scrollRAF = requestAnimationFrame(async () => {
|
||||
scrollRAF = null;
|
||||
await scrollToBottom();
|
||||
});
|
||||
}
|
||||
};
|
||||
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
|
||||
const res = await chatCompleted(localStorage.token, {
|
||||
model: modelId,
|
||||
@@ -1512,27 +1577,29 @@
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
// Emit chat event for TTS
|
||||
const messageContentParts = getMessageContentParts(
|
||||
removeAllDetails(message.content),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
// Emit chat event for TTS (only when call overlay is active)
|
||||
if ($showCallOverlay) {
|
||||
const messageContentParts = getMessageContentParts(
|
||||
removeAllDetails(message.content),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1546,27 +1613,29 @@
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
// Emit chat event for TTS
|
||||
const messageContentParts = getMessageContentParts(
|
||||
removeAllDetails(message.content),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
// Emit chat event for TTS (only when call overlay is active)
|
||||
if ($showCallOverlay) {
|
||||
const messageContentParts = getMessageContentParts(
|
||||
removeAllDetails(message.content),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1593,18 +1662,20 @@
|
||||
document.getElementById(`speak-button-${message.id}`)?.click();
|
||||
}
|
||||
|
||||
// Emit chat event for TTS
|
||||
let lastMessageContentPart =
|
||||
getMessageContentParts(
|
||||
removeAllDetails(message.content),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
)?.at(-1) ?? '';
|
||||
if (lastMessageContentPart) {
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: { id: message.id, content: lastMessageContentPart }
|
||||
})
|
||||
);
|
||||
// Emit chat event for TTS (only when call overlay is active)
|
||||
if ($showCallOverlay) {
|
||||
let lastMessageContentPart =
|
||||
getMessageContentParts(
|
||||
removeAllDetails(message.content),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
)?.at(-1) ?? '';
|
||||
if (lastMessageContentPart) {
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: { id: message.id, content: lastMessageContentPart }
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat:finish', {
|
||||
@@ -1634,7 +1705,7 @@
|
||||
await tick();
|
||||
|
||||
if (autoScroll) {
|
||||
scrollToBottom();
|
||||
scheduleScrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1688,7 +1759,7 @@
|
||||
if (taskIds !== null && taskIds.length > 0) {
|
||||
if ($settings?.enableMessageQueue ?? true) {
|
||||
// Queue the message
|
||||
const _files = JSON.parse(JSON.stringify(files));
|
||||
const _files = structuredClone(files);
|
||||
messageQueue = [
|
||||
...messageQueue,
|
||||
{
|
||||
@@ -1723,7 +1794,7 @@
|
||||
prompt = '';
|
||||
|
||||
const messages = createMessagesList(history, history.currentId);
|
||||
const _files = JSON.parse(JSON.stringify(files));
|
||||
const _files = structuredClone(files);
|
||||
|
||||
chatFiles.push(
|
||||
..._files.filter(
|
||||
@@ -1792,7 +1863,7 @@
|
||||
}
|
||||
|
||||
let _chatId = JSON.parse(JSON.stringify($chatId));
|
||||
_history = JSON.parse(JSON.stringify(_history));
|
||||
_history = structuredClone(_history);
|
||||
|
||||
const responseMessageIds: Record<PropertyKey, string> = {};
|
||||
// If modelId is provided, use it, else use selected model
|
||||
@@ -1845,7 +1916,7 @@
|
||||
|
||||
await tick();
|
||||
|
||||
_history = JSON.parse(JSON.stringify(history));
|
||||
_history = structuredClone(history);
|
||||
// Save chat after all messages have been created
|
||||
await saveChatHandler(_chatId, _history);
|
||||
|
||||
@@ -1942,6 +2013,17 @@
|
||||
return features;
|
||||
};
|
||||
|
||||
const getStopTokens = () => {
|
||||
const stop = params?.stop ?? $settings?.params?.stop;
|
||||
if (!stop) return undefined;
|
||||
|
||||
const tokens = Array.isArray(stop) ? stop : stop.split(',').map((s) => s.trim());
|
||||
|
||||
return tokens
|
||||
.filter(Boolean)
|
||||
.map((token) => decodeURIComponent(JSON.parse(`"${token.replace(/"/g, '\\"')}"`)));
|
||||
};
|
||||
|
||||
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
|
||||
const responseMessage = _history.messages[responseMessageId];
|
||||
const userMessage = _history.messages[responseMessage.parentId];
|
||||
@@ -1956,7 +2038,7 @@
|
||||
return fileExists;
|
||||
});
|
||||
|
||||
let files = JSON.parse(JSON.stringify(chatFiles));
|
||||
let files = structuredClone(chatFiles);
|
||||
files.push(
|
||||
...(userMessage?.files ?? []).filter(
|
||||
(item) =>
|
||||
@@ -2091,6 +2173,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Use the user-selected terminal from the dropdown
|
||||
const activeTerminalId = $selectedTerminalId ?? null;
|
||||
|
||||
const res = await generateOpenAIChatCompletion(
|
||||
localStorage.token,
|
||||
{
|
||||
@@ -2100,12 +2185,7 @@
|
||||
params: {
|
||||
...$settings?.params,
|
||||
...params,
|
||||
stop:
|
||||
(params?.stop ?? $settings?.params?.stop ?? undefined)
|
||||
? (params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop).map(
|
||||
(str) => decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
|
||||
)
|
||||
: undefined
|
||||
stop: getStopTokens()
|
||||
},
|
||||
|
||||
files: (files?.length ?? 0) > 0 ? files : undefined,
|
||||
@@ -2113,9 +2193,14 @@
|
||||
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
||||
tool_ids: toolIds.length > 0 ? toolIds : undefined,
|
||||
skill_ids: skillIds.length > 0 ? skillIds : undefined,
|
||||
tool_servers: ($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
),
|
||||
terminal_id: activeTerminalId ?? undefined,
|
||||
tool_servers: [
|
||||
...($toolServers ?? []).filter(
|
||||
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
||||
),
|
||||
// Direct terminal servers — always included when enabled (not routed through selectedToolIds)
|
||||
...($terminalServers ?? []).filter((t) => !t.id)
|
||||
],
|
||||
features: getFeatures(),
|
||||
variables: {
|
||||
...getPromptVariables(
|
||||
@@ -2412,7 +2497,7 @@
|
||||
}
|
||||
|
||||
if (autoScroll) {
|
||||
scrollToBottom();
|
||||
scheduleScrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2528,6 +2613,25 @@
|
||||
toast.error($i18n.t('Failed to move chat'));
|
||||
}
|
||||
};
|
||||
|
||||
const archiveChatHandler = async (id: string) => {
|
||||
try {
|
||||
await archiveChatById(localStorage.token, id);
|
||||
currentChatPage.set(1);
|
||||
initNewChat();
|
||||
await goto('/');
|
||||
getChatList(localStorage.token, $currentChatPage).then((chats) => {
|
||||
chats.set(chats);
|
||||
});
|
||||
getPinnedChatList(localStorage.token).then((pinnedChats) => {
|
||||
pinnedChats.set(pinnedChats);
|
||||
});
|
||||
toast.success($i18n.t('Chat archived.'));
|
||||
} catch (error) {
|
||||
console.error('Error archiving chat:', error);
|
||||
toast.error($i18n.t('Failed to archive chat.'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -2591,6 +2695,7 @@
|
||||
|
||||
<PaneGroup direction="horizontal" class="w-full h-full">
|
||||
<Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
|
||||
<FilesOverlay show={dragged} />
|
||||
<Navbar
|
||||
bind:this={navbarElement}
|
||||
chat={{
|
||||
@@ -2609,7 +2714,7 @@
|
||||
bind:selectedModels
|
||||
shareEnabled={!!history.currentId}
|
||||
{initNewChat}
|
||||
archiveChatHandler={() => {}}
|
||||
{archiveChatHandler}
|
||||
{moveChatHandler}
|
||||
onSaveTempChat={async () => {
|
||||
try {
|
||||
@@ -2627,6 +2732,7 @@
|
||||
id: uuidv4(),
|
||||
title: title.length > 50 ? `${title.slice(0, 50)}...` : title,
|
||||
models: selectedModels,
|
||||
params: params,
|
||||
history: history,
|
||||
messages: messages,
|
||||
timestamp: Date.now()
|
||||
@@ -2649,7 +2755,7 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
|
||||
<div id="chat-pane" class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
|
||||
{#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
|
||||
<div
|
||||
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
|
||||
@@ -2703,6 +2809,7 @@
|
||||
bind:webSearchEnabled
|
||||
bind:atSelectedModel
|
||||
bind:showCommands
|
||||
bind:dragged
|
||||
toolServers={$toolServers}
|
||||
{generating}
|
||||
{stopResponse}
|
||||
@@ -2773,6 +2880,7 @@
|
||||
bind:webSearchEnabled
|
||||
bind:atSelectedModel
|
||||
bind:showCommands
|
||||
bind:dragged
|
||||
toolServers={$toolServers}
|
||||
{stopResponse}
|
||||
{createMessagePair}
|
||||
@@ -2816,6 +2924,7 @@
|
||||
{stopResponse}
|
||||
{showMessage}
|
||||
{eventTarget}
|
||||
{codeInterpreterEnabled}
|
||||
/>
|
||||
</PaneGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
<script context="module" lang="ts">
|
||||
let savedTab: 'controls' | 'files' | 'overview' = 'controls';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { SvelteFlowProvider } from '@xyflow/svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { Pane, PaneResizer } from 'paneforge';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { onDestroy, onMount, tick, getContext } from 'svelte';
|
||||
import {
|
||||
config,
|
||||
terminalServers,
|
||||
mobile,
|
||||
showControls,
|
||||
showCallOverlay,
|
||||
showOverview,
|
||||
showArtifacts,
|
||||
showEmbeds
|
||||
showEmbeds,
|
||||
settings,
|
||||
showFileNavPath,
|
||||
selectedTerminalId,
|
||||
user
|
||||
} from '$lib/stores';
|
||||
|
||||
import { uploadFile } from '$lib/apis/files';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Controls from './Controls/Controls.svelte';
|
||||
import CallOverlay from './MessageInput/CallOverlay.svelte';
|
||||
import Drawer from '../common/Drawer.svelte';
|
||||
import Artifacts from './Artifacts.svelte';
|
||||
import Embeds from './ChatControls/Embeds.svelte';
|
||||
import FileNav from './FileNav.svelte';
|
||||
import PyodideFileNav from './PyodideFileNav.svelte';
|
||||
import Overview from './Overview.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let history;
|
||||
export let models = [];
|
||||
@@ -34,13 +52,96 @@
|
||||
export let files;
|
||||
export let modelId;
|
||||
|
||||
export let pane;
|
||||
export let codeInterpreterEnabled = false;
|
||||
|
||||
export let pane: Pane | null = null;
|
||||
|
||||
let mediaQuery;
|
||||
let largeScreen = false;
|
||||
let dragged = false;
|
||||
|
||||
let minSize = 0;
|
||||
let paneReady = false;
|
||||
|
||||
// Tab state for Controls+Files panel
|
||||
let activeTab = savedTab;
|
||||
// svelte-ignore reactive_declaration_module_script_dependency
|
||||
$: {
|
||||
savedTab = activeTab;
|
||||
}
|
||||
|
||||
$: hasMessages = history?.messages && Object.keys(history.messages).length > 0;
|
||||
|
||||
$: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true);
|
||||
$: showFilesTab =
|
||||
!!$selectedTerminalId ||
|
||||
(codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter');
|
||||
$: showOverviewTab = hasMessages;
|
||||
|
||||
// Tab fallback: if active tab becomes hidden, switch to next available
|
||||
$: if (!showOverviewTab && activeTab === 'overview') activeTab = 'controls';
|
||||
$: if (!showFilesTab && activeTab === 'files') activeTab = 'controls';
|
||||
$: if (!showControlsTab && activeTab === 'controls') {
|
||||
if (showFilesTab) activeTab = 'files';
|
||||
else if (showOverviewTab) activeTab = 'overview';
|
||||
}
|
||||
|
||||
// Auto-close if there are no visible tabs
|
||||
$: if (!showControlsTab && !showFilesTab && !showOverviewTab) {
|
||||
showControls.set(false);
|
||||
}
|
||||
|
||||
// Auto-switch to Files tab when display_file is triggered
|
||||
$: if ($showFileNavPath) {
|
||||
activeTab = 'files';
|
||||
showControls.set(true);
|
||||
}
|
||||
|
||||
// Auto-open Files tab when a terminal is selected
|
||||
$: if ($selectedTerminalId) {
|
||||
activeTab = 'files';
|
||||
showControls.set(true);
|
||||
}
|
||||
|
||||
// Attach a terminal file to the chat input
|
||||
const handleTerminalAttach = async (blob: Blob, name: string, contentType: string) => {
|
||||
const tempItemId = uuidv4();
|
||||
const fileItem = {
|
||||
type: 'file',
|
||||
file: '',
|
||||
id: null,
|
||||
url: '',
|
||||
name,
|
||||
collection_name: '',
|
||||
status: 'uploading',
|
||||
error: '',
|
||||
itemId: tempItemId,
|
||||
size: blob.size
|
||||
};
|
||||
|
||||
files = [...files, fileItem];
|
||||
|
||||
try {
|
||||
const file = new File([blob], name, { type: contentType || 'application/octet-stream' });
|
||||
const uploaded = await uploadFile(localStorage.token, file);
|
||||
if (!uploaded) throw new Error('Upload failed');
|
||||
|
||||
const idx = files.findIndex((f) => f.itemId === tempItemId);
|
||||
if (idx !== -1) {
|
||||
files[idx] = {
|
||||
...fileItem,
|
||||
status: 'uploaded',
|
||||
file: uploaded,
|
||||
id: uploaded.id,
|
||||
url: `${uploaded.id}`,
|
||||
collection_name: uploaded?.meta?.collection_name
|
||||
};
|
||||
files = files;
|
||||
}
|
||||
toast.success($i18n.t('File attached to chat'));
|
||||
} catch (e) {
|
||||
files = files.filter((f) => f.itemId !== tempItemId);
|
||||
toast.error($i18n.t('Failed to attach file'));
|
||||
}
|
||||
};
|
||||
|
||||
export const openPane = () => {
|
||||
if (parseInt(localStorage?.chatControlsSize)) {
|
||||
@@ -57,7 +158,6 @@
|
||||
const handleMediaQuery = async (e) => {
|
||||
if (e.matches) {
|
||||
largeScreen = true;
|
||||
|
||||
if ($showCallOverlay) {
|
||||
showCallOverlay.set(false);
|
||||
await tick();
|
||||
@@ -65,7 +165,6 @@
|
||||
}
|
||||
} else {
|
||||
largeScreen = false;
|
||||
|
||||
if ($showCallOverlay) {
|
||||
showCallOverlay.set(false);
|
||||
await tick();
|
||||
@@ -75,98 +174,102 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseDown = (event) => {
|
||||
const onMouseDown = () => {
|
||||
dragged = true;
|
||||
};
|
||||
|
||||
const onMouseUp = (event) => {
|
||||
const onMouseUp = () => {
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// listen to resize 1024px
|
||||
mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
mediaQuery.addEventListener('change', handleMediaQuery);
|
||||
handleMediaQuery(mediaQuery);
|
||||
|
||||
// Select the container element you want to observe
|
||||
const container = document.getElementById('chat-container');
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
// initialize the minSize based on the container width
|
||||
minSize = Math.floor((350 / container.clientWidth) * 100);
|
||||
// Wait for Svelte to render the Pane after largeScreen changed
|
||||
const init = async () => {
|
||||
await tick();
|
||||
|
||||
// Create a new ResizeObserver instance
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
// calculate the percentage of 350px
|
||||
const percentage = (350 / width) * 100;
|
||||
// set the minSize to the percentage, must be an integer
|
||||
minSize = Math.floor(percentage);
|
||||
if (isDestroyed) return;
|
||||
|
||||
if ($showControls) {
|
||||
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
|
||||
pane.resize(minSize);
|
||||
} else {
|
||||
let size = Math.floor(
|
||||
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
|
||||
);
|
||||
if (size < minSize) {
|
||||
// If controls were persisted as open, set the pane to the saved size
|
||||
if ($showControls && pane) {
|
||||
openPane();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
paneReady = true;
|
||||
}, 0);
|
||||
|
||||
const container = document.getElementById('chat-container') as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
minSize = Math.floor((350 / container.clientWidth) * 100);
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (let entry of entries) {
|
||||
const width = entry.contentRect.width;
|
||||
minSize = Math.floor((350 / width) * 100);
|
||||
if ($showControls) {
|
||||
if (pane && pane.isExpanded() && pane.getSize() < minSize) {
|
||||
pane.resize(minSize);
|
||||
} else {
|
||||
let size = Math.floor(
|
||||
(parseInt(localStorage?.chatControlsSize) / container.clientWidth) * 100
|
||||
);
|
||||
if (size < minSize && pane) pane.resize(minSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the container's size changes
|
||||
resizeObserver.observe(container);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
};
|
||||
init();
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
showControls.set(false);
|
||||
|
||||
mediaQuery.removeEventListener('change', handleMediaQuery);
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
isDestroyed = true;
|
||||
paneReady = false;
|
||||
resizeObserver?.disconnect();
|
||||
if (!largeScreen) {
|
||||
showControls.set(false);
|
||||
}
|
||||
mediaQuery.removeEventListener('change', handleMediaQuery);
|
||||
document.removeEventListener('mousedown', onMouseDown);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
});
|
||||
|
||||
const closeHandler = () => {
|
||||
showControls.set(false);
|
||||
showOverview.set(false);
|
||||
if (!largeScreen) {
|
||||
showControls.set(false);
|
||||
}
|
||||
showArtifacts.set(false);
|
||||
showEmbeds.set(false);
|
||||
|
||||
if ($showCallOverlay) {
|
||||
showCallOverlay.set(false);
|
||||
}
|
||||
if ($showCallOverlay) showCallOverlay.set(false);
|
||||
};
|
||||
|
||||
$: if (!chatId) {
|
||||
closeHandler();
|
||||
}
|
||||
$: if (paneReady && !chatId) closeHandler();
|
||||
|
||||
// Helper: is a "special" full-screen panel active?
|
||||
$: specialPanel = $showCallOverlay || $showArtifacts || $showEmbeds;
|
||||
</script>
|
||||
|
||||
{#if !largeScreen}
|
||||
{#if $showControls}
|
||||
<Drawer
|
||||
show={$showControls}
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
onClose={() => showControls.set(false)}
|
||||
className="min-h-[100dvh] !bg-white dark:!bg-gray-850"
|
||||
>
|
||||
<div
|
||||
class=" {$showCallOverlay || $showOverview || $showArtifacts || $showEmbeds
|
||||
? ' h-screen w-full'
|
||||
: 'px-4 py-3'} h-full"
|
||||
>
|
||||
<div class="h-[100dvh] flex flex-col">
|
||||
{#if $showCallOverlay}
|
||||
<div
|
||||
class=" h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
|
||||
class="h-full max-h-[100dvh] bg-white text-gray-700 dark:bg-black dark:text-gray-300 flex justify-center"
|
||||
>
|
||||
<CallOverlay
|
||||
bind:files
|
||||
@@ -175,51 +278,108 @@
|
||||
{modelId}
|
||||
{chatId}
|
||||
{eventTarget}
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
on:close={() => showControls.set(false)}
|
||||
/>
|
||||
</div>
|
||||
{:else if $showEmbeds}
|
||||
<Embeds />
|
||||
{:else if $showArtifacts}
|
||||
<Artifacts {history} />
|
||||
{:else if $showOverview}
|
||||
{#await import('./Overview.svelte') then { default: Overview }}
|
||||
<Overview
|
||||
{history}
|
||||
onNodeClick={(e) => {
|
||||
const node = e.node;
|
||||
showMessage(node.data.message, true);
|
||||
}}
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
<Controls
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
{models}
|
||||
bind:chatFiles
|
||||
bind:params
|
||||
/>
|
||||
<!-- Controls + Files tabs -->
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
|
||||
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
|
||||
{#if showControlsTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
|
||||
'controls'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'controls')}
|
||||
>
|
||||
{$i18n.t('Controls')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if showFilesTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
|
||||
'files'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'files')}
|
||||
>
|
||||
{$i18n.t('Files')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if showOverviewTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
|
||||
'overview'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'overview')}
|
||||
>
|
||||
{$i18n.t('Overview')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={() => showControls.set(false)}
|
||||
aria-label={$i18n.t('Close')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 min-h-0 {activeTab === 'overview'
|
||||
? 'h-full'
|
||||
: activeTab === 'controls'
|
||||
? 'overflow-y-auto px-3 pt-1'
|
||||
: ''}"
|
||||
>
|
||||
{#if activeTab === 'overview'}
|
||||
<Overview
|
||||
{history}
|
||||
onNodeClick={(e) => {
|
||||
const node = e.node;
|
||||
showMessage(node.data.message, true);
|
||||
}}
|
||||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav />
|
||||
{:else}
|
||||
<Controls embed={true} {models} bind:chatFiles bind:params />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Drawer>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- if $showControls -->
|
||||
|
||||
{#if $showControls}
|
||||
<PaneResizer
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
|
||||
id="controls-resizer"
|
||||
>
|
||||
<div
|
||||
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
class="absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
|
||||
/>
|
||||
</PaneResizer>
|
||||
{/if}
|
||||
@@ -229,31 +389,30 @@
|
||||
defaultSize={0}
|
||||
onResize={(size) => {
|
||||
if ($showControls && pane.isExpanded()) {
|
||||
if (size < minSize) {
|
||||
pane.resize(minSize);
|
||||
}
|
||||
|
||||
if (size < minSize) pane.resize(minSize);
|
||||
if (size < minSize) {
|
||||
localStorage.chatControlsSize = 0;
|
||||
} else {
|
||||
// save the size in pixels to localStorage
|
||||
const container = document.getElementById('chat-container');
|
||||
localStorage.chatControlsSize = Math.floor((size / 100) * container.clientWidth);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCollapse={() => {
|
||||
showControls.set(false);
|
||||
if (paneReady) showControls.set(false);
|
||||
}}
|
||||
collapsible={true}
|
||||
class=" z-10 bg-white dark:bg-gray-850"
|
||||
class="z-10 bg-white dark:bg-gray-850"
|
||||
>
|
||||
{#if $showControls}
|
||||
<div class="flex max-h-full min-h-full">
|
||||
<div
|
||||
class="w-full {($showOverview || $showArtifacts || $showEmbeds) && !$showCallOverlay
|
||||
class="w-full {specialPanel && !$showCallOverlay
|
||||
? ' '
|
||||
: 'px-4 py-3 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
|
||||
: 'bg-white dark:shadow-lg dark:bg-gray-850'} z-40 pointer-events-auto {activeTab ===
|
||||
'files'
|
||||
? ''
|
||||
: 'overflow-y-auto'} scrollbar-hidden"
|
||||
id="controls-container"
|
||||
>
|
||||
{#if $showCallOverlay}
|
||||
@@ -265,43 +424,101 @@
|
||||
{modelId}
|
||||
{chatId}
|
||||
{eventTarget}
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
on:close={() => showControls.set(false)}
|
||||
/>
|
||||
</div>
|
||||
{:else if $showEmbeds}
|
||||
<Embeds overlay={dragged} />
|
||||
{:else if $showArtifacts}
|
||||
<Artifacts {history} overlay={dragged} />
|
||||
{:else if $showOverview}
|
||||
{#await import('./Overview.svelte') then { default: Overview }}
|
||||
<Overview
|
||||
{history}
|
||||
onNodeClick={(e) => {
|
||||
const node = e.node;
|
||||
if (node?.data?.message?.favorite) {
|
||||
history.messages[node.data.message.id].favorite = true;
|
||||
} else {
|
||||
history.messages[node.data.message.id].favorite = null;
|
||||
}
|
||||
|
||||
showMessage(node.data.message, true);
|
||||
}}
|
||||
onClose={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
<Controls
|
||||
on:close={() => {
|
||||
showControls.set(false);
|
||||
}}
|
||||
{models}
|
||||
bind:chatFiles
|
||||
bind:params
|
||||
/>
|
||||
<!-- Controls + Files tabs -->
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
|
||||
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
|
||||
{#if showControlsTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
|
||||
'controls'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'controls')}
|
||||
>
|
||||
{$i18n.t('Controls')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if showFilesTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
|
||||
'files'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'files')}
|
||||
>
|
||||
{$i18n.t('Files')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if showOverviewTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
|
||||
'overview'
|
||||
? 'bg-gray-100 dark:bg-gray-800 font-medium text-gray-900 dark:text-white'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => (activeTab = 'overview')}
|
||||
>
|
||||
{$i18n.t('Overview')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400"
|
||||
on:click={() => showControls.set(false)}
|
||||
aria-label={$i18n.t('Close')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 min-h-0 {activeTab === 'overview'
|
||||
? 'h-full'
|
||||
: activeTab === 'controls'
|
||||
? 'overflow-y-auto px-3 pt-1'
|
||||
: ''}"
|
||||
>
|
||||
{#if activeTab === 'overview'}
|
||||
<Overview
|
||||
{history}
|
||||
onNodeClick={(e) => {
|
||||
const node = e.node;
|
||||
if (node?.data?.message?.favorite) {
|
||||
history.messages[node.data.message.id].favorite = true;
|
||||
} else {
|
||||
history.messages[node.data.message.id].favorite = null;
|
||||
}
|
||||
showMessage(node.data.message, true);
|
||||
}}
|
||||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} overlay={dragged} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav overlay={dragged} />
|
||||
{:else}
|
||||
<Controls embed={true} {models} bind:chatFiles bind:params />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
</script>
|
||||
|
||||
{#if $embed}
|
||||
<div class="h-full w-full">
|
||||
<div class="h-full w-full flex flex-col">
|
||||
<div
|
||||
class="pointer-events-auto z-20 flex justify-between items-center py-3 px-2 font-primar text-gray-900 dark:text-white"
|
||||
class="pointer-events-auto z-20 flex justify-between items-center py-3 px-2 font-primar text-gray-900 dark:text-white flex-shrink-0"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-between pl-2">
|
||||
<a
|
||||
@@ -68,7 +68,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class=" w-full h-full relative">
|
||||
<div class="w-full flex-1 min-h-0 relative">
|
||||
{#if overlay}
|
||||
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
||||
{/if}
|
||||
|
||||
@@ -13,28 +13,48 @@
|
||||
export let models = [];
|
||||
export let chatFiles = [];
|
||||
export let params = {};
|
||||
export let embed = false;
|
||||
|
||||
let showValves = false;
|
||||
// Persist collapsible section open/close state
|
||||
const getOpen = (key: string, fallback = true): boolean => {
|
||||
const v = localStorage.getItem(`chatControls.${key}`);
|
||||
return v !== null ? v === 'true' : fallback;
|
||||
};
|
||||
const setOpen = (key: string) => (open: boolean) => {
|
||||
localStorage.setItem(`chatControls.${key}`, String(open));
|
||||
};
|
||||
|
||||
let showFiles = getOpen('files');
|
||||
let showValves = getOpen('valves', false);
|
||||
let showSystemPrompt = getOpen('systemPrompt');
|
||||
let showAdvancedParams = getOpen('advancedParams');
|
||||
</script>
|
||||
|
||||
<div class=" dark:text-white">
|
||||
<div class=" flex items-center justify-between dark:text-gray-100 mb-2">
|
||||
<div class=" text-lg font-medium self-center font-primary">{$i18n.t('Chat Controls')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close chat controls')}
|
||||
on:click={() => {
|
||||
dispatch('close');
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{#if !embed}
|
||||
<div class=" flex items-center justify-between dark:text-gray-100 mb-2">
|
||||
<div class=" text-md self-center font-primary">{$i18n.t('Controls')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close chat controls')}
|
||||
on:click={() => {
|
||||
dispatch('close');
|
||||
}}
|
||||
>
|
||||
<XMark className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)}
|
||||
<div class=" dark:text-gray-200 text-sm font-primary py-0.5 px-0.5">
|
||||
<div class=" dark:text-gray-200 text-sm py-0.5 px-0.5">
|
||||
{#if chatFiles.length > 0}
|
||||
<Collapsible title={$i18n.t('Files')} open={true} buttonClassName="w-full">
|
||||
<Collapsible
|
||||
title={$i18n.t('Files')}
|
||||
bind:open={showFiles}
|
||||
onChange={setOpen('files')}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<div class="flex flex-col gap-1 mt-1.5" slot="content">
|
||||
{#each chatFiles as file, fileIdx}
|
||||
<FileItem
|
||||
@@ -65,7 +85,12 @@
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || ($user?.permissions.chat?.valves ?? true)}
|
||||
<Collapsible bind:open={showValves} title={$i18n.t('Valves')} buttonClassName="w-full">
|
||||
<Collapsible
|
||||
bind:open={showValves}
|
||||
onChange={setOpen('valves')}
|
||||
title={$i18n.t('Valves')}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<div class="text-sm" slot="content">
|
||||
<Valves show={showValves} />
|
||||
</div>
|
||||
@@ -75,7 +100,12 @@
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
|
||||
<Collapsible title={$i18n.t('System Prompt')} open={true} buttonClassName="w-full">
|
||||
<Collapsible
|
||||
title={$i18n.t('System Prompt')}
|
||||
bind:open={showSystemPrompt}
|
||||
onChange={setOpen('systemPrompt')}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<div class="" slot="content">
|
||||
<textarea
|
||||
bind:value={params.system}
|
||||
@@ -92,7 +122,12 @@
|
||||
{/if}
|
||||
|
||||
{#if $user?.role === 'admin' || ($user?.permissions.chat?.params ?? true)}
|
||||
<Collapsible title={$i18n.t('Advanced Params')} open={true} buttonClassName="w-full">
|
||||
<Collapsible
|
||||
title={$i18n.t('Advanced Params')}
|
||||
bind:open={showAdvancedParams}
|
||||
onChange={setOpen('advancedParams')}
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
<div class="text-sm mt-1.5" slot="content">
|
||||
<div>
|
||||
<AdvancedParams admin={$user?.role === 'admin'} custom={true} bind:params />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import '$lib/utils/codemirror';
|
||||
import { basicSetup, EditorView } from 'codemirror';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let value = '';
|
||||
export let lang = 'python';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let editor: EditorView | null = null;
|
||||
let editorTheme = new Compartment();
|
||||
let editorLanguage = new Compartment();
|
||||
|
||||
const getLang = async () => {
|
||||
const language = languages.find((l) => l.alias.includes(lang));
|
||||
return await language?.load();
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
|
||||
const extensions = [
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
dispatch('run');
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: () => {
|
||||
dispatch('cancel');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
])
|
||||
),
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
indentUnit.of(' '),
|
||||
EditorView.updateListener.of((e) => {
|
||||
if (e.docChanged) {
|
||||
value = e.state.doc.toString();
|
||||
dispatch('change', value);
|
||||
}
|
||||
}),
|
||||
editorTheme.of(isDark ? oneDark : []),
|
||||
editorLanguage.of([]),
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '0.75rem' },
|
||||
'.cm-content': {
|
||||
padding: '0.35rem 0',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
||||
},
|
||||
'.cm-gutters': { display: 'none' },
|
||||
'.cm-focused': { outline: 'none' },
|
||||
'.cm-scroller': { overflow: 'auto' }
|
||||
})
|
||||
];
|
||||
|
||||
editor = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
parent: container
|
||||
});
|
||||
|
||||
const language = await getLang();
|
||||
if (language && editor) {
|
||||
editor.dispatch({ effects: editorLanguage.reconfigure(language) });
|
||||
}
|
||||
|
||||
// Watch dark mode
|
||||
const observer = new MutationObserver(() => {
|
||||
const dark = document.documentElement.classList.contains('dark');
|
||||
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
editor.focus();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="nb-cm-editor" />
|
||||
|
||||
<style>
|
||||
.nb-cm-editor {
|
||||
width: 100%;
|
||||
background: #fffef5;
|
||||
}
|
||||
:global(.dark) .nb-cm-editor {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import '$lib/utils/codemirror';
|
||||
import { basicSetup, EditorView } from 'codemirror';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { indentUnit, LanguageDescription } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let value = '';
|
||||
export let filePath: string | null = null;
|
||||
export let onSave: ((content: string) => Promise<void>) | null = null;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let editor: EditorView | null = null;
|
||||
let editorTheme = new Compartment();
|
||||
let editorLanguage = new Compartment();
|
||||
let internalValue = '';
|
||||
|
||||
/** Return the current editor content */
|
||||
export const getValue = (): string => {
|
||||
return editor?.state.doc.toString() ?? value;
|
||||
};
|
||||
|
||||
/** Replace editor content */
|
||||
export const setValue = (newValue: string) => {
|
||||
if (!editor) return;
|
||||
internalValue = newValue;
|
||||
editor.dispatch({
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: newValue }
|
||||
});
|
||||
};
|
||||
|
||||
export const focus = () => {
|
||||
editor?.focus();
|
||||
};
|
||||
|
||||
const detectLanguage = async (path: string | null) => {
|
||||
if (!path) return;
|
||||
const match = LanguageDescription.matchFilename(languages, path);
|
||||
if (match) {
|
||||
const lang = await match.load();
|
||||
if (lang && editor) {
|
||||
editor.dispatch({ effects: editorLanguage.reconfigure(lang) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// React to external value changes (e.g. switching files)
|
||||
$: if (editor && value !== internalValue) {
|
||||
internalValue = value;
|
||||
editor.dispatch({
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: value }
|
||||
});
|
||||
}
|
||||
|
||||
// React to filePath changes for language detection
|
||||
$: if (editor && filePath) {
|
||||
detectLanguage(filePath);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
internalValue = value;
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
if (onSave) {
|
||||
onSave(editor?.state.doc.toString() ?? '');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
]),
|
||||
indentUnit.of(' '),
|
||||
EditorView.updateListener.of((e) => {
|
||||
if (e.docChanged) {
|
||||
internalValue = e.state.doc.toString();
|
||||
value = internalValue;
|
||||
}
|
||||
}),
|
||||
editorTheme.of(isDark ? oneDark : []),
|
||||
editorLanguage.of([]),
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '0.75rem', height: '100%' },
|
||||
'.cm-content': {
|
||||
padding: '0.5rem 0',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
|
||||
},
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
'.cm-focused': { outline: 'none' }
|
||||
})
|
||||
];
|
||||
|
||||
editor = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
parent: container
|
||||
});
|
||||
|
||||
detectLanguage(filePath);
|
||||
|
||||
// Watch dark mode
|
||||
const observer = new MutationObserver(() => {
|
||||
const dark = document.documentElement.classList.contains('dark');
|
||||
editor?.dispatch({ effects: editorTheme.reconfigure(dark ? oneDark : []) });
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="file-code-editor" />
|
||||
|
||||
<style>
|
||||
.file-code-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.file-code-editor :global(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import { flyAndScale } from '$lib/utils/transitions';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
import type { FileEntry } from '$lib/apis/terminal';
|
||||
import Folder from '../../icons/Folder.svelte';
|
||||
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
|
||||
import GarbageBin from '../../icons/GarbageBin.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let entry: FileEntry;
|
||||
export let currentPath: string;
|
||||
export let terminalUrl: string = '';
|
||||
export let terminalKey: string = '';
|
||||
|
||||
export let onOpen: (entry: FileEntry) => void = () => {};
|
||||
export let onDownload: (path: string) => void = () => {};
|
||||
export let onDelete: (path: string, name: string) => void = () => {};
|
||||
export let onMove: (source: string, destFolder: string) => void = () => {};
|
||||
|
||||
let dragOverFolder = false;
|
||||
</script>
|
||||
|
||||
<li class="group">
|
||||
<div
|
||||
class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition
|
||||
{dragOverFolder
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
|
||||
: ''}"
|
||||
role={entry.type === 'directory' ? 'button' : undefined}
|
||||
on:dragover={(e) => {
|
||||
if (entry.type !== 'directory') return;
|
||||
if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverFolder = true;
|
||||
}}
|
||||
on:dragleave={(e) => {
|
||||
if (entry.type !== 'directory') return;
|
||||
e.stopPropagation();
|
||||
dragOverFolder = false;
|
||||
}}
|
||||
on:drop={(e) => {
|
||||
if (entry.type !== 'directory') return;
|
||||
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverFolder = false;
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data.path) {
|
||||
const destFolder = `${currentPath}${entry.name}/`;
|
||||
// Don't allow dropping a folder onto itself
|
||||
if (data.path + '/' === destFolder || data.path === destFolder) return;
|
||||
onMove(data.path, destFolder);
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0"
|
||||
draggable={true}
|
||||
on:dragstart={(e) => {
|
||||
const filePath = `${currentPath}${entry.name}`;
|
||||
// Internal move data
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file-move',
|
||||
JSON.stringify({ path: filePath, name: entry.name })
|
||||
);
|
||||
// Keep existing chat-attachment drag for files
|
||||
if (entry.type === 'file') {
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
JSON.stringify({
|
||||
path: filePath,
|
||||
name: entry.name,
|
||||
url: terminalUrl,
|
||||
key: terminalKey
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
on:click={() => onOpen(entry)}
|
||||
>
|
||||
{#if entry.type === 'directory'}
|
||||
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-4 shrink-0 text-gray-400"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.type === 'file' && entry.size !== undefined}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="shrink-0 p-0.5 mr-1 rounded-lg transition
|
||||
text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400
|
||||
hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
aria-label={$i18n.t('More')}
|
||||
>
|
||||
<EllipsisHorizontal className="size-3.5" />
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content
|
||||
strategy="fixed"
|
||||
class="w-full max-w-[150px] rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
|
||||
sideOffset={4}
|
||||
side="bottom"
|
||||
align="end"
|
||||
transition={flyAndScale}
|
||||
>
|
||||
{#if entry.type !== 'directory'}
|
||||
<DropdownMenu.Item
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(`${currentPath}${entry.name}`);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(`${currentPath}${entry.name}`, entry.name);
|
||||
}}
|
||||
>
|
||||
<GarbageBin className="size-4" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</li>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { getContext, afterUpdate } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
import Folder from '../../icons/Folder.svelte';
|
||||
import NewFolderAlt from '../../icons/NewFolderAlt.svelte';
|
||||
import FilePlusAlt from '../../icons/FilePlusAlt.svelte';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import Tooltip from '../../common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let breadcrumbs: { label: string; path: string }[] = [];
|
||||
export let selectedFile: string | null = null;
|
||||
export let loading = false;
|
||||
|
||||
export let onNavigate: (path: string) => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onNewFolder: () => void = () => {};
|
||||
export let onNewFile: () => void = () => {};
|
||||
export let onUploadFiles: (files: File[]) => void = () => {};
|
||||
export let onMove: (source: string, destFolder: string) => void = () => {};
|
||||
|
||||
let dragOverCrumb: number | null = null;
|
||||
|
||||
let uploadInput: HTMLInputElement;
|
||||
let breadcrumbEl: HTMLDivElement;
|
||||
|
||||
// Scroll breadcrumb to the end after every DOM update
|
||||
afterUpdate(() => {
|
||||
if (breadcrumbEl) breadcrumbEl.scrollLeft = breadcrumbEl.scrollWidth;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
|
||||
<div
|
||||
bind:this={breadcrumbEl}
|
||||
class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none"
|
||||
>
|
||||
{#each breadcrumbs as crumb, i}
|
||||
{#if i > 1}
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
|
||||
{/if}
|
||||
<button
|
||||
class="text-xs shrink-0 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition
|
||||
{!selectedFile && i === breadcrumbs.length - 1
|
||||
? 'text-gray-700 dark:text-gray-300'
|
||||
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}
|
||||
{dragOverCrumb === i
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500'
|
||||
: ''}"
|
||||
on:click={() => onNavigate(crumb.path)}
|
||||
on:dragover={(e) => {
|
||||
if (!e.dataTransfer?.types.includes('application/x-terminal-file-move')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverCrumb = i;
|
||||
}}
|
||||
on:dragleave={() => {
|
||||
if (dragOverCrumb === i) dragOverCrumb = null;
|
||||
}}
|
||||
on:drop={(e) => {
|
||||
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverCrumb = null;
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data.path) onMove(data.path, crumb.path);
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if selectedFile}
|
||||
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
|
||||
<span class="text-xs shrink-0 px-1.5 py-0.5 text-gray-700 dark:text-gray-300">
|
||||
{selectedFile.split('/').pop()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Tooltip content={$i18n.t('Refresh')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onRefresh}
|
||||
aria-label={$i18n.t('Refresh')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5 {loading ? 'animate-spin' : ''}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{#if !selectedFile}
|
||||
<Tooltip content={$i18n.t('New Folder')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onNewFolder}
|
||||
aria-label={$i18n.t('New Folder')}
|
||||
>
|
||||
<NewFolderAlt className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={$i18n.t('New File')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onNewFile}
|
||||
aria-label={$i18n.t('New File')}
|
||||
>
|
||||
<FilePlusAlt className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={$i18n.t('Upload')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={() => uploadInput?.click()}
|
||||
aria-label={$i18n.t('Upload')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input
|
||||
bind:this={uploadInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
on:change={async () => {
|
||||
if (!uploadInput?.files?.length) return;
|
||||
onUploadFiles(Array.from(uploadInput.files));
|
||||
uploadInput.value = '';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,725 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, tick } from 'svelte';
|
||||
import panzoom, { type PanZoom } from 'panzoom';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { settings } from '$lib/stores';
|
||||
import { isCodeFile } from '$lib/utils/codeHighlight';
|
||||
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import PDFViewer from '../../common/PDFViewer.svelte';
|
||||
import JsonTreeView from './JsonTreeView.svelte';
|
||||
import NotebookView from './NotebookView.svelte';
|
||||
import SqliteView from './SqliteView.svelte';
|
||||
import FileCodeEditor from './FileCodeEditor.svelte';
|
||||
|
||||
let pdfViewerRef: PDFViewer;
|
||||
let fileCodeEditorRef: FileCodeEditor;
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let selectedFile: string | null = null;
|
||||
export let fileLoading = false;
|
||||
export let fileImageUrl: string | null = null;
|
||||
export let fileVideoUrl: string | null = null;
|
||||
export let fileAudioUrl: string | null = null;
|
||||
export let filePdfData: ArrayBuffer | null = null;
|
||||
export let fileSqliteData: ArrayBuffer | null = null;
|
||||
export let fileContent: string | null = null;
|
||||
|
||||
// Terminal connection for notebook execution
|
||||
export let baseUrl: string = '';
|
||||
export let apiKey: string = '';
|
||||
|
||||
// Office preview props
|
||||
export let fileOfficeHtml: string | null = null;
|
||||
export let fileOfficeSlides: string[] | null = null;
|
||||
export let currentSlide = 0;
|
||||
export let excelSheetNames: string[] = [];
|
||||
export let selectedExcelSheet = '';
|
||||
export let onSheetChange: ((sheet: string) => void) | null = null;
|
||||
|
||||
export let overlay = false;
|
||||
|
||||
export let onSave: ((content: string) => Promise<void>) | null = null;
|
||||
|
||||
export let editing = false;
|
||||
let editContent = '';
|
||||
export let saving = false;
|
||||
let editTextarea: HTMLTextAreaElement;
|
||||
|
||||
// Reset edit state when switching files
|
||||
$: selectedFile, resetEdit();
|
||||
|
||||
const resetEdit = () => {
|
||||
editing = false;
|
||||
editContent = '';
|
||||
saving = false;
|
||||
};
|
||||
|
||||
export const startEdit = async () => {
|
||||
editContent = fileContent ?? '';
|
||||
editing = true;
|
||||
showRaw = true;
|
||||
await tick();
|
||||
editTextarea?.focus();
|
||||
};
|
||||
|
||||
export const saveEdit = async () => {
|
||||
if (!onSave) return;
|
||||
saving = true;
|
||||
await onSave(editContent);
|
||||
saving = false;
|
||||
editing = false;
|
||||
};
|
||||
|
||||
export const cancelEdit = () => {
|
||||
editing = false;
|
||||
editContent = '';
|
||||
};
|
||||
|
||||
/** Save code file directly from CodeMirror */
|
||||
export const saveCodeFile = async () => {
|
||||
if (!onSave) return;
|
||||
saving = true;
|
||||
const content = fileCodeEditorRef?.getValue() ?? '';
|
||||
await onSave(content);
|
||||
saving = false;
|
||||
};
|
||||
|
||||
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
|
||||
|
||||
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
|
||||
const CSV_EXTS = new Set(['csv', 'tsv']);
|
||||
const HTML_EXTS = new Set(['html', 'htm']);
|
||||
const JSON_EXTS = new Set(['json', 'jsonc', 'jsonl', 'json5']);
|
||||
const getExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
$: isMarkdown = MD_EXTS.has(getExt(selectedFile));
|
||||
$: isCsv = CSV_EXTS.has(getExt(selectedFile));
|
||||
$: isHtml = HTML_EXTS.has(getExt(selectedFile));
|
||||
$: isJson = JSON_EXTS.has(getExt(selectedFile));
|
||||
$: isSvg = getExt(selectedFile) === 'svg';
|
||||
$: isNotebook = getExt(selectedFile) === 'ipynb';
|
||||
$: isCode = isCodeFile(selectedFile);
|
||||
$: csvDelimiter = getExt(selectedFile) === 'tsv' ? '\t' : ',';
|
||||
$: renderedHtml =
|
||||
isMarkdown && fileContent
|
||||
? DOMPurify.sanitize(marked.parse(fileContent, { async: false }) as string)
|
||||
: '';
|
||||
|
||||
let markdownEl: HTMLDivElement;
|
||||
let mermaidInstance: any = null;
|
||||
|
||||
const renderMermaidBlocks = async (el: HTMLDivElement) => {
|
||||
if (!el) return;
|
||||
const codeEls = el.querySelectorAll('code.language-mermaid');
|
||||
if (codeEls.length === 0) return;
|
||||
|
||||
if (!mermaidInstance) {
|
||||
mermaidInstance = await initMermaid();
|
||||
}
|
||||
|
||||
for (const codeEl of codeEls) {
|
||||
const pre = codeEl.parentElement;
|
||||
if (!pre || pre.tagName !== 'PRE' || pre.dataset.mermaidRendered) continue;
|
||||
pre.dataset.mermaidRendered = 'true';
|
||||
|
||||
try {
|
||||
const svg = await renderMermaidDiagram(mermaidInstance, codeEl.textContent ?? '');
|
||||
if (svg) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mermaid-diagram flex justify-center py-2';
|
||||
wrapper.innerHTML = svg;
|
||||
pre.replaceWith(wrapper);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Mermaid render error:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$: if (renderedHtml && markdownEl) {
|
||||
tick().then(() => renderMermaidBlocks(markdownEl));
|
||||
}
|
||||
|
||||
// Simple CSV parser that handles quoted fields
|
||||
const parseCsv = (text: string, delimiter: string): string[][] => {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === delimiter) {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (ch === '\n' || (ch === '\r' && text[i + 1] === '\n')) {
|
||||
if (ch === '\r') i++;
|
||||
row.push(field);
|
||||
field = '';
|
||||
if (row.some((c) => c !== '')) rows.push(row);
|
||||
row = [];
|
||||
} else {
|
||||
field += ch;
|
||||
}
|
||||
}
|
||||
row.push(field);
|
||||
if (row.some((c) => c !== '')) rows.push(row);
|
||||
return rows;
|
||||
};
|
||||
|
||||
$: csvRows = isCsv && fileContent ? parseCsv(fileContent, csvDelimiter) : [];
|
||||
$: csvHeader = csvRows.length > 0 ? csvRows[0] : [];
|
||||
$: csvBody = csvRows.length > 1 ? csvRows.slice(1) : [];
|
||||
|
||||
// ── Shiki code highlighting (SVG only) ──────────────────────────────
|
||||
let highlightedHtml: string | null = null;
|
||||
let highlightingFile: string | null = null;
|
||||
|
||||
$: if (isSvg && fileContent !== null && selectedFile) {
|
||||
const currentFile = selectedFile;
|
||||
highlightingFile = currentFile;
|
||||
import('shiki')
|
||||
.then(({ codeToHtml }) =>
|
||||
codeToHtml(fileContent!, {
|
||||
lang: 'xml',
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
defaultColor: 'light'
|
||||
})
|
||||
)
|
||||
.then((html) => {
|
||||
if (highlightingFile === currentFile) highlightedHtml = html;
|
||||
})
|
||||
.catch(() => {
|
||||
if (highlightingFile === currentFile) highlightedHtml = null;
|
||||
});
|
||||
} else {
|
||||
highlightedHtml = null;
|
||||
}
|
||||
|
||||
// ── JSON parsing ────────────────────────────────────────────────────
|
||||
let parsedJson: unknown = undefined;
|
||||
let jsonError: string | null = null;
|
||||
|
||||
$: if (isJson && fileContent !== null) {
|
||||
try {
|
||||
parsedJson = JSON.parse(fileContent);
|
||||
jsonError = null;
|
||||
} catch (e) {
|
||||
parsedJson = undefined;
|
||||
jsonError = e instanceof Error ? e.message : 'Invalid JSON';
|
||||
}
|
||||
} else {
|
||||
parsedJson = undefined;
|
||||
jsonError = null;
|
||||
}
|
||||
|
||||
// ── Notebook parsing ─────────────────────────────────────────────────
|
||||
let parsedNotebook: Record<string, unknown> | null = null;
|
||||
|
||||
$: if (isNotebook && fileContent !== null) {
|
||||
try {
|
||||
parsedNotebook = JSON.parse(fileContent);
|
||||
} catch {
|
||||
parsedNotebook = null;
|
||||
}
|
||||
} else {
|
||||
parsedNotebook = null;
|
||||
}
|
||||
|
||||
export let showRaw = false;
|
||||
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
|
||||
|
||||
let pzInstance: PanZoom | null = null;
|
||||
|
||||
const initImagePanzoom = (node: HTMLElement) => {
|
||||
pzInstance = panzoom(node, {
|
||||
bounds: true,
|
||||
boundsPadding: 0.1,
|
||||
zoomSpeed: 0.065,
|
||||
zoomDoubleClickSpeed: 1
|
||||
});
|
||||
};
|
||||
|
||||
export const resetImageView = () => {
|
||||
if (pzInstance) {
|
||||
pzInstance.moveTo(0, 0);
|
||||
pzInstance.zoomAbs(0, 0, 1);
|
||||
}
|
||||
};
|
||||
|
||||
export const disposePanzoom = () => {
|
||||
if (pzInstance) {
|
||||
pzInstance.dispose();
|
||||
pzInstance = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPdfView = () => {
|
||||
pdfViewerRef?.resetView();
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
disposePanzoom();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 {fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
|
||||
>
|
||||
<!-- File preview -->
|
||||
{#if fileLoading}
|
||||
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
|
||||
{:else if fileImageUrl !== null}
|
||||
<div class="w-full h-full flex items-center justify-center" use:initImagePanzoom>
|
||||
<img
|
||||
src={fileImageUrl}
|
||||
alt={selectedFile?.split('/').pop()}
|
||||
class="max-w-full max-h-full object-contain p-3"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{:else if fileVideoUrl !== null}
|
||||
<div class="w-full h-full flex items-center justify-center bg-black">
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video src={fileVideoUrl} controls class="max-w-full max-h-full">
|
||||
{$i18n.t('Your browser does not support the video tag.')}
|
||||
</video>
|
||||
</div>
|
||||
{:else if fileAudioUrl !== null}
|
||||
<div class="w-full h-full flex items-center justify-center p-6">
|
||||
<audio src={fileAudioUrl} controls class="w-full max-w-md">
|
||||
{$i18n.t('Your browser does not support the audio tag.')}
|
||||
</audio>
|
||||
</div>
|
||||
{:else if filePdfData !== null}
|
||||
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
|
||||
{:else if fileSqliteData !== null}
|
||||
<SqliteView data={fileSqliteData} />
|
||||
{:else if fileOfficeHtml !== null}
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="office-preview overflow-auto flex-1 min-h-0">
|
||||
{@html fileOfficeHtml}
|
||||
</div>
|
||||
{#if excelSheetNames.length > 1}
|
||||
<div
|
||||
class="flex items-center gap-1 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 overflow-x-auto"
|
||||
>
|
||||
{#each excelSheetNames as sheet}
|
||||
<button
|
||||
class="shrink-0 px-3 py-1 text-xs rounded-md transition-colors
|
||||
{selectedExcelSheet === sheet
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
on:click={() => onSheetChange?.(sheet)}
|
||||
>
|
||||
{sheet}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
|
||||
use:initImagePanzoom
|
||||
>
|
||||
<img
|
||||
src={fileOfficeSlides[currentSlide]}
|
||||
alt="Slide {currentSlide + 1}"
|
||||
class="max-w-full max-h-full object-contain p-3"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{#if fileOfficeSlides.length > 1}
|
||||
<div
|
||||
class="flex items-center justify-center gap-3 py-2 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500"
|
||||
>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={currentSlide === 0}
|
||||
on:click={() => {
|
||||
resetImageView();
|
||||
currentSlide = Math.max(0, currentSlide - 1);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span>{currentSlide + 1} / {fileOfficeSlides.length}</span>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={currentSlide === fileOfficeSlides.length - 1}
|
||||
on:click={() => {
|
||||
resetImageView();
|
||||
currentSlide = Math.min(fileOfficeSlides.length - 1, currentSlide + 1);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if fileContent !== null}
|
||||
{#if isHtml && !showRaw}
|
||||
{#if overlay}
|
||||
<div class="absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
||||
{/if}
|
||||
<iframe
|
||||
srcdoc={fileContent}
|
||||
sandbox="allow-scripts allow-downloads{($settings?.iframeSandboxAllowForms ?? false)
|
||||
? ' allow-forms'
|
||||
: ''}{($settings?.iframeSandboxAllowSameOrigin ?? false) ? ' allow-same-origin' : ''}"
|
||||
class="w-full h-full border-none bg-white"
|
||||
title="HTML Preview"
|
||||
/>
|
||||
{:else if isHtml && showRaw}
|
||||
<div class="h-full">
|
||||
<FileCodeEditor
|
||||
bind:this={fileCodeEditorRef}
|
||||
value={fileContent ?? ''}
|
||||
filePath={selectedFile}
|
||||
{onSave}
|
||||
/>
|
||||
</div>
|
||||
{:else if isMarkdown && !showRaw}
|
||||
<div bind:this={markdownEl} class="prose dark:prose-invert max-w-full text-sm p-3">
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
{:else if isCsv && !showRaw && csvRows.length > 0}
|
||||
<div class="absolute inset-0 overflow-auto px-3 pb-3">
|
||||
<table class="csv-table text-xs font-mono border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="csv-row-num">#</th>
|
||||
{#each csvHeader as cell}
|
||||
<th>{cell}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each csvBody as row, i}
|
||||
<tr>
|
||||
<td class="csv-row-num">{i + 1}</td>
|
||||
{#each row as cell}
|
||||
<td>{cell}</td>
|
||||
{/each}
|
||||
<!-- Pad missing columns -->
|
||||
{#each Array(Math.max(0, csvHeader.length - row.length)) as _}
|
||||
<td></td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if isNotebook && !showRaw && parsedNotebook}
|
||||
<div class="overflow-auto h-full">
|
||||
<NotebookView notebook={parsedNotebook} filePath={selectedFile ?? ''} {baseUrl} {apiKey} />
|
||||
</div>
|
||||
{:else if isJson && !showRaw && parsedJson !== undefined}
|
||||
<div class="overflow-auto h-full">
|
||||
<JsonTreeView data={parsedJson} />
|
||||
</div>
|
||||
{:else if isJson && !showRaw && jsonError}
|
||||
<div class="p-3 text-xs">
|
||||
<div class="text-red-500 mb-2">JSON parse error: {jsonError}</div>
|
||||
<pre
|
||||
class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed">{fileContent}</pre>
|
||||
</div>
|
||||
{:else if isSvg && !showRaw && fileContent}
|
||||
<div class="svg-preview w-full h-full flex items-center justify-center overflow-auto p-3">
|
||||
{@html DOMPurify.sanitize(fileContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['use']
|
||||
})}
|
||||
</div>
|
||||
{:else if isCode && !showRaw}
|
||||
<div class="h-full">
|
||||
<FileCodeEditor
|
||||
bind:this={fileCodeEditorRef}
|
||||
value={fileContent ?? ''}
|
||||
filePath={selectedFile}
|
||||
{onSave}
|
||||
/>
|
||||
</div>
|
||||
{:else if isSvg && highlightedHtml && !showRaw}
|
||||
<div class="shiki-preview overflow-auto h-full text-xs">
|
||||
{@html highlightedHtml}
|
||||
</div>
|
||||
{:else if editing}
|
||||
<textarea
|
||||
bind:this={editTextarea}
|
||||
bind:value={editContent}
|
||||
class="w-full h-full text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre break-all leading-relaxed p-3 bg-transparent border-none outline-none resize-none"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{:else}
|
||||
<pre
|
||||
class="text-xs font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all leading-relaxed p-3">{fileContent}</pre>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-xs text-gray-400 text-center pt-8">
|
||||
{$i18n.t('Could not read file.')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.csv-table {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.csv-table th,
|
||||
.csv-table td {
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
.csv-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(243, 244, 246, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid rgba(128, 128, 128, 0.25);
|
||||
z-index: 1;
|
||||
}
|
||||
:global(.dark) .csv-table thead th {
|
||||
background: rgba(31, 41, 55, 0.95);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.csv-table tbody tr:nth-child(even) {
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
}
|
||||
.csv-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
:global(.dark) .csv-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.csv-table td {
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .csv-table td {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.csv-row-num {
|
||||
color: #9ca3af;
|
||||
font-size: 0.6rem;
|
||||
text-align: right !important;
|
||||
user-select: none;
|
||||
width: 1px;
|
||||
padding-right: 6px !important;
|
||||
}
|
||||
:global(.dark) .csv-row-num {
|
||||
color: #6b7280;
|
||||
}
|
||||
/* ── Office preview styles ──────────────────────────────────────── */
|
||||
:global(.office-preview) {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:global(.dark .office-preview) {
|
||||
color: #e5e7eb;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
:global(.office-preview table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
||||
line-height: 1.3;
|
||||
}
|
||||
:global(.office-preview table td),
|
||||
:global(.office-preview table th) {
|
||||
border: 1px solid rgba(200, 200, 200, 0.5);
|
||||
padding: 4px 10px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: text;
|
||||
cursor: cell;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:global(.dark .office-preview table td),
|
||||
:global(.dark .office-preview table th) {
|
||||
border-color: rgba(80, 80, 80, 0.5);
|
||||
}
|
||||
/* Column letter headers */
|
||||
:global(.office-preview table th.excel-col-hdr) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
font-size: 0.65rem;
|
||||
text-align: center;
|
||||
padding: 3px 10px;
|
||||
border-bottom: 2px solid rgba(180, 180, 180, 0.6);
|
||||
}
|
||||
:global(.dark .office-preview table th.excel-col-hdr) {
|
||||
background: #2a2a3e;
|
||||
color: #888;
|
||||
border-bottom-color: rgba(100, 100, 100, 0.6);
|
||||
}
|
||||
/* Row number cells */
|
||||
:global(.office-preview .excel-row-num) {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: #f0f0f0;
|
||||
color: #999;
|
||||
font-size: 0.6rem;
|
||||
text-align: right !important;
|
||||
padding: 4px 8px 4px 4px !important;
|
||||
user-select: none;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
border-right: 2px solid rgba(180, 180, 180, 0.6) !important;
|
||||
}
|
||||
:global(.dark .office-preview .excel-row-num) {
|
||||
background: #2a2a3e;
|
||||
color: #666;
|
||||
border-right-color: rgba(100, 100, 100, 0.6) !important;
|
||||
}
|
||||
/* Corner cell (intersection of row nums and col headers) */
|
||||
:global(.office-preview thead .excel-row-num) {
|
||||
z-index: 3;
|
||||
}
|
||||
/* Number cells right-aligned */
|
||||
:global(.office-preview .excel-num) {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Row hover and selection */
|
||||
:global(.office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) {
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
}
|
||||
:global(.dark .office-preview table tbody tr:nth-child(even) td:not(.excel-row-num)) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
:global(.office-preview table tbody tr:hover td:not(.excel-row-num)) {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
:global(.dark .office-preview table tbody tr:hover td:not(.excel-row-num)) {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
:global(.office-preview table td:focus) {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.5);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* DOCX / generic office styles */
|
||||
:global(.office-preview img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
:global(.office-preview h1) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0.75em 0 0.5em;
|
||||
}
|
||||
:global(.office-preview h2) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0.75em 0 0.5em;
|
||||
}
|
||||
:global(.office-preview h3) {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5em 0 0.25em;
|
||||
}
|
||||
:global(.office-preview p) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
:global(.office-preview ul),
|
||||
:global(.office-preview ol) {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
/* ── Shiki code highlighting ─────────────────────────────────── */
|
||||
.shiki-preview :global(pre.shiki) {
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
border-radius: 0;
|
||||
overflow-x: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
.shiki-preview :global(pre.shiki code) {
|
||||
counter-reset: line;
|
||||
}
|
||||
.shiki-preview :global(pre.shiki code > .line) {
|
||||
counter-increment: line;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
white-space: pre;
|
||||
}
|
||||
.shiki-preview :global(pre.shiki code > .line::before) {
|
||||
content: counter(line);
|
||||
display: inline-block;
|
||||
width: 2.5em;
|
||||
text-align: right;
|
||||
margin-right: 1em;
|
||||
color: #9ca3af;
|
||||
user-select: none;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
:global(.dark) .shiki-preview :global(pre.shiki code > .line::before) {
|
||||
color: #4b5563;
|
||||
}
|
||||
/* Shiki dual-theme: swap CSS variables in dark mode */
|
||||
:global(.dark) .shiki-preview :global(.shiki),
|
||||
:global(.dark) .shiki-preview :global(.shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
export let data: unknown;
|
||||
export let key: string | null = null;
|
||||
export let root = true;
|
||||
export let expandDepth = 2;
|
||||
|
||||
let depth = 0;
|
||||
export { depth };
|
||||
|
||||
$: expanded = depth < expandDepth;
|
||||
|
||||
const toggle = () => {
|
||||
expanded = !expanded;
|
||||
};
|
||||
|
||||
$: type = Array.isArray(data) ? 'array' : data === null ? 'null' : typeof data;
|
||||
$: isExpandable = type === 'object' || type === 'array';
|
||||
$: entries = isExpandable && data !== null ? Object.entries(data as Record<string, unknown>) : [];
|
||||
$: bracket = type === 'array' ? ['[', ']'] : ['{', '}'];
|
||||
$: preview =
|
||||
type === 'array'
|
||||
? `[${(data as unknown[]).length}]`
|
||||
: type === 'object'
|
||||
? `{${entries.length}}`
|
||||
: '';
|
||||
</script>
|
||||
|
||||
{#if isExpandable}
|
||||
<div class="json-node" class:json-root={root}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span class="json-toggle" on:click={toggle}>
|
||||
<span class="json-arrow" class:json-expanded={expanded}>▶</span>
|
||||
{#if key !== null}<span class="json-key">{key}</span><span class="json-colon">: </span>{/if}
|
||||
{#if !expanded}<span class="json-preview">{bracket[0]} {preview} {bracket[1]}</span>{/if}
|
||||
{#if expanded}<span class="json-bracket">{bracket[0]}</span>{/if}
|
||||
</span>
|
||||
{#if expanded}
|
||||
<div class="json-children">
|
||||
{#each entries as [k, v], i}
|
||||
<div class="json-entry">
|
||||
<svelte:self
|
||||
data={v}
|
||||
key={type === 'array' ? null : k}
|
||||
root={false}
|
||||
depth={depth + 1}
|
||||
{expandDepth}
|
||||
/>
|
||||
{#if i < entries.length - 1}<span class="json-comma">,</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="json-bracket">{bracket[1]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="json-leaf">
|
||||
{#if key !== null}<span class="json-key">{key}</span><span class="json-colon">: </span>{/if}
|
||||
{#if type === 'string'}
|
||||
<span class="json-string">"{data}"</span>
|
||||
{:else if type === 'number'}
|
||||
<span class="json-number">{data}</span>
|
||||
{:else if type === 'boolean'}
|
||||
<span class="json-boolean">{data}</span>
|
||||
{:else if type === 'null'}
|
||||
<span class="json-null">null</span>
|
||||
{:else}
|
||||
<span>{String(data)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.json-root {
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.json-node {
|
||||
/* keep structure visible */
|
||||
}
|
||||
.json-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.json-toggle:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.json-arrow {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
font-size: 0.55em;
|
||||
transition: transform 0.15s ease;
|
||||
color: #9ca3af;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.json-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.json-children {
|
||||
padding-left: 1.25em;
|
||||
border-left: 1px solid rgba(128, 128, 128, 0.15);
|
||||
margin-left: 0.35em;
|
||||
}
|
||||
.json-entry {
|
||||
/* one entry per line */
|
||||
}
|
||||
.json-key {
|
||||
color: #0550ae;
|
||||
}
|
||||
:global(.dark) .json-key {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.json-colon {
|
||||
color: #6b7280;
|
||||
}
|
||||
.json-string {
|
||||
color: #0a3069;
|
||||
}
|
||||
:global(.dark) .json-string {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
.json-number {
|
||||
color: #0550ae;
|
||||
}
|
||||
:global(.dark) .json-number {
|
||||
color: #79c0ff;
|
||||
}
|
||||
.json-boolean {
|
||||
color: #cf222e;
|
||||
}
|
||||
:global(.dark) .json-boolean {
|
||||
color: #ff7b72;
|
||||
}
|
||||
.json-null {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
.json-bracket {
|
||||
color: #6b7280;
|
||||
}
|
||||
.json-comma {
|
||||
color: #6b7280;
|
||||
}
|
||||
.json-preview {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
:global(.dark) .json-preview {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user