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 | ||
|
|
6863ca482c |
@@ -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
|
||||
+110
@@ -5,6 +5,116 @@ 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
|
||||
|
||||
@@ -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",
|
||||
@@ -1960,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:
|
||||
@@ -2290,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."""
|
||||
|
||||
|
||||
####################################
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+40
-33
@@ -1398,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)
|
||||
@@ -2187,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]
|
||||
@@ -139,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)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -729,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
|
||||
@@ -1055,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
|
||||
|
||||
@@ -19,7 +19,7 @@ from open_webui.models.skills import (
|
||||
)
|
||||
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
|
||||
|
||||
@@ -8,13 +8,14 @@ Routes:
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
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__)
|
||||
|
||||
@@ -149,3 +150,155 @@ async def proxy_terminal(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -918,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 []
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -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":
|
||||
|
||||
@@ -163,8 +163,8 @@ def has_connection_access(
|
||||
based on ``config.access_grants`` within the connection dict.
|
||||
|
||||
- Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed
|
||||
- Empty / missing access_grants → allowed for all users
|
||||
- Otherwise → delegates to ``has_access``
|
||||
- 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
|
||||
|
||||
@@ -175,9 +175,6 @@ def has_connection_access(
|
||||
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", [])
|
||||
if not access_grants:
|
||||
return True
|
||||
|
||||
return has_access(user.id, "read", access_grants, user_group_ids)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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__)
|
||||
@@ -21,6 +22,7 @@ def has_access_to_file(
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -72,4 +74,15 @@ def has_access_to_file(
|
||||
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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
@@ -91,6 +92,7 @@ from open_webui.utils.misc import (
|
||||
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,
|
||||
@@ -118,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 (
|
||||
@@ -159,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]:
|
||||
@@ -172,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)
|
||||
@@ -180,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
|
||||
@@ -375,9 +436,9 @@ def serialize_output(output: list) -> str:
|
||||
result_item = tool_outputs.get(call_id)
|
||||
if result_item:
|
||||
result_text = ""
|
||||
for output in result_item.get("output", []):
|
||||
if "text" in output:
|
||||
output_text = output.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)
|
||||
@@ -829,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,
|
||||
@@ -847,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,
|
||||
)
|
||||
@@ -1052,16 +1121,16 @@ async def terminal_event_handler(
|
||||
"""Emit terminal:* events for Open Terminal tools.
|
||||
|
||||
- display_file → emits 'terminal:display_file' to open the file preview.
|
||||
- write_file → emits 'terminal:write_file' to silently refresh the file browser.
|
||||
- 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
|
||||
|
||||
path = tool_function_params.get("path", "")
|
||||
if not path:
|
||||
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):
|
||||
@@ -1078,13 +1147,23 @@ async def terminal_event_handler(
|
||||
"data": {"path": path},
|
||||
}
|
||||
)
|
||||
elif tool_function_name == "write_file":
|
||||
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(
|
||||
@@ -2170,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"])
|
||||
@@ -2283,18 +2367,35 @@ 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":
|
||||
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(
|
||||
(
|
||||
request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE
|
||||
!= ""
|
||||
else DEFAULT_CODE_INTERPRETER_PROMPT
|
||||
),
|
||||
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)
|
||||
@@ -2644,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(
|
||||
@@ -3082,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"],
|
||||
@@ -3089,6 +3202,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"output": response_output,
|
||||
**({"usage": usage} if usage else {}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3718,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[
|
||||
@@ -3766,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(
|
||||
{
|
||||
@@ -4078,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()
|
||||
@@ -4090,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
|
||||
@@ -4136,25 +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}"
|
||||
)
|
||||
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
|
||||
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(
|
||||
@@ -4244,7 +4379,8 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
|
||||
# Extract citation sources from tool results
|
||||
if (
|
||||
tool_function_name
|
||||
citations_enabled
|
||||
and tool_function_name
|
||||
in [
|
||||
"search_web",
|
||||
"fetch_url",
|
||||
@@ -4334,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
|
||||
set_last_user_message_content(
|
||||
user_message, form_data["messages"]
|
||||
)
|
||||
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(
|
||||
{
|
||||
@@ -4456,6 +4627,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
"session_id": metadata.get(
|
||||
"session_id", None
|
||||
),
|
||||
"files": metadata.get("files", []),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,11 +332,15 @@ 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 = Functions.get_function_valves_by_id(action_id)
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
@@ -113,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
|
||||
@@ -787,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(
|
||||
@@ -1081,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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -432,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
|
||||
@@ -1186,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"),
|
||||
@@ -1247,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))
|
||||
|
||||
@@ -1319,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
|
||||
|
||||
Generated
+560
-467
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.8.6",
|
||||
"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,8 @@
|
||||
"@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",
|
||||
@@ -108,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",
|
||||
@@ -131,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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -5,6 +5,16 @@ export type FileEntry = {
|
||||
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 = {
|
||||
@@ -23,6 +33,18 @@ export const getTerminalServers = async (token: string): Promise<TerminalServer[
|
||||
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, {
|
||||
@@ -193,3 +215,124 @@ export const setCwd = async (
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
let id = '';
|
||||
let auth_type = 'bearer';
|
||||
let path = '/openapi.json';
|
||||
let enabled = true;
|
||||
let enabled = false;
|
||||
let showAdvanced = false;
|
||||
let showAccessControlModal = false;
|
||||
let accessGrants: any[] = [];
|
||||
@@ -47,7 +47,7 @@
|
||||
name = '';
|
||||
auth_type = 'bearer';
|
||||
path = '/openapi.json';
|
||||
enabled = true;
|
||||
enabled = false;
|
||||
accessGrants = [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -898,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"
|
||||
@@ -907,9 +907,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 !== ''),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -219,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"
|
||||
@@ -228,9 +228,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+124
-109
@@ -65,6 +65,7 @@
|
||||
import { AudioQueue } from '$lib/utils/audio';
|
||||
|
||||
import {
|
||||
archiveChatById,
|
||||
createNewChat,
|
||||
getAllTags,
|
||||
getChatById,
|
||||
@@ -104,6 +105,7 @@
|
||||
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 = '';
|
||||
|
||||
@@ -146,34 +148,6 @@
|
||||
let webSearchEnabled = false;
|
||||
let codeInterpreterEnabled = false;
|
||||
|
||||
// Auto-inject direct terminal servers into selected tool IDs so they act like toggled-on tools
|
||||
// System terminals (with id field) are handled server-side via terminal_id, not as direct tool servers
|
||||
$: if ($terminalServers && $terminalServers.length > 0) {
|
||||
const directTerminalServers = $terminalServers.filter((t) => !t.id);
|
||||
const terminalIds = directTerminalServers.map(
|
||||
(_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}`
|
||||
);
|
||||
const missingIds = terminalIds.filter((id) => !selectedToolIds.includes(id));
|
||||
if (missingIds.length > 0) {
|
||||
selectedToolIds = [...selectedToolIds, ...missingIds];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove disabled terminal servers from selectedToolIds automatically
|
||||
$: if (selectedToolIds.length > 0) {
|
||||
const directTerminalServers = ($terminalServers ?? []).filter((t) => !t.id);
|
||||
const terminalIds = directTerminalServers.map(
|
||||
(_, i) =>
|
||||
`direct_server:terminal_${($terminalServers ?? []).indexOf(directTerminalServers[i])}`
|
||||
);
|
||||
const invalidTerminalIds = selectedToolIds.filter(
|
||||
(id) => id.startsWith('direct_server:terminal_') && !terminalIds.includes(id)
|
||||
);
|
||||
if (invalidTerminalIds.length > 0) {
|
||||
selectedToolIds = selectedToolIds.filter((id) => !invalidTerminalIds.includes(id));
|
||||
}
|
||||
}
|
||||
|
||||
let showCommands = false;
|
||||
|
||||
let generating = false;
|
||||
@@ -354,25 +328,12 @@
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
||||
)
|
||||
];
|
||||
} else if (
|
||||
$settings?.tools &&
|
||||
$settings.tools.some((id) => !id.startsWith('direct_server:terminal_'))
|
||||
) {
|
||||
} else if ($settings?.tools) {
|
||||
selectedToolIds = $settings.tools;
|
||||
} else {
|
||||
// Don't wipe existing terminal servers if no default tool IDs
|
||||
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
|
||||
}
|
||||
|
||||
// Auto-inject direct terminal servers (system ones are handled via terminal_id)
|
||||
if ($terminalServers && $terminalServers.length > 0) {
|
||||
const directTerminalServers = $terminalServers.filter((t) => !t.id);
|
||||
const terminalIds = directTerminalServers.map(
|
||||
(_, i) => `direct_server:terminal_${$terminalServers.indexOf(directTerminalServers[i])}`
|
||||
);
|
||||
selectedToolIds = [...new Set([...selectedToolIds, ...terminalIds])];
|
||||
}
|
||||
|
||||
// Set Default Filters (Toggleable only)
|
||||
if (model?.info?.meta?.defaultFilterIds) {
|
||||
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
|
||||
@@ -446,11 +407,14 @@
|
||||
};
|
||||
|
||||
const terminalEventHandler = (type: string, data: any) => {
|
||||
if (!data?.path) return;
|
||||
if (type === 'terminal:display_file') {
|
||||
if (!data?.path) return;
|
||||
displayFileHandler(data.path, { showControls, showFileNavPath });
|
||||
} else if (type === 'terminal:write_file') {
|
||||
} 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('/');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -685,6 +649,13 @@
|
||||
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();
|
||||
@@ -963,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) : [];
|
||||
@@ -1294,6 +1273,7 @@
|
||||
};
|
||||
|
||||
let scrollRAF = null;
|
||||
let contentsRAF = null;
|
||||
const scheduleScrollToBottom = () => {
|
||||
if (!scrollRAF) {
|
||||
scrollRAF = requestAnimationFrame(async () => {
|
||||
@@ -1597,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]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1631,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]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1678,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', {
|
||||
@@ -2027,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];
|
||||
@@ -2188,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,
|
||||
@@ -2205,7 +2197,9 @@
|
||||
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: {
|
||||
@@ -2619,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>
|
||||
@@ -2701,7 +2714,7 @@
|
||||
bind:selectedModels
|
||||
shareEnabled={!!history.currentId}
|
||||
{initNewChat}
|
||||
archiveChatHandler={() => {}}
|
||||
{archiveChatHandler}
|
||||
{moveChatHandler}
|
||||
onSaveTempChat={async () => {
|
||||
try {
|
||||
@@ -2719,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()
|
||||
@@ -2910,6 +2924,7 @@
|
||||
{stopResponse}
|
||||
{showMessage}
|
||||
{eventTarget}
|
||||
{codeInterpreterEnabled}
|
||||
/>
|
||||
</PaneGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script context="module">
|
||||
<script context="module" lang="ts">
|
||||
let savedTab: 'controls' | 'files' | 'overview' = 'controls';
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { onDestroy, onMount, tick, getContext } from 'svelte';
|
||||
import {
|
||||
config,
|
||||
terminalServers,
|
||||
mobile,
|
||||
showControls,
|
||||
@@ -31,6 +32,7 @@
|
||||
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');
|
||||
@@ -50,21 +52,28 @@
|
||||
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: 'controls' | 'files' | 'overview' = savedTab;
|
||||
$: savedTab = activeTab;
|
||||
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;
|
||||
$: showFilesTab =
|
||||
!!$selectedTerminalId ||
|
||||
(codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter');
|
||||
$: showOverviewTab = hasMessages;
|
||||
|
||||
// Tab fallback: if active tab becomes hidden, switch to next available
|
||||
@@ -172,56 +181,67 @@
|
||||
dragged = false;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
onMount(() => {
|
||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
mediaQuery.addEventListener('change', handleMediaQuery);
|
||||
handleMediaQuery(mediaQuery);
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
// Wait for Svelte to render the Pane after largeScreen changed
|
||||
await tick();
|
||||
const init = async () => {
|
||||
await tick();
|
||||
|
||||
const container = document.getElementById('chat-container');
|
||||
minSize = Math.floor((350 / container.clientWidth) * 100);
|
||||
if (isDestroyed) return;
|
||||
|
||||
const 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.resize(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
};
|
||||
init();
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
setTimeout(() => {
|
||||
paneReady = true;
|
||||
}, 0);
|
||||
|
||||
// If controls were persisted as open, set the pane to the saved size
|
||||
if ($showControls && pane) {
|
||||
openPane();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
paneReady = false;
|
||||
if (!largeScreen) {
|
||||
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 = () => {
|
||||
@@ -270,10 +290,11 @@
|
||||
<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">
|
||||
<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 {activeTab === 'controls'
|
||||
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')}
|
||||
@@ -283,7 +304,8 @@
|
||||
{/if}
|
||||
{#if showFilesTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
|
||||
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')}
|
||||
@@ -293,7 +315,8 @@
|
||||
{/if}
|
||||
{#if showOverviewTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'overview'
|
||||
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')}
|
||||
@@ -325,7 +348,7 @@
|
||||
? 'h-full'
|
||||
: activeTab === 'controls'
|
||||
? 'overflow-y-auto px-3 pt-1'
|
||||
: 'overflow-y-auto'}"
|
||||
: ''}"
|
||||
>
|
||||
{#if activeTab === 'overview'}
|
||||
<Overview
|
||||
@@ -338,6 +361,8 @@
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav />
|
||||
{:else}
|
||||
<Controls embed={true} {models} bind:chatFiles bind:params />
|
||||
{/if}
|
||||
@@ -384,7 +409,10 @@
|
||||
<div
|
||||
class="w-full {specialPanel && !$showCallOverlay
|
||||
? ' '
|
||||
: '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}
|
||||
@@ -408,10 +436,11 @@
|
||||
<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">
|
||||
<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 {activeTab === 'controls'
|
||||
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')}
|
||||
@@ -421,7 +450,8 @@
|
||||
{/if}
|
||||
{#if showFilesTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'files'
|
||||
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')}
|
||||
@@ -431,7 +461,8 @@
|
||||
{/if}
|
||||
{#if showOverviewTab}
|
||||
<button
|
||||
class="px-2.5 py-1 text-sm rounded-lg transition {activeTab === 'overview'
|
||||
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')}
|
||||
@@ -463,7 +494,7 @@
|
||||
? 'h-full'
|
||||
: activeTab === 'controls'
|
||||
? 'overflow-y-auto px-3 pt-1'
|
||||
: 'overflow-y-auto'}"
|
||||
: ''}"
|
||||
>
|
||||
{#if activeTab === 'overview'}
|
||||
<Overview
|
||||
@@ -480,7 +511,9 @@
|
||||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} />
|
||||
<FileNav onAttach={handleTerminalAttach} overlay={dragged} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav overlay={dragged} />
|
||||
{:else}
|
||||
<Controls embed={true} {models} bind:chatFiles bind:params />
|
||||
{/if}
|
||||
|
||||
@@ -15,15 +15,18 @@
|
||||
} from '$lib/stores';
|
||||
import {
|
||||
getCwd,
|
||||
getTerminalConfig,
|
||||
listFiles,
|
||||
readFile,
|
||||
downloadFileBlob,
|
||||
uploadToTerminal,
|
||||
createDirectory,
|
||||
deleteEntry,
|
||||
moveEntry,
|
||||
setCwd,
|
||||
type FileEntry
|
||||
} from '$lib/apis/terminal';
|
||||
import { isCodeFile } from '$lib/utils/codeHighlight';
|
||||
import Folder from '../icons/Folder.svelte';
|
||||
import Document from '../icons/Document.svelte';
|
||||
import PenAlt from '../icons/PenAlt.svelte';
|
||||
@@ -35,10 +38,48 @@
|
||||
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
|
||||
import FilePreview from './FileNav/FilePreview.svelte';
|
||||
import FileEntryRow from './FileNav/FileEntryRow.svelte';
|
||||
import PortList from './FileNav/PortList.svelte';
|
||||
import XTerminal from './XTerminal.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null;
|
||||
export let overlay = false;
|
||||
|
||||
// ── Terminal panel state ────────────────────────────────────────────
|
||||
let terminalExpanded = false;
|
||||
let terminalHeight = 200; // px, default when expanded
|
||||
let isDraggingHandle = false;
|
||||
let containerEl: HTMLElement;
|
||||
let terminalConnected = false;
|
||||
let terminalConnecting = false;
|
||||
let terminalEnabled = true;
|
||||
|
||||
const toggleTerminal = () => {
|
||||
terminalExpanded = !terminalExpanded;
|
||||
};
|
||||
|
||||
const onHandleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingHandle = true;
|
||||
const startY = e.clientY;
|
||||
const startHeight = terminalHeight;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
const delta = startY - ev.clientY;
|
||||
const maxH = containerEl ? containerEl.clientHeight - 100 : 500;
|
||||
terminalHeight = Math.max(80, Math.min(maxH, startHeight + delta));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isDraggingHandle = false;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
// ── Directory state ──────────────────────────────────────────────────
|
||||
let currentPath = savedPath;
|
||||
@@ -50,10 +91,21 @@
|
||||
let selectedFile: string | null = null;
|
||||
let fileContent: string | null = null;
|
||||
let fileImageUrl: string | null = null;
|
||||
let fileVideoUrl: string | null = null;
|
||||
let fileAudioUrl: string | null = null;
|
||||
let filePdfData: ArrayBuffer | null = null;
|
||||
let fileSqliteData: ArrayBuffer | null = null;
|
||||
let fileLoading = false;
|
||||
let filePreviewRef: FilePreview;
|
||||
|
||||
// ── Office preview state ────────────────────────────────────────────
|
||||
let fileOfficeHtml: string | null = null;
|
||||
let fileOfficeSlides: string[] | null = null;
|
||||
let currentSlide = 0;
|
||||
let excelSheetNames: string[] = [];
|
||||
let selectedExcelSheet = '';
|
||||
let excelWorkbook: import('xlsx').WorkBook | null = null;
|
||||
|
||||
// ── File preview toolbar state (bound from FilePreview) ─────────────
|
||||
let editing = false;
|
||||
let showRaw = false;
|
||||
@@ -61,11 +113,20 @@
|
||||
|
||||
const MD_EXTS = new Set(['md', 'markdown', 'mdx']);
|
||||
const CSV_EXTS = new Set(['csv', 'tsv']);
|
||||
const HTML_EXTS = new Set(['html', 'htm']);
|
||||
const OFFICE_EXTS = new Set(['docx', 'xlsx', 'pptx']);
|
||||
const getFileExt = (path: string | null) => path?.split('.').pop()?.toLowerCase() ?? '';
|
||||
|
||||
$: isMarkdown = MD_EXTS.has(getFileExt(selectedFile));
|
||||
$: isCsv = CSV_EXTS.has(getFileExt(selectedFile));
|
||||
$: isTextFile = fileContent !== null && fileImageUrl === null && filePdfData === null;
|
||||
$: isHtml = HTML_EXTS.has(getFileExt(selectedFile));
|
||||
$: isJson = ['json', 'jsonc', 'jsonl', 'json5'].includes(getFileExt(selectedFile));
|
||||
$: isSvg = getFileExt(selectedFile) === 'svg';
|
||||
$: isNotebook = getFileExt(selectedFile) === 'ipynb';
|
||||
$: isCode = isCodeFile(selectedFile);
|
||||
$: isOfficeFile = OFFICE_EXTS.has(getFileExt(selectedFile));
|
||||
$: isTextFile =
|
||||
fileContent !== null && fileImageUrl === null && filePdfData === null && !isOfficeFile;
|
||||
|
||||
// ── Upload / folder creation ─────────────────────────────────────────
|
||||
let isDragOver = false;
|
||||
@@ -112,7 +173,12 @@
|
||||
if (terminal && terminal.url !== prevTerminalUrl) {
|
||||
prevTerminalUrl = terminal.url;
|
||||
(async () => {
|
||||
const cwd = await getCwd(terminal.url, terminal.key);
|
||||
// Discover server features (terminal enabled/disabled)
|
||||
const config = await getTerminalConfig(terminal.url, terminal.key);
|
||||
terminalEnabled = config?.features?.terminal !== false;
|
||||
|
||||
const rawCwd = await getCwd(terminal.url, terminal.key);
|
||||
const cwd = rawCwd ? normalizePath(rawCwd) : null;
|
||||
const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/';
|
||||
savedPath = dir;
|
||||
loadDir(dir);
|
||||
@@ -121,22 +187,33 @@
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']);
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
|
||||
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']);
|
||||
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']);
|
||||
const SQLITE_EXTS = new Set(['db', 'sqlite', 'sqlite3', 'db3']);
|
||||
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
const isVideo = (path: string) => VIDEO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
const isAudio = (path: string) => AUDIO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
const isSqlite = (path: string) => SQLITE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
|
||||
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
|
||||
const buildBreadcrumbs = (path: string) =>
|
||||
path
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.reduce(
|
||||
(acc, part) => {
|
||||
const prev = acc[acc.length - 1];
|
||||
acc.push({ label: part, path: `${prev.path}${part}/` });
|
||||
return acc;
|
||||
},
|
||||
[{ label: '/', path: '/' }]
|
||||
);
|
||||
/** Normalize Windows backslashes to forward slashes. */
|
||||
const normalizePath = (p: string) => p.replace(/\\/g, '/');
|
||||
|
||||
const buildBreadcrumbs = (path: string) => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const isDrive = /^[A-Za-z]:$/.test(parts[0] ?? '');
|
||||
const root = isDrive ? { label: parts[0], path: `${parts[0]}/` } : { label: '/', path: '/' };
|
||||
return (isDrive ? parts.slice(1) : parts).reduce(
|
||||
(acc, part) => {
|
||||
const prev = acc[acc.length - 1];
|
||||
acc.push({ label: part, path: `${prev.path}${part}/` });
|
||||
return acc;
|
||||
},
|
||||
[root]
|
||||
);
|
||||
};
|
||||
|
||||
// ── File preview management ──────────────────────────────────────────
|
||||
const clearFilePreview = () => {
|
||||
@@ -146,7 +223,22 @@
|
||||
URL.revokeObjectURL(fileImageUrl);
|
||||
fileImageUrl = null;
|
||||
}
|
||||
if (fileVideoUrl) {
|
||||
URL.revokeObjectURL(fileVideoUrl);
|
||||
fileVideoUrl = null;
|
||||
}
|
||||
if (fileAudioUrl) {
|
||||
URL.revokeObjectURL(fileAudioUrl);
|
||||
fileAudioUrl = null;
|
||||
}
|
||||
filePdfData = null;
|
||||
fileSqliteData = null;
|
||||
fileOfficeHtml = null;
|
||||
fileOfficeSlides = null;
|
||||
currentSlide = 0;
|
||||
excelSheetNames = [];
|
||||
selectedExcelSheet = '';
|
||||
excelWorkbook = null;
|
||||
};
|
||||
|
||||
// ── Directory operations ─────────────────────────────────────────────
|
||||
@@ -196,9 +288,51 @@
|
||||
if (isImage(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) fileImageUrl = URL.createObjectURL(result.blob);
|
||||
} else if (isVideo(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
|
||||
} else if (isAudio(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
|
||||
} else if (isPdf(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) filePdfData = await result.blob.arrayBuffer();
|
||||
} else if (isSqlite(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) fileSqliteData = await result.blob.arrayBuffer();
|
||||
} else if (isOffice(filePath)) {
|
||||
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
|
||||
if (result) {
|
||||
const ext = getFileExt(filePath);
|
||||
const arrayBuffer = await result.blob.arrayBuffer();
|
||||
try {
|
||||
if (ext === 'docx') {
|
||||
const mammoth = await import('mammoth');
|
||||
const res = await mammoth.convertToHtml({ arrayBuffer });
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(res.value);
|
||||
} else if (ext === 'xlsx') {
|
||||
const XLSX = await import('xlsx');
|
||||
const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
|
||||
excelWorkbook = wb;
|
||||
excelSheetNames = wb.SheetNames;
|
||||
if (excelSheetNames.length > 0) {
|
||||
selectedExcelSheet = excelSheetNames[0];
|
||||
const { excelToTable } = await import('$lib/utils/excelToTable');
|
||||
const result = await excelToTable(wb.Sheets[selectedExcelSheet]);
|
||||
fileOfficeHtml = result.html;
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
fileOfficeSlides = result.images;
|
||||
currentSlide = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to render Office file:', e);
|
||||
fileContent = `Error previewing file: ${e instanceof Error ? e.message : 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileContent = await readFile(terminal.url, terminal.key, filePath);
|
||||
}
|
||||
@@ -323,6 +457,29 @@
|
||||
showDeleteConfirm = true;
|
||||
};
|
||||
|
||||
// ── Move (drag-and-drop) ────────────────────────────────────────────
|
||||
const handleMove = async (source: string, destFolder: string) => {
|
||||
const terminal = selectedTerminal;
|
||||
if (!terminal) return;
|
||||
|
||||
const fileName = source.split('/').pop() ?? '';
|
||||
const destination = `${destFolder}${fileName}`;
|
||||
|
||||
if (source === destination) return;
|
||||
|
||||
// Prevent moving a folder into itself or its own subtree
|
||||
const sourceDir = source.endsWith('/') ? source : source + '/';
|
||||
if (destFolder.startsWith(sourceDir)) return;
|
||||
|
||||
const result = await moveEntry(terminal.url, terminal.key, source, destination);
|
||||
if ('error' in result) {
|
||||
toast.error(result.error);
|
||||
} else {
|
||||
toast.success($i18n.t('Moved {{name}}', { name: fileName }));
|
||||
}
|
||||
await loadDir(currentPath);
|
||||
};
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────────
|
||||
onMount(async () => {
|
||||
const terminal = getTerminal();
|
||||
@@ -334,40 +491,49 @@
|
||||
if (!filePath || !selectedTerminal) return;
|
||||
handledDisplayFile = true;
|
||||
showFileNavPath.set(null);
|
||||
filePath = normalizePath(filePath);
|
||||
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash + 1) : '/';
|
||||
const fileName = filePath.substring(lastSlash + 1);
|
||||
|
||||
if (dir !== currentPath) {
|
||||
await loadDir(dir);
|
||||
}
|
||||
|
||||
// Always reload directory to ensure entries are fresh
|
||||
await loadDir(dir);
|
||||
await tick();
|
||||
|
||||
const entry = entries.find((e) => e.name === fileName);
|
||||
if (entry) await openEntry(entry);
|
||||
if (entry) {
|
||||
await openEntry(entry);
|
||||
} else {
|
||||
// File may not be in listing; open it directly
|
||||
await openEntry({ name: fileName, type: 'file', size: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
const unsubFileNavDir = showFileNavDir.subscribe(async (filePath) => {
|
||||
if (!filePath || !selectedTerminal) return;
|
||||
showFileNavDir.set(null);
|
||||
filePath = normalizePath(filePath);
|
||||
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash + 1) : '/';
|
||||
|
||||
if (dir === currentPath) {
|
||||
await loadDir(currentPath);
|
||||
}
|
||||
if (filePath === selectedFile) {
|
||||
const fileName = filePath.substring(lastSlash + 1);
|
||||
const entry = entries.find((e) => e.name === fileName);
|
||||
if (entry) await openEntry(entry);
|
||||
if (selectedFile) {
|
||||
if (selectedFile === filePath || currentPath.startsWith(dir)) {
|
||||
const fileName = selectedFile.split('/').pop() ?? '';
|
||||
await openEntry({ name: fileName, type: 'file', size: 0 });
|
||||
}
|
||||
} else {
|
||||
if (currentPath.startsWith(dir) || dir.startsWith(currentPath)) {
|
||||
await loadDir(currentPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!handledDisplayFile) {
|
||||
if (savedPath === '/') {
|
||||
const cwd = await getCwd(terminal.url, terminal.key);
|
||||
const rawCwd = await getCwd(terminal.url, terminal.key);
|
||||
const cwd = rawCwd ? normalizePath(rawCwd) : null;
|
||||
if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/';
|
||||
}
|
||||
loadDir(savedPath);
|
||||
@@ -393,6 +559,8 @@
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
unsubFileNav();
|
||||
unsubFileNavDir();
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
@@ -402,6 +570,8 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (fileImageUrl) URL.revokeObjectURL(fileImageUrl);
|
||||
if (fileVideoUrl) URL.revokeObjectURL(fileVideoUrl);
|
||||
if (fileAudioUrl) URL.revokeObjectURL(fileAudioUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -427,7 +597,8 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col h-full min-h-0 relative"
|
||||
bind:this={containerEl}
|
||||
class="flex flex-col h-full min-h-0 min-w-0 relative"
|
||||
on:dragover={handleDragOver}
|
||||
on:dragleave={() => (isDragOver = false)}
|
||||
on:drop={handleDrop}
|
||||
@@ -461,12 +632,20 @@
|
||||
{selectedFile}
|
||||
{loading}
|
||||
onNavigate={loadDir}
|
||||
onRefresh={() => loadDir(currentPath)}
|
||||
onRefresh={() => {
|
||||
if (selectedFile) {
|
||||
const fileName = selectedFile.split('/').pop() ?? '';
|
||||
openEntry({ name: fileName, type: 'file', size: 0 });
|
||||
} else {
|
||||
loadDir(currentPath);
|
||||
}
|
||||
}}
|
||||
onNewFolder={startNewFolder}
|
||||
onNewFile={startNewFile}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
onMove={handleMove}
|
||||
>
|
||||
{#if fileImageUrl !== null}
|
||||
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
|
||||
<Tooltip content={$i18n.t('Reset view')}>
|
||||
<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"
|
||||
@@ -488,7 +667,7 @@
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if (isMarkdown || isCsv) && fileContent !== null && !editing}
|
||||
{#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing}
|
||||
<Tooltip content={showRaw ? $i18n.t('Preview') : $i18n.t('Source')}>
|
||||
<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"
|
||||
@@ -538,7 +717,61 @@
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if isTextFile}
|
||||
{#if editing}
|
||||
{#if isHtml && showRaw}
|
||||
<Tooltip content={$i18n.t('Save')}>
|
||||
<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={() => filePreviewRef?.saveCodeFile()}
|
||||
disabled={saving}
|
||||
aria-label={$i18n.t('Save')}
|
||||
>
|
||||
{#if saving}
|
||||
<Spinner className="size-3.5" />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else if isHtml}
|
||||
<!-- HTML preview mode: no edit/save buttons -->
|
||||
{:else if isCode}
|
||||
<Tooltip content={$i18n.t('Save')}>
|
||||
<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={() => filePreviewRef?.saveCodeFile()}
|
||||
disabled={saving}
|
||||
aria-label={$i18n.t('Save')}
|
||||
>
|
||||
{#if saving}
|
||||
<Spinner className="size-3.5" />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else if editing}
|
||||
<Tooltip content={$i18n.t('Cancel')}>
|
||||
<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"
|
||||
@@ -594,6 +827,34 @@
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if fileContent !== null}
|
||||
<Tooltip content={$i18n.t('Copy')}>
|
||||
<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={async () => {
|
||||
await navigator.clipboard.writeText(fileContent ?? '');
|
||||
toast.success($i18n.t('Copied to clipboard'));
|
||||
}}
|
||||
aria-label={$i18n.t('Copy')}
|
||||
>
|
||||
<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="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<Tooltip content={$i18n.t('Download')}>
|
||||
<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"
|
||||
@@ -619,18 +880,36 @@
|
||||
</FileNavToolbar>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="flex-1 overflow-y-auto min-h-0 min-w-0">
|
||||
{#if selectedFile !== null}
|
||||
<FilePreview
|
||||
bind:this={filePreviewRef}
|
||||
bind:editing
|
||||
bind:showRaw
|
||||
bind:saving
|
||||
bind:currentSlide
|
||||
{selectedFile}
|
||||
{fileLoading}
|
||||
{fileImageUrl}
|
||||
{fileVideoUrl}
|
||||
{fileAudioUrl}
|
||||
{filePdfData}
|
||||
{fileSqliteData}
|
||||
{fileContent}
|
||||
{fileOfficeHtml}
|
||||
{fileOfficeSlides}
|
||||
{excelSheetNames}
|
||||
{selectedExcelSheet}
|
||||
onSheetChange={async (sheet) => {
|
||||
if (!excelWorkbook) return;
|
||||
selectedExcelSheet = sheet;
|
||||
const { excelToTable } = await import('$lib/utils/excelToTable');
|
||||
const result = await excelToTable(excelWorkbook.Sheets[sheet]);
|
||||
fileOfficeHtml = result.html;
|
||||
}}
|
||||
baseUrl={selectedTerminal?.url ?? ''}
|
||||
apiKey={selectedTerminal?.key ?? ''}
|
||||
{overlay}
|
||||
onSave={async (content) => {
|
||||
const terminal = selectedTerminal;
|
||||
if (!terminal || !selectedFile) return;
|
||||
@@ -717,6 +996,7 @@
|
||||
onOpen={openEntry}
|
||||
onDownload={downloadFile}
|
||||
onDelete={requestDelete}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -724,5 +1004,81 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Port detection -->
|
||||
{#if selectedTerminal && !selectedFile}
|
||||
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800">
|
||||
<PortList baseUrl={selectedTerminal.url} apiKey={selectedTerminal.key} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Terminal bottom panel -->
|
||||
{#if terminalEnabled}
|
||||
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850">
|
||||
{#if terminalExpanded}
|
||||
<!-- Drag handle (at top of panel) -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="h-1 cursor-row-resize hover:bg-blue-400/30 transition group relative"
|
||||
on:mousedown={onHandleMouseDown}
|
||||
>
|
||||
<div class="absolute inset-x-0 -top-1 -bottom-1" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toggle header (full-width button) -->
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition"
|
||||
on:click={toggleTerminal}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3.25 3A2.25 2.25 0 0 0 1 5.25v9.5A2.25 2.25 0 0 0 3.25 17h13.5A2.25 2.25 0 0 0 19 14.75v-9.5A2.25 2.25 0 0 0 16.75 3H3.25Zm.943 8.752a.75.75 0 0 1 .055-1.06L6.128 9l-1.88-1.693a.75.75 0 1 1 1.004-1.114l2.5 2.25a.75.75 0 0 1 0 1.114l-2.5 2.25a.75.75 0 0 1-1.06-.055ZM9.75 10.25a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-medium">{$i18n.t('Terminal')}</span>
|
||||
|
||||
{#if terminalExpanded}
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors {terminalConnected
|
||||
? 'bg-emerald-500'
|
||||
: terminalConnecting
|
||||
? 'bg-yellow-500 animate-pulse'
|
||||
: 'bg-gray-400'}"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3 ml-auto transition-transform {terminalExpanded ? 'rotate-180' : ''}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.47 6.47a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 1 1-1.06 1.06L10 8.06l-3.72 3.72a.75.75 0 0 1-1.06-1.06l4.25-4.25Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if terminalExpanded}
|
||||
<div style="height: {terminalHeight}px" class="min-h-0">
|
||||
<XTerminal
|
||||
{overlay}
|
||||
bind:connected={terminalConnected}
|
||||
bind:connecting={terminalConnecting}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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>
|
||||
@@ -12,30 +12,76 @@
|
||||
|
||||
export let entry: FileEntry;
|
||||
export let currentPath: string;
|
||||
export let terminalUrl: string;
|
||||
export let terminalKey: 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">
|
||||
<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={entry.type === 'file'}
|
||||
draggable={true}
|
||||
on:dragstart={(e) => {
|
||||
if (entry.type !== 'file') return;
|
||||
const filePath = `${currentPath}${entry.name}`;
|
||||
// Internal move data
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
JSON.stringify({
|
||||
path: `${currentPath}${entry.name}`,
|
||||
name: entry.name,
|
||||
url: terminalUrl,
|
||||
key: terminalKey
|
||||
})
|
||||
'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)}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
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;
|
||||
@@ -41,8 +44,31 @@
|
||||
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'}"
|
||||
: '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>
|
||||
@@ -55,27 +81,28 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !selectedFile}
|
||||
<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')}
|
||||
<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' : ''}"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
|
||||
@@ -3,19 +3,44 @@
|
||||
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;
|
||||
@@ -53,20 +78,71 @@
|
||||
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[][] = [];
|
||||
@@ -110,6 +186,61 @@
|
||||
$: 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
|
||||
|
||||
@@ -119,7 +250,8 @@
|
||||
pzInstance = panzoom(node, {
|
||||
bounds: true,
|
||||
boundsPadding: 0.1,
|
||||
zoomSpeed: 0.065
|
||||
zoomSpeed: 0.065,
|
||||
zoomDoubleClickSpeed: 1
|
||||
});
|
||||
};
|
||||
|
||||
@@ -147,9 +279,9 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 {fileImageUrl !== null
|
||||
class="flex-1 {fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto'} min-h-0 relative h-full"
|
||||
: 'overflow-y-auto'} min-h-0 min-w-0 relative h-full"
|
||||
>
|
||||
<!-- File preview -->
|
||||
{#if fileLoading}
|
||||
@@ -163,16 +295,138 @@
|
||||
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 isMarkdown && !showRaw}
|
||||
<div class="prose dark:prose-invert max-w-full text-sm p-3">
|
||||
{#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="overflow-auto h-full px-3 pb-3">
|
||||
<table class="csv-table w-full text-xs font-mono border-collapse">
|
||||
<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>
|
||||
@@ -197,6 +451,40 @@
|
||||
</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}
|
||||
@@ -209,7 +497,7 @@
|
||||
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-sm text-gray-400 text-center pt-8">
|
||||
<div class="text-xs text-gray-400 text-center pt-8">
|
||||
{$i18n.t('Could not read file.')}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -267,4 +555,171 @@
|
||||
: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>
|
||||
@@ -0,0 +1,645 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from 'svelte';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { highlightCode } from '$lib/utils/codeHighlight';
|
||||
import {
|
||||
createNotebookSession,
|
||||
executeNotebookCell,
|
||||
stopNotebookSession
|
||||
} from '$lib/apis/terminal';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import Tooltip from '../../common/Tooltip.svelte';
|
||||
import CellEditor from './CellEditor.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let notebook: Record<string, unknown>;
|
||||
export let filePath: string = '';
|
||||
export let baseUrl: string = '';
|
||||
export let apiKey: string = '';
|
||||
|
||||
interface NotebookCell {
|
||||
cell_type: 'markdown' | 'code' | 'raw';
|
||||
source: string[] | string;
|
||||
outputs?: NotebookOutput[];
|
||||
execution_count?: number | null;
|
||||
}
|
||||
|
||||
interface NotebookOutput {
|
||||
output_type: 'stream' | 'execute_result' | 'display_data' | 'error';
|
||||
text?: string[] | string;
|
||||
data?: Record<string, string[] | string>;
|
||||
name?: string;
|
||||
ename?: string;
|
||||
evalue?: string;
|
||||
traceback?: string[];
|
||||
}
|
||||
|
||||
$: cells = (notebook?.cells as NotebookCell[]) ?? [];
|
||||
$: lang = (notebook?.metadata as Record<string, unknown>)?.kernelspec
|
||||
? ((notebook.metadata as Record<string, Record<string, string>>).kernelspec?.language ??
|
||||
'python')
|
||||
: 'python';
|
||||
|
||||
const toStr = (s: string[] | string | undefined): string =>
|
||||
Array.isArray(s) ? s.join('') : (s ?? '');
|
||||
|
||||
// ── Syntax highlighting ──────────────────────────────────────────────
|
||||
let highlightedCells: Record<number, string> = {};
|
||||
|
||||
const highlightCell = async (index: number, code: string, language: string) => {
|
||||
try {
|
||||
const { codeToHtml } = await import('shiki');
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: { light: 'github-light', dark: 'github-dark' },
|
||||
defaultColor: 'light'
|
||||
});
|
||||
highlightedCells[index] = html;
|
||||
highlightedCells = highlightedCells;
|
||||
} catch {
|
||||
// fallback handled in template
|
||||
}
|
||||
};
|
||||
|
||||
$: {
|
||||
highlightedCells = {};
|
||||
cells.forEach((cell, i) => {
|
||||
if (cell.cell_type === 'code' && !editingCell[i]) {
|
||||
highlightCell(i, toStr(cell.source), lang);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Markdown / output helpers ────────────────────────────────────────
|
||||
const renderMarkdown = (src: string): string =>
|
||||
DOMPurify.sanitize(marked.parse(src, { async: false }) as string);
|
||||
|
||||
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
|
||||
|
||||
const getOutputImages = (output: NotebookOutput): string[] => {
|
||||
if (!output.data) return [];
|
||||
const images: string[] = [];
|
||||
for (const [mime, content] of Object.entries(output.data)) {
|
||||
if (mime.startsWith('image/')) {
|
||||
const raw = Array.isArray(content) ? content.join('') : content;
|
||||
images.push(raw.startsWith('data:') ? raw : `data:${mime};base64,${raw}`);
|
||||
}
|
||||
}
|
||||
return images;
|
||||
};
|
||||
|
||||
const getOutputHtml = (output: NotebookOutput): string | null => {
|
||||
if (!output.data?.['text/html']) return null;
|
||||
return DOMPurify.sanitize(toStr(output.data['text/html']));
|
||||
};
|
||||
|
||||
const getOutputText = (output: NotebookOutput): string | null => {
|
||||
if (output.output_type === 'stream') return toStr(output.text);
|
||||
if (output.data?.['text/plain']) return toStr(output.data['text/plain']);
|
||||
return null;
|
||||
};
|
||||
|
||||
// ── Cell editing ─────────────────────────────────────────────────────
|
||||
let editingCell: Record<number, boolean> = {};
|
||||
let editedSources: Record<number, string> = {};
|
||||
|
||||
const startEditing = (index: number) => {
|
||||
editingCell[index] = true;
|
||||
editedSources[index] = toStr(cells[index].source);
|
||||
editingCell = editingCell;
|
||||
};
|
||||
|
||||
const cancelEditing = (index: number) => {
|
||||
delete editingCell[index];
|
||||
delete editedSources[index];
|
||||
editingCell = editingCell;
|
||||
highlightCell(index, toStr(cells[index].source), lang);
|
||||
};
|
||||
|
||||
// ── Session / kernel ─────────────────────────────────────────────────
|
||||
let sessionId: string | null = null;
|
||||
let kernelReady = false;
|
||||
let kernelStarting = false;
|
||||
let kernelError: string | null = null;
|
||||
let runningCell: number | null = null;
|
||||
let runAllActive = false;
|
||||
|
||||
const canExecute = baseUrl && apiKey && filePath;
|
||||
|
||||
const startSession = async (): Promise<boolean> => {
|
||||
if (!baseUrl || !apiKey || !filePath) return false;
|
||||
kernelStarting = true;
|
||||
kernelError = null;
|
||||
|
||||
const result = await createNotebookSession(baseUrl, apiKey, filePath);
|
||||
|
||||
if ('error' in result) {
|
||||
kernelStarting = false;
|
||||
kernelError = result.error;
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionId = result.id;
|
||||
kernelReady = true;
|
||||
kernelStarting = false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const stopSession = async () => {
|
||||
if (sessionId && baseUrl && apiKey) {
|
||||
await stopNotebookSession(baseUrl, apiKey, sessionId);
|
||||
}
|
||||
sessionId = null;
|
||||
kernelReady = false;
|
||||
};
|
||||
|
||||
const runCell = async (index: number) => {
|
||||
if (runningCell !== null) return;
|
||||
if (!kernelReady && !(await startSession())) return;
|
||||
|
||||
runningCell = index;
|
||||
const source = editedSources[index] ?? toStr(cells[index].source);
|
||||
|
||||
// Apply edits
|
||||
if (editedSources[index] !== undefined) {
|
||||
cells[index].source = editedSources[index];
|
||||
delete editingCell[index];
|
||||
delete editedSources[index];
|
||||
editingCell = editingCell;
|
||||
}
|
||||
|
||||
const result = await executeNotebookCell(baseUrl, apiKey, sessionId!, index, source);
|
||||
|
||||
if ('error' in result) {
|
||||
cells[index].outputs = [
|
||||
{
|
||||
output_type: 'error',
|
||||
ename: 'ExecutionError',
|
||||
evalue: result.error,
|
||||
traceback: [result.error]
|
||||
}
|
||||
];
|
||||
} else {
|
||||
cells[index].outputs = result.outputs;
|
||||
if (result.execution_count !== undefined) {
|
||||
cells[index].execution_count = result.execution_count;
|
||||
}
|
||||
}
|
||||
|
||||
cells = cells;
|
||||
runningCell = null;
|
||||
highlightCell(index, toStr(cells[index].source), lang);
|
||||
};
|
||||
|
||||
const runAll = async () => {
|
||||
if (runAllActive) return;
|
||||
runAllActive = true;
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i].cell_type === 'code') {
|
||||
await runCell(i);
|
||||
}
|
||||
}
|
||||
runAllActive = false;
|
||||
};
|
||||
|
||||
const autoResize = (e: Event) => {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = ta.scrollHeight + 'px';
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (sessionId) stopSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="notebook-view">
|
||||
<!-- Toolbar -->
|
||||
{#if baseUrl && apiKey && filePath}
|
||||
<div class="nb-toolbar flex items-center gap-1 px-2 py-0.5">
|
||||
<button
|
||||
class="nb-btn text-[0.6rem]"
|
||||
on:click={runAll}
|
||||
disabled={runAllActive || runningCell !== null}
|
||||
>
|
||||
{$i18n.t('Run All')}
|
||||
</button>
|
||||
{#if kernelReady}
|
||||
<button
|
||||
class="nb-btn text-[0.6rem]"
|
||||
on:click={async () => {
|
||||
await stopSession();
|
||||
await startSession();
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Restart')}
|
||||
</button>
|
||||
<button class="nb-btn text-[0.6rem]" on:click={stopSession}>
|
||||
{$i18n.t('Stop')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="flex items-center select-none">
|
||||
{#if kernelStarting}
|
||||
<Tooltip content={$i18n.t('Starting kernel...')} placement="bottom"
|
||||
><Spinner className="size-2" /></Tooltip
|
||||
>
|
||||
{:else if runningCell !== null}
|
||||
<Tooltip content={$i18n.t('Running')} placement="bottom"
|
||||
><Spinner className="size-2" /></Tooltip
|
||||
>
|
||||
{:else if kernelReady}
|
||||
<Tooltip content="Jupyter" placement="bottom"
|
||||
><span class="size-1.5 rounded-full bg-green-500 inline-block"></span></Tooltip
|
||||
>
|
||||
{:else}
|
||||
<Tooltip content={$i18n.t('No kernel')} placement="bottom"
|
||||
><span class="size-1.5 rounded-full bg-gray-400 dark:bg-gray-600 inline-block"
|
||||
></span></Tooltip
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if kernelError}
|
||||
<div
|
||||
class="px-2 py-1 text-[0.65rem] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 whitespace-pre-wrap font-mono"
|
||||
>
|
||||
{kernelError}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Cells -->
|
||||
{#each cells as cell, i}
|
||||
<div class="nb-cell nb-cell-{cell.cell_type}">
|
||||
{#if cell.cell_type === 'markdown'}
|
||||
{#if editingCell[i]}
|
||||
<textarea
|
||||
class="nb-edit-textarea text-sm"
|
||||
bind:value={editedSources[i]}
|
||||
on:input={autoResize}
|
||||
on:blur={() => cancelEditing(i)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Escape') cancelEditing(i);
|
||||
}}
|
||||
></textarea>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="nb-markdown prose dark:prose-invert max-w-full text-sm cursor-text"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
on:dblclick={() => startEditing(i)}
|
||||
>
|
||||
{@html renderMarkdown(toStr(cell.source))}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if cell.cell_type === 'code'}
|
||||
<div class="nb-code-wrap">
|
||||
<div class="nb-cell-gutter">
|
||||
{#if runningCell === i}
|
||||
<div class="nb-cell-label"><Spinner className="size-3" /></div>
|
||||
{:else if baseUrl && apiKey && filePath}
|
||||
<button
|
||||
class="nb-run-btn"
|
||||
on:click={() => runCell(i)}
|
||||
disabled={runningCell !== null}
|
||||
title="Run cell (⌘+Enter)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-3"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="nb-cell-label">
|
||||
{#if cell.execution_count !== undefined && cell.execution_count !== null}
|
||||
[{cell.execution_count}]
|
||||
{:else}
|
||||
[ ]
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="nb-code-content">
|
||||
{#if editingCell[i]}
|
||||
<CellEditor
|
||||
value={editedSources[i]}
|
||||
{lang}
|
||||
on:change={(e) => {
|
||||
editedSources[i] = e.detail;
|
||||
}}
|
||||
on:run={() => runCell(i)}
|
||||
on:cancel={() => cancelEditing(i)}
|
||||
/>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="nb-code-source-clickable"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
on:dblclick={() => startEditing(i)}
|
||||
>
|
||||
{#if highlightedCells[i]}
|
||||
<div class="nb-code-source shiki-preview">
|
||||
{@html highlightedCells[i]}
|
||||
</div>
|
||||
{:else}
|
||||
<pre class="nb-code-source-raw">{toStr(cell.source)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if cell.outputs && cell.outputs.length > 0}
|
||||
<div class="nb-outputs">
|
||||
{#each cell.outputs as output}
|
||||
{#if output.output_type === 'error'}
|
||||
<pre class="nb-error">{stripAnsi(
|
||||
(output.traceback ?? []).join('\n') || `${output.ename}: ${output.evalue}`
|
||||
)}</pre>
|
||||
{:else}
|
||||
{@const html = getOutputHtml(output)}
|
||||
{@const images = getOutputImages(output)}
|
||||
{@const text = getOutputText(output)}
|
||||
{#if html}
|
||||
<div class="nb-output-html">{@html html}</div>
|
||||
{/if}
|
||||
{#each images as src}
|
||||
<img {src} alt="Output" class="nb-output-img" />
|
||||
{/each}
|
||||
{#if text}
|
||||
<pre class="nb-output-text">{text}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if cell.cell_type === 'raw'}
|
||||
<pre class="nb-raw">{toStr(cell.source)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.notebook-view {
|
||||
padding: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.nb-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
:global(.dark) .nb-toolbar {
|
||||
background: rgba(17, 24, 39, 0.95);
|
||||
}
|
||||
.nb-btn {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nb-btn:hover:not(:disabled) {
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .nb-btn:hover:not(:disabled) {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.nb-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.nb-cell {
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
.nb-cell-markdown {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.nb-markdown :global(h1) {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
.nb-markdown :global(h2) {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.nb-markdown :global(h3) {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.nb-markdown :global(p) {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.nb-markdown :global(pre) {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.nb-code-wrap {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
.nb-cell-gutter {
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 0.35rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.nb-cell-label {
|
||||
color: #9ca3af;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.6rem;
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
}
|
||||
:global(.dark) .nb-cell-label {
|
||||
color: #4b5563;
|
||||
}
|
||||
.nb-run-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.25rem;
|
||||
color: #9ca3af;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nb-run-btn:hover:not(:disabled) {
|
||||
color: #059669;
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
:global(.dark) .nb-run-btn {
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .nb-run-btn:hover:not(:disabled) {
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
}
|
||||
.nb-run-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.nb-code-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.dark) .nb-code-content {
|
||||
border-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
.nb-code-source-clickable {
|
||||
cursor: text;
|
||||
}
|
||||
.nb-code-source :global(pre.shiki) {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0;
|
||||
}
|
||||
.nb-code-source :global(pre.shiki code > .line::before) {
|
||||
display: none;
|
||||
}
|
||||
.nb-code-source-raw {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
background: #f8f8f8;
|
||||
color: #1f2937;
|
||||
}
|
||||
:global(.dark) .nb-code-source-raw {
|
||||
background: #161b22;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.nb-code-textarea,
|
||||
.nb-edit-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
background: #fffef5;
|
||||
color: #1f2937;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 2.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.dark) .nb-code-textarea,
|
||||
:global(.dark) .nb-edit-textarea {
|
||||
background: #1a1d23;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.nb-edit-textarea {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 0.8rem;
|
||||
background: transparent;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px dashed rgba(128, 128, 128, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nb-outputs {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
:global(.dark) .nb-outputs {
|
||||
border-top-color: rgba(128, 128, 128, 0.25);
|
||||
}
|
||||
.nb-output-text {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
color: #374151;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:global(.dark) .nb-output-text {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.nb-output-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.nb-output-html {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nb-output-html :global(table) {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.nb-output-html :global(td),
|
||||
.nb-output-html :global(th) {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
padding: 3px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.nb-output-html :global(th) {
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nb-error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
color: #dc2626;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
:global(.dark) .nb-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #f87171;
|
||||
}
|
||||
.nb-raw {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
color: #6b7280;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
:global(.dark) .nb-code-source :global(.shiki),
|
||||
:global(.dark) .nb-code-source :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,139 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, getContext } from 'svelte';
|
||||
import type { ListeningPort } from '$lib/apis/terminal';
|
||||
import { getListeningPorts, getPortProxyUrl } from '$lib/apis/terminal';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let baseUrl: string;
|
||||
export let apiKey: string;
|
||||
|
||||
let ports: ListeningPort[] = [];
|
||||
let expanded = false;
|
||||
let loading = false;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const loadPorts = async () => {
|
||||
loading = true;
|
||||
ports = await getListeningPorts(baseUrl, apiKey);
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
loadPorts();
|
||||
pollTimer = setInterval(loadPorts, 5000);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openPort = (port: number) => {
|
||||
const url = getPortProxyUrl(baseUrl, port);
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// Start polling when baseUrl is available
|
||||
$: if (baseUrl) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
stopPolling();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="px-2 py-1">
|
||||
<button
|
||||
class="flex items-center gap-1 w-full text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
||||
on:click={() => (expanded = !expanded)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3 transition-transform {expanded ? '' : '-rotate-90'}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$i18n.t('Ports')}
|
||||
<span class="ml-auto flex items-center gap-1">
|
||||
{#if ports.length > 0}
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{ports.length}
|
||||
</span>
|
||||
{/if}
|
||||
<Tooltip content={$i18n.t('Refresh')}>
|
||||
<button
|
||||
class="p-0.5 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|stopPropagation={loadPorts}
|
||||
aria-label={$i18n.t('Refresh')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3 {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>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="mt-1 space-y-0.5 max-h-[150px] overflow-y-auto">
|
||||
{#if ports.length === 0}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 px-1 py-1">
|
||||
{$i18n.t('No servers detected')}
|
||||
</div>
|
||||
{:else}
|
||||
{#each ports as port}
|
||||
<button
|
||||
class="flex items-center w-full gap-2 px-1.5 py-1 text-xs rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition group"
|
||||
on:click={() => openPort(port.port)}
|
||||
>
|
||||
<span class="font-mono text-blue-500 dark:text-blue-400 shrink-0">
|
||||
:{port.port}
|
||||
</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 truncate flex-1 text-left">
|
||||
{port.process ?? ''}
|
||||
</span>
|
||||
<span
|
||||
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition shrink-0"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,373 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy } from 'svelte';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let data: ArrayBuffer;
|
||||
|
||||
let db: any = null;
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
|
||||
let tables: string[] = [];
|
||||
let selectedTable = '';
|
||||
let columns: string[] = [];
|
||||
let rows: string[][] = [];
|
||||
let totalRows = 0;
|
||||
let page = 0;
|
||||
const pageSize = 100;
|
||||
|
||||
// Custom query
|
||||
let queryMode = false;
|
||||
let queryText = '';
|
||||
let queryColumns: string[] = [];
|
||||
let queryRows: string[][] = [];
|
||||
let queryError: string | null = null;
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const initSqlJs = (await import('sql.js')).default;
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: () => '/sql.js/sql-wasm.wasm'
|
||||
});
|
||||
db = new SQL.Database(new Uint8Array(data));
|
||||
|
||||
// Get table list
|
||||
const result = db.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
);
|
||||
if (result.length > 0) {
|
||||
tables = result[0].values.map((r: string[]) => r[0]);
|
||||
if (tables.length > 0) {
|
||||
selectTable(tables[0]);
|
||||
}
|
||||
}
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
loading = false;
|
||||
error = e instanceof Error ? e.message : 'Failed to open database';
|
||||
}
|
||||
};
|
||||
|
||||
const selectTable = (table: string) => {
|
||||
if (!db) return;
|
||||
selectedTable = table;
|
||||
page = 0;
|
||||
queryMode = false;
|
||||
loadPage();
|
||||
};
|
||||
|
||||
const loadPage = () => {
|
||||
if (!db || !selectedTable) return;
|
||||
try {
|
||||
// Get columns
|
||||
const info = db.exec(`PRAGMA table_info("${selectedTable}")`);
|
||||
if (info.length > 0) {
|
||||
columns = info[0].values.map((r: any[]) => r[1] as string);
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const countResult = db.exec(`SELECT COUNT(*) FROM "${selectedTable}"`);
|
||||
if (countResult.length > 0) {
|
||||
totalRows = countResult[0].values[0][0] as number;
|
||||
}
|
||||
|
||||
// Get page data
|
||||
const offset = page * pageSize;
|
||||
const result = db.exec(`SELECT * FROM "${selectedTable}" LIMIT ${pageSize} OFFSET ${offset}`);
|
||||
if (result.length > 0) {
|
||||
rows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
|
||||
} else {
|
||||
rows = [];
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Query failed';
|
||||
}
|
||||
};
|
||||
|
||||
const runQuery = () => {
|
||||
if (!db || !queryText.trim()) return;
|
||||
queryError = null;
|
||||
try {
|
||||
const result = db.exec(queryText);
|
||||
if (result.length > 0) {
|
||||
queryColumns = result[0].columns;
|
||||
queryRows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
|
||||
} else {
|
||||
queryColumns = [];
|
||||
queryRows = [];
|
||||
}
|
||||
} catch (e) {
|
||||
queryError = e instanceof Error ? e.message : 'Query failed';
|
||||
queryColumns = [];
|
||||
queryRows = [];
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (v: any): string => {
|
||||
if (v === null) return 'NULL';
|
||||
if (v instanceof Uint8Array) return `[BLOB ${v.length}B]`;
|
||||
return String(v);
|
||||
};
|
||||
|
||||
$: totalPages = Math.ceil(totalRows / pageSize);
|
||||
|
||||
$: data && init();
|
||||
|
||||
onDestroy(() => {
|
||||
if (db) {
|
||||
try {
|
||||
db.close();
|
||||
} catch {}
|
||||
db = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="sqlite-view flex flex-col h-full">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
|
||||
{:else if error}
|
||||
<div class="p-3 text-xs text-red-500">{error}</div>
|
||||
{:else}
|
||||
<!-- Table tabs + query toggle -->
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 py-1.5 border-b border-gray-100 dark:border-gray-800 overflow-x-auto scrollbar-none shrink-0"
|
||||
>
|
||||
{#each tables as table}
|
||||
<button
|
||||
class="shrink-0 px-2 py-1 text-xs rounded-lg transition
|
||||
{table === selectedTable && !queryMode
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
on:click={() => selectTable(table)}
|
||||
>
|
||||
{table}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
class="shrink-0 px-2 py-1 text-xs rounded-lg transition font-mono
|
||||
{queryMode
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
|
||||
on:click={() => {
|
||||
queryMode = !queryMode;
|
||||
}}
|
||||
>
|
||||
SQL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if queryMode}
|
||||
<!-- Query editor -->
|
||||
<div class="shrink-0">
|
||||
<textarea
|
||||
bind:value={queryText}
|
||||
placeholder="SELECT * FROM ..."
|
||||
class="query-editor w-full text-xs font-mono bg-transparent text-gray-800 dark:text-gray-200 px-3 py-2 outline-none resize-none"
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
on:keydown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runQuery();
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<div
|
||||
class="flex items-center justify-between px-2 py-1 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{#if queryError}
|
||||
<span class="text-[0.65rem] text-red-500 dark:text-red-400 truncate mr-2"
|
||||
>{queryError}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="text-[0.6rem] text-gray-400 dark:text-gray-600 select-none">⌘+Enter</span>
|
||||
{/if}
|
||||
<button
|
||||
class="shrink-0 px-2.5 py-0.5 text-[0.65rem] font-medium rounded hover:bg-gray-200 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
on:click={runQuery}
|
||||
>
|
||||
{$i18n.t('Run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if queryColumns.length > 0}
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<table class="sqlite-table w-full text-xs font-mono border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sqlite-row-num">#</th>
|
||||
{#each queryColumns as col}
|
||||
<th>{col}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each queryRows as row, i}
|
||||
<tr>
|
||||
<td class="sqlite-row-num">{i + 1}</td>
|
||||
{#each row as cell}
|
||||
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Table data -->
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
{#if columns.length > 0}
|
||||
<table class="sqlite-table w-full text-xs font-mono border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sqlite-row-num">#</th>
|
||||
{#each columns as col}
|
||||
<th>{col}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row, i}
|
||||
<tr>
|
||||
<td class="sqlite-row-num">{page * pageSize + i + 1}</td>
|
||||
{#each row as cell}
|
||||
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<div class="text-xs text-gray-400 text-center pt-6">{$i18n.t('No data')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div
|
||||
class="flex items-center justify-center gap-3 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={page === 0}
|
||||
on:click={() => {
|
||||
page--;
|
||||
loadPage();
|
||||
}}
|
||||
>
|
||||
<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>{page + 1} / {totalPages} ({totalRows.toLocaleString()} rows)</span>
|
||||
<button
|
||||
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={page >= totalPages - 1}
|
||||
on:click={() => {
|
||||
page++;
|
||||
loadPage();
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sqlite-table {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.sqlite-table th,
|
||||
.sqlite-table td {
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
.sqlite-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) .sqlite-table thead th {
|
||||
background: rgba(31, 41, 55, 0.95);
|
||||
color: #d1d5db;
|
||||
}
|
||||
.sqlite-table tbody tr:nth-child(even) {
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
}
|
||||
.sqlite-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
:global(.dark) .sqlite-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.sqlite-table td {
|
||||
color: #374151;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:global(.dark) .sqlite-table td {
|
||||
color: #d1d5db;
|
||||
}
|
||||
.sqlite-row-num {
|
||||
color: #9ca3af;
|
||||
font-size: 0.6rem;
|
||||
text-align: right !important;
|
||||
user-select: none;
|
||||
width: 1px;
|
||||
padding-right: 6px !important;
|
||||
}
|
||||
:global(.dark) .sqlite-row-num {
|
||||
color: #6b7280;
|
||||
}
|
||||
.sqlite-null {
|
||||
color: #9ca3af !important;
|
||||
font-style: italic;
|
||||
}
|
||||
:global(.dark) .sqlite-null {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.query-editor::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .query-editor::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -508,11 +508,17 @@
|
||||
|
||||
let showCodeInterpreterButton = false;
|
||||
$: showCodeInterpreterButton =
|
||||
!$selectedTerminalId &&
|
||||
(atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).length ===
|
||||
codeInterpreterCapableModels.length &&
|
||||
$config?.features?.enable_code_interpreter &&
|
||||
($_user.role === 'admin' || $_user?.permissions?.features?.code_interpreter);
|
||||
|
||||
// Disable code interpreter when terminal is active (mutually exclusive)
|
||||
$: if ($selectedTerminalId && codeInterpreterEnabled) {
|
||||
codeInterpreterEnabled = false;
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = document.getElementById('messages-container');
|
||||
element.scrollTo({
|
||||
@@ -1175,7 +1181,7 @@
|
||||
<!-- Queued messages display -->
|
||||
{#if messageQueue.length > 0}
|
||||
<div
|
||||
class="mb-1 mx-2 py-0.5 px-1.5 rounded-2xl bg-white dark:bg-gray-900/60 border border-gray-100 dark:border-gray-800/50 overflow-hidden"
|
||||
class="mb-1 mx-2 py-0.5 px-1.5 rounded-2xl bg-white dark:bg-gray-900/60 border border-gray-100 dark:border-gray-800/50 overflow-x-hidden overflow-y-auto max-h-[25vh]"
|
||||
>
|
||||
{#each messageQueue as queuedMessage (queuedMessage.id)}
|
||||
<QueuedMessageItem
|
||||
@@ -1636,12 +1642,10 @@
|
||||
{/if}
|
||||
|
||||
<div class="ml-1 flex gap-1.5">
|
||||
{#if (selectedToolIds ?? []).filter((id) => !id.startsWith('direct_server:terminal_')).length > 0}
|
||||
{#if (selectedToolIds ?? []).length > 0}
|
||||
<Tooltip
|
||||
content={$i18n.t('{{COUNT}} Available Tools', {
|
||||
COUNT: (selectedToolIds ?? []).filter(
|
||||
(id) => !id.startsWith('direct_server:terminal_')
|
||||
).length
|
||||
COUNT: (selectedToolIds ?? []).length
|
||||
})}
|
||||
>
|
||||
<button
|
||||
@@ -1655,9 +1659,7 @@
|
||||
<Wrench className="size-4" strokeWidth="1.75" />
|
||||
|
||||
<span class="text-sm">
|
||||
{(selectedToolIds ?? []).filter(
|
||||
(id) => !id.startsWith('direct_server:terminal_')
|
||||
).length}
|
||||
{(selectedToolIds ?? []).length}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -1807,59 +1809,61 @@
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
|
||||
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
|
||||
<!-- Terminal Server Selector -->
|
||||
{#if ($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url)}
|
||||
<TerminalMenu bind:show={showTerminalMenu} />
|
||||
{/if}
|
||||
|
||||
<!-- {$i18n.t('Record voice')} -->
|
||||
<Tooltip content={$i18n.t('Dictate')}>
|
||||
<button
|
||||
id="voice-input-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
try {
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
`Permission denied when accessing microphone: {{error}}`,
|
||||
{
|
||||
error: err
|
||||
}
|
||||
)
|
||||
);
|
||||
return null;
|
||||
});
|
||||
{#if $_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true)}
|
||||
<!-- {$i18n.t('Record voice')} -->
|
||||
<Tooltip content={$i18n.t('Dictate')}>
|
||||
<button
|
||||
id="voice-input-button"
|
||||
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
try {
|
||||
let stream = await navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.catch(function (err) {
|
||||
toast.error(
|
||||
$i18n.t(
|
||||
`Permission denied when accessing microphone: {{error}}`,
|
||||
{
|
||||
error: err
|
||||
}
|
||||
)
|
||||
);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
recording = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
if (stream) {
|
||||
recording = true;
|
||||
const tracks = stream.getTracks();
|
||||
tracks.forEach((track) => track.stop());
|
||||
}
|
||||
stream = null;
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
stream = null;
|
||||
} catch {
|
||||
toast.error($i18n.t('Permission denied when accessing microphone'));
|
||||
}
|
||||
}}
|
||||
aria-label="Voice Input"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-5 translate-y-[0.5px]"
|
||||
}}
|
||||
aria-label="Voice Input"
|
||||
>
|
||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-5 translate-y-[0.5px]"
|
||||
>
|
||||
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
|
||||
<path
|
||||
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
let variableValues = {};
|
||||
|
||||
const submitHandler = async () => {
|
||||
// Normalize Windows CRLF (\r\n) to LF (\n) for all string values
|
||||
for (const key of Object.keys(variableValues)) {
|
||||
if (typeof variableValues[key] === 'string') {
|
||||
variableValues[key] = variableValues[key].replace(/\r\n/g, '\n');
|
||||
}
|
||||
}
|
||||
onSave(variableValues);
|
||||
show = false;
|
||||
};
|
||||
|
||||
@@ -96,9 +96,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
selectedToolIds = selectedToolIds.filter(
|
||||
(id) => Object.keys(tools).includes(id) || id.startsWith('direct_server:terminal_')
|
||||
);
|
||||
selectedToolIds = selectedToolIds.filter((id) => Object.keys(tools).includes(id));
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
|
||||
messagesLoading = true;
|
||||
messagesCount += 20;
|
||||
buildMessages();
|
||||
|
||||
await tick();
|
||||
|
||||
@@ -89,25 +90,30 @@
|
||||
}
|
||||
visitedMessageIds.add(message.id);
|
||||
|
||||
_messages.unshift(message);
|
||||
_messages.push(message);
|
||||
message = message.parentId !== null ? history.messages[message.parentId] : null;
|
||||
}
|
||||
|
||||
messages = _messages;
|
||||
messages = _messages.reverse();
|
||||
};
|
||||
|
||||
// Throttle message list rebuilds to once per animation frame during streaming.
|
||||
// Structural changes (currentId change) always rebuild immediately.
|
||||
$: if (history.currentId) {
|
||||
const currentIdChanged = history.currentId !== lastCurrentId;
|
||||
lastCurrentId = history.currentId;
|
||||
const handleHistoryChange = (currentId, _messages) => {
|
||||
if (!currentId) {
|
||||
messages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIdChanged = currentId !== lastCurrentId;
|
||||
lastCurrentId = currentId;
|
||||
|
||||
if (currentIdChanged) {
|
||||
// Structural change: new chat, navigation, new message — rebuild immediately
|
||||
cancelAnimationFrame(pendingRebuild);
|
||||
pendingRebuild = null;
|
||||
buildMessages();
|
||||
} else if (history.messages) {
|
||||
} else if (_messages) {
|
||||
// Content update (streaming) — throttle to once per frame
|
||||
if (!pendingRebuild) {
|
||||
pendingRebuild = requestAnimationFrame(() => {
|
||||
@@ -116,9 +122,9 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages = [];
|
||||
}
|
||||
};
|
||||
|
||||
$: handleHistoryChange(history.currentId, history.messages);
|
||||
|
||||
$: if (autoScroll && bottomPadding) {
|
||||
(async () => {
|
||||
|
||||
@@ -42,6 +42,31 @@
|
||||
let contentContainerElement;
|
||||
let floatingButtonsElement;
|
||||
|
||||
let sourceIds = [];
|
||||
$: getSourceIds(sources);
|
||||
|
||||
const getSourceIds = (sources) => {
|
||||
const result = [];
|
||||
for (const source of sources ?? []) {
|
||||
for (let index = 0; index < (source.document ?? []).length; index++) {
|
||||
if (model?.info?.meta?.capabilities?.citations == false) {
|
||||
result.push('N/A');
|
||||
continue;
|
||||
}
|
||||
const metadata = source.metadata?.[index];
|
||||
const id = metadata?.source ?? 'N/A';
|
||||
if (metadata?.name) {
|
||||
result.push(metadata.name);
|
||||
} else if (id.startsWith('http://') || id.startsWith('https://')) {
|
||||
result.push(id);
|
||||
} else {
|
||||
result.push(source?.source?.name ?? id);
|
||||
}
|
||||
}
|
||||
}
|
||||
sourceIds = [...new Set(result)];
|
||||
};
|
||||
|
||||
const updateButtonPosition = (event) => {
|
||||
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
|
||||
if (
|
||||
@@ -142,36 +167,7 @@
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
sourceIds={(sources ?? []).reduce((acc, source) => {
|
||||
let ids = [];
|
||||
source.document.forEach((document, index) => {
|
||||
if (model?.info?.meta?.capabilities?.citations == false) {
|
||||
ids.push('N/A');
|
||||
return ids;
|
||||
}
|
||||
|
||||
const metadata = source.metadata?.[index];
|
||||
const id = metadata?.source ?? 'N/A';
|
||||
|
||||
if (metadata?.name) {
|
||||
ids.push(metadata.name);
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (id.startsWith('http://') || id.startsWith('https://')) {
|
||||
ids.push(id);
|
||||
} else {
|
||||
ids.push(source?.source?.name ?? id);
|
||||
}
|
||||
|
||||
return ids;
|
||||
});
|
||||
|
||||
acc = [...acc, ...ids];
|
||||
|
||||
// remove duplicates
|
||||
return acc.filter((item, index) => acc.indexOf(item) === index);
|
||||
}, [])}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
@@ -199,7 +195,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if floatingButtons && model}
|
||||
{#if floatingButtons}
|
||||
<FloatingButtons
|
||||
bind:this={floatingButtonsElement}
|
||||
{id}
|
||||
@@ -209,7 +205,7 @@
|
||||
? model?.id
|
||||
: (selectedModels ?? []).length > 0
|
||||
? selectedModels.at(0)
|
||||
: model?.id}
|
||||
: (model?.id ?? null)}
|
||||
messages={createMessagesList(history, messageId)}
|
||||
onAdd={({ modelId, parentId, messages }) => {
|
||||
console.log(modelId, parentId, messages);
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
|
||||
let tokens = [];
|
||||
let pendingUpdate = null;
|
||||
let lastContent = '';
|
||||
let lastParsedContent = '';
|
||||
|
||||
const options = {
|
||||
throwOnError: false,
|
||||
@@ -56,24 +58,35 @@
|
||||
});
|
||||
|
||||
const parseTokens = () => {
|
||||
tokens = marked.lexer(replaceTokens(processResponseContent(content), model?.name, $user?.name));
|
||||
if (content === lastContent) return;
|
||||
lastContent = content;
|
||||
|
||||
const processed = replaceTokens(processResponseContent(content), model?.name, $user?.name);
|
||||
if (processed === lastParsedContent) return;
|
||||
lastParsedContent = processed;
|
||||
|
||||
tokens = marked.lexer(processed);
|
||||
};
|
||||
|
||||
// Throttle parsing to once per animation frame while streaming
|
||||
$: if (content) {
|
||||
if (done) {
|
||||
cancelAnimationFrame(pendingUpdate);
|
||||
pendingUpdate = null;
|
||||
parseTokens();
|
||||
} else if (!pendingUpdate) {
|
||||
pendingUpdate = requestAnimationFrame(() => {
|
||||
const updateHandler = (content) => {
|
||||
if (content) {
|
||||
if (done) {
|
||||
cancelAnimationFrame(pendingUpdate);
|
||||
pendingUpdate = null;
|
||||
parseTokens();
|
||||
});
|
||||
} else if (!pendingUpdate) {
|
||||
pendingUpdate = requestAnimationFrame(() => {
|
||||
pendingUpdate = null;
|
||||
parseTokens();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
$: updateHandler(content);
|
||||
|
||||
// Throttle parsing to once per animation frame while streaming
|
||||
$: onDestroy(() => {
|
||||
cancelAnimationFrame(pendingUpdate);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="flex w-full">
|
||||
<ProfileImage
|
||||
src={`${WEBUI_API_BASE_URL}/users/${data.user.id}/profile/image`}
|
||||
className={'size-5 -translate-y-[1px]'}
|
||||
className={'size-5 -translate-y-[1px] flex-shrink-0'}
|
||||
/>
|
||||
<div class="ml-2">
|
||||
<div class=" flex justify-between items-center">
|
||||
@@ -45,7 +45,7 @@
|
||||
<div class="flex w-full">
|
||||
<ProfileImage
|
||||
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${data.model?.id ?? data.message.model}&lang=${$i18n.language}`}
|
||||
className={'size-5 -translate-y-[1px]'}
|
||||
className={'size-5 -translate-y-[1px] flex-shrink-0'}
|
||||
/>
|
||||
|
||||
<div class="ml-2">
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
<script context="module">
|
||||
let savedPyodidePath = '/mnt/uploads';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, tick } from 'svelte';
|
||||
import { pyodideWorker } from '$lib/stores';
|
||||
import PyodideWorkerConstructor from '$lib/workers/pyodide.worker?worker';
|
||||
import type { FileEntry } from '$lib/apis/terminal';
|
||||
|
||||
import FileNavToolbar from './FileNav/FileNavToolbar.svelte';
|
||||
import FileEntryRow from './FileNav/FileEntryRow.svelte';
|
||||
import FilePreview from './FileNav/FilePreview.svelte';
|
||||
import ConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import Folder from '../icons/Folder.svelte';
|
||||
import Document from '../icons/Document.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let overlay = false;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────
|
||||
let currentPath = savedPyodidePath;
|
||||
let entries: FileEntry[] = [];
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
|
||||
let selectedFile: string | null = null;
|
||||
let fileLoading = false;
|
||||
let fileContent: string | null = null;
|
||||
let fileImageUrl: string | null = null;
|
||||
|
||||
let isDragOver = false;
|
||||
let showDeleteConfirm = false;
|
||||
let deletePath = '';
|
||||
let deleteName = '';
|
||||
|
||||
let creatingFolder = false;
|
||||
let newFolderName = '';
|
||||
let newFolderInput: HTMLInputElement;
|
||||
|
||||
let creatingFile = false;
|
||||
let newFileName = '';
|
||||
let newFileInput: HTMLInputElement;
|
||||
|
||||
let _reqId = 0;
|
||||
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
|
||||
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
|
||||
|
||||
// ── Worker management ─────────────────────────────────────────────────
|
||||
|
||||
function ensureWorker(): Worker {
|
||||
let worker = $pyodideWorker;
|
||||
if (!worker) {
|
||||
worker = new PyodideWorkerConstructor();
|
||||
pyodideWorker.set(worker);
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function sendWorkerMessage(msg: any): Promise<any> {
|
||||
const worker = ensureWorker();
|
||||
const id = `fs-${++_reqId}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
worker.removeEventListener('message', handler);
|
||||
reject('Timeout');
|
||||
}, 30000);
|
||||
|
||||
function handler(event: MessageEvent) {
|
||||
if (event.data?.id !== id) return;
|
||||
clearTimeout(timeout);
|
||||
worker.removeEventListener('message', handler);
|
||||
resolve(event.data);
|
||||
}
|
||||
|
||||
worker.addEventListener('message', handler);
|
||||
worker.postMessage({ ...msg, id });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Breadcrumbs ───────────────────────────────────────────────────────
|
||||
|
||||
const buildBreadcrumbs = (path: string) => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
return parts.reduce(
|
||||
(acc, part) => {
|
||||
const prev = acc[acc.length - 1];
|
||||
acc.push({ label: part, path: `${prev.path}${part}/` });
|
||||
return acc;
|
||||
},
|
||||
[{ label: '/', path: '/' }]
|
||||
);
|
||||
};
|
||||
|
||||
$: breadcrumbs = buildBreadcrumbs(currentPath);
|
||||
|
||||
// ── Operations ────────────────────────────────────────────────────────
|
||||
|
||||
const loadDir = async (path: string) => {
|
||||
loading = true;
|
||||
error = null;
|
||||
selectedFile = null;
|
||||
clearPreview();
|
||||
currentPath = path.endsWith('/') ? path : path + '/';
|
||||
savedPyodidePath = currentPath;
|
||||
|
||||
try {
|
||||
const res = await sendWorkerMessage({
|
||||
type: 'fs:list',
|
||||
path: currentPath.replace(/\/$/, '') || '/'
|
||||
});
|
||||
entries = (res.entries || []).sort((a: FileEntry, b: FileEntry) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch {
|
||||
error = 'Failed to list directory';
|
||||
entries = [];
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const openEntry = async (entry: FileEntry) => {
|
||||
if (entry.type === 'directory') {
|
||||
await loadDir(`${currentPath}${entry.name}/`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = `${currentPath}${entry.name}`;
|
||||
selectedFile = filePath;
|
||||
fileLoading = true;
|
||||
clearPreview();
|
||||
|
||||
try {
|
||||
const res = await sendWorkerMessage({ type: 'fs:read', path: filePath });
|
||||
if (res.error) {
|
||||
fileContent = `Error: ${res.error}`;
|
||||
} else if (isImage(filePath)) {
|
||||
const blob = new Blob([res.data]);
|
||||
fileImageUrl = URL.createObjectURL(blob);
|
||||
} else {
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
try {
|
||||
fileContent = decoder.decode(res.data);
|
||||
} catch {
|
||||
fileContent = `[Binary file: ${entry.size ?? 0} bytes]`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
fileContent = 'Failed to read file';
|
||||
}
|
||||
fileLoading = false;
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
fileContent = null;
|
||||
if (fileImageUrl) {
|
||||
URL.revokeObjectURL(fileImageUrl);
|
||||
fileImageUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (path: string) => {
|
||||
try {
|
||||
const res = await sendWorkerMessage({ type: 'fs:read', path });
|
||||
if (res.data) {
|
||||
const blob = new Blob([res.data]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = path.split('/').pop() ?? 'file';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (path: string, name: string) => {
|
||||
deletePath = path;
|
||||
deleteName = name;
|
||||
showDeleteConfirm = true;
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
try {
|
||||
await sendWorkerMessage({ type: 'fs:delete', path: deletePath });
|
||||
if (selectedFile === deletePath) {
|
||||
selectedFile = null;
|
||||
clearPreview();
|
||||
}
|
||||
await loadDir(currentPath);
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const startNewFolder = async () => {
|
||||
creatingFolder = true;
|
||||
newFolderName = '';
|
||||
await tick();
|
||||
newFolderInput?.focus();
|
||||
};
|
||||
|
||||
const submitNewFolder = async () => {
|
||||
const name = newFolderName.trim();
|
||||
creatingFolder = false;
|
||||
newFolderName = '';
|
||||
if (!name) return;
|
||||
const folderPath = `${currentPath}${name}`.replace(/\/$/, '');
|
||||
try {
|
||||
await sendWorkerMessage({ type: 'fs:mkdir', path: folderPath });
|
||||
await loadDir(currentPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to create folder:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const startNewFile = async () => {
|
||||
creatingFile = true;
|
||||
newFileName = '';
|
||||
await tick();
|
||||
newFileInput?.focus();
|
||||
};
|
||||
|
||||
const submitNewFile = async () => {
|
||||
const name = newFileName.trim();
|
||||
creatingFile = false;
|
||||
newFileName = '';
|
||||
if (!name) return;
|
||||
try {
|
||||
await sendWorkerMessage({
|
||||
type: 'fs:upload',
|
||||
files: [{ name, data: new ArrayBuffer(0) }],
|
||||
dir: currentPath.replace(/\/$/, '') || '/'
|
||||
});
|
||||
await loadDir(currentPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to create file:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (fileList: File[]) => {
|
||||
const payloads: { name: string; data: ArrayBuffer }[] = [];
|
||||
for (const file of fileList) {
|
||||
payloads.push({ name: file.name, data: await file.arrayBuffer() });
|
||||
}
|
||||
try {
|
||||
await sendWorkerMessage({
|
||||
type: 'fs:upload',
|
||||
files: payloads,
|
||||
dir: currentPath.replace(/\/$/, '') || '/'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Upload failed:', e);
|
||||
}
|
||||
await loadDir(currentPath);
|
||||
};
|
||||
|
||||
// ── Drag and drop ─────────────────────────────────────────────────────
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
if (selectedFile) return;
|
||||
if (!e.dataTransfer?.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragOver = true;
|
||||
};
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragOver = false;
|
||||
if (selectedFile) return;
|
||||
const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (droppedFiles.length) await uploadFiles(droppedFiles);
|
||||
};
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
|
||||
onMount(() => {
|
||||
ensureWorker();
|
||||
loadDir(currentPath);
|
||||
});
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete {{name}}', { name: deleteName })}
|
||||
message={$i18n.t('Are you sure you want to delete this?')}
|
||||
on:confirm={doDelete}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col h-full min-h-0 min-w-0 relative"
|
||||
on:dragover={handleDragOver}
|
||||
on:dragleave={() => (isDragOver = false)}
|
||||
on:drop={handleDrop}
|
||||
role="region"
|
||||
aria-label={$i18n.t('Pyodide file browser')}
|
||||
>
|
||||
{#if isDragOver}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/80 dark:bg-gray-850/80 backdrop-blur-sm pointer-events-none gap-1.5"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
class="size-5 text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<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>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">{$i18n.t('Drop files here')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if overlay}
|
||||
<div class="absolute inset-0 z-10 pointer-events-none" />
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar (shared with FileNav) -->
|
||||
<FileNavToolbar
|
||||
{breadcrumbs}
|
||||
{selectedFile}
|
||||
{loading}
|
||||
onNavigate={(path) => loadDir(path)}
|
||||
onRefresh={() => {
|
||||
if (selectedFile) {
|
||||
const name = selectedFile.split('/').pop() ?? '';
|
||||
openEntry({ name, type: 'file', size: 0 });
|
||||
} else {
|
||||
loadDir(currentPath);
|
||||
}
|
||||
}}
|
||||
onNewFolder={startNewFolder}
|
||||
onNewFile={startNewFile}
|
||||
onUploadFiles={uploadFiles}
|
||||
onMove={() => {}}
|
||||
>
|
||||
<!-- File action buttons when a file is selected (slot content) -->
|
||||
<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={() => selectedFile && downloadFile(selectedFile)}
|
||||
aria-label={$i18n.t('Download')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<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-red-600 dark:hover:text-red-400"
|
||||
on:click={() => {
|
||||
if (selectedFile) {
|
||||
confirmDelete(selectedFile, selectedFile.split('/').pop() ?? '');
|
||||
}
|
||||
}}
|
||||
aria-label={$i18n.t('Delete')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022 1.005 11.36A2.75 2.75 0 0 0 7.764 20h4.472a2.75 2.75 0 0 0 2.745-2.689l1.005-11.36.149.022a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 1 .7.8l-.3 6a.75.75 0 0 1-1.5-.075l.3-6a.75.75 0 0 1 .8-.725ZM12.2 8.52a.75.75 0 0 0-1.5.075l-.3 6a.75.75 0 0 0 1.5-.075l.3-6Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</FileNavToolbar>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
{#if selectedFile}
|
||||
<FilePreview {selectedFile} {fileLoading} {fileImageUrl} {fileContent} {overlay} />
|
||||
{:else if loading}
|
||||
<div class="flex items-center justify-center flex-1 p-6">
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex items-center justify-center flex-1 p-6">
|
||||
<div class="text-xs text-red-500">{error}</div>
|
||||
</div>
|
||||
{:else if entries.length === 0 && !creatingFolder && !creatingFile}
|
||||
<div class="flex flex-col items-center justify-center flex-1 p-6 text-center gap-2">
|
||||
<Folder className="size-5 text-gray-300 dark:text-gray-600" />
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('No files yet. Upload files or run Python code to create them.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && !error && !selectedFile}
|
||||
{#if creatingFolder}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
|
||||
<input
|
||||
bind:this={newFolderInput}
|
||||
bind:value={newFolderName}
|
||||
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
|
||||
placeholder={$i18n.t('Folder name')}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') submitNewFolder();
|
||||
if (e.key === 'Escape') {
|
||||
creatingFolder = false;
|
||||
newFolderName = '';
|
||||
}
|
||||
}}
|
||||
on:blur={submitNewFolder}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if creatingFile}
|
||||
<div class="flex items-center gap-2 px-3 py-1.5">
|
||||
<Document className="size-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
bind:this={newFileInput}
|
||||
bind:value={newFileName}
|
||||
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500"
|
||||
placeholder={$i18n.t('File name')}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') submitNewFile();
|
||||
if (e.key === 'Escape') {
|
||||
creatingFile = false;
|
||||
newFileName = '';
|
||||
}
|
||||
}}
|
||||
on:blur={submitNewFile}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entries.length > 0 || creatingFolder || creatingFile}
|
||||
<ul class="overflow-y-auto flex-1 min-h-0">
|
||||
{#each entries as entry (entry.name)}
|
||||
<FileEntryRow
|
||||
{entry}
|
||||
{currentPath}
|
||||
onOpen={openEntry}
|
||||
onDownload={downloadFile}
|
||||
onDelete={confirmDelete}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
<div class="flex justify-end pt-1 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 {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"
|
||||
@@ -85,9 +85,9 @@
|
||||
{$i18n.t('Add')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
<div class="flex justify-end pt-1 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 {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"
|
||||
@@ -95,9 +95,9 @@
|
||||
{$i18n.t('Update')}
|
||||
|
||||
{#if loading}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, getContext } from 'svelte';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
import { terminalServers, settings, selectedTerminalId, user } from '$lib/stores';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let overlay = false;
|
||||
|
||||
let terminalEl: HTMLDivElement;
|
||||
let term: Terminal | null = null;
|
||||
let fitAddon: FitAddon | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
export let connected = false;
|
||||
export let connecting = false;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Resolve the active terminal server's info for the WebSocket URL
|
||||
const getTerminalInfo = (): { serverId: string; baseUrl: string } | null => {
|
||||
// System terminal (admin-configured, has an `id`)
|
||||
const systemTerminals = ($terminalServers ?? []).filter((t: any) => t.id);
|
||||
const systemMatch = systemTerminals.find((t: any) => t.id === $selectedTerminalId);
|
||||
if (systemMatch) {
|
||||
// For system terminals, WS goes through the Open WebUI backend proxy
|
||||
return { serverId: systemMatch.id, baseUrl: WEBUI_API_BASE_URL };
|
||||
}
|
||||
|
||||
// Direct terminal (user-configured, matched by URL)
|
||||
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
|
||||
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
|
||||
if (directMatch) {
|
||||
// For direct terminals, construct WS URL from the server URL directly
|
||||
return { serverId: '__direct__', baseUrl: directMatch.url };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
if (ws) disconnect();
|
||||
|
||||
const info = getTerminalInfo();
|
||||
if (!info) return;
|
||||
|
||||
connecting = true;
|
||||
|
||||
const token = localStorage.getItem('token') ?? '';
|
||||
|
||||
try {
|
||||
let sessionId: string;
|
||||
let wsUrl: string;
|
||||
let authToken: string;
|
||||
|
||||
if (info.serverId === '__direct__') {
|
||||
// Direct connection to open-terminal
|
||||
const base = info.baseUrl.replace(/\/$/, '');
|
||||
const directTerminals = ($settings?.terminalServers ?? []).filter((s: any) => s.url);
|
||||
const directMatch = directTerminals.find((s: any) => s.url === $selectedTerminalId);
|
||||
const apiKey = directMatch?.key ?? '';
|
||||
authToken = apiKey;
|
||||
|
||||
// Create session
|
||||
const res = await fetch(`${base}/api/terminals`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
|
||||
const session = await res.json();
|
||||
sessionId = session.id;
|
||||
|
||||
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
wsUrl = `${wsBase}/api/terminals/${sessionId}`;
|
||||
} else {
|
||||
// System terminal — proxy through Open WebUI backend
|
||||
const base = info.baseUrl.replace(/\/$/, '');
|
||||
authToken = token;
|
||||
|
||||
// Create session via proxy
|
||||
const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
|
||||
const session = await res.json();
|
||||
sessionId = session.id;
|
||||
|
||||
const wsBase = base.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
|
||||
wsUrl = `${wsBase}/terminals/${info.serverId}/api/terminals/${sessionId}`;
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
// First-message auth (no token in URL)
|
||||
if (ws) {
|
||||
ws.send(JSON.stringify({ type: 'auth', token: authToken }));
|
||||
}
|
||||
connected = true;
|
||||
connecting = false;
|
||||
// Send initial resize
|
||||
if (term && ws) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
// Keepalive ping to prevent idle timeout from proxies/LBs
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
pingInterval = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 25000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (term) {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
term.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
connected = false;
|
||||
connecting = false;
|
||||
if (term) {
|
||||
term.write('\r\n\x1b[90m[Connection closed]\x1b[0m\r\n');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
connected = false;
|
||||
connecting = false;
|
||||
};
|
||||
} catch (err) {
|
||||
connecting = false;
|
||||
if (term) {
|
||||
term.write(`\r\n\x1b[31m[Error: ${err}]\x1b[0m\r\n`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connected = false;
|
||||
connecting = false;
|
||||
};
|
||||
|
||||
const initTerminal = () => {
|
||||
if (!terminalEl || term) return;
|
||||
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
"'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#c0caf5',
|
||||
cursor: '#c0caf5',
|
||||
cursorAccent: '#1a1b26',
|
||||
selectionBackground: '#33467c',
|
||||
selectionForeground: '#c0caf5',
|
||||
black: '#15161e',
|
||||
red: '#f7768e',
|
||||
green: '#9ece6a',
|
||||
yellow: '#e0af68',
|
||||
blue: '#7aa2f7',
|
||||
magenta: '#bb9af7',
|
||||
cyan: '#7dcfff',
|
||||
white: '#a9b1d6',
|
||||
brightBlack: '#414868',
|
||||
brightRed: '#f7768e',
|
||||
brightGreen: '#9ece6a',
|
||||
brightYellow: '#e0af68',
|
||||
brightBlue: '#7aa2f7',
|
||||
brightMagenta: '#bb9af7',
|
||||
brightCyan: '#7dcfff',
|
||||
brightWhite: '#c0caf5'
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalEl);
|
||||
|
||||
// Fit after a frame so the container has dimensions
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon?.fit();
|
||||
});
|
||||
|
||||
// Forward keystrokes to WebSocket
|
||||
term.onData((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
|
||||
// Forward binary data (e.g. paste with special chars)
|
||||
term.onBinary((data) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
const buffer = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
buffer[i] = data.charCodeAt(i) & 0xff;
|
||||
}
|
||||
ws.send(buffer);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// Watch container size changes
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon?.fit();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(terminalEl);
|
||||
|
||||
// Connection is handled by the reactive block below (which fires
|
||||
// when `term` is set here), so we intentionally do NOT call
|
||||
// connect() to avoid creating a duplicate WebSocket whose onclose
|
||||
// handler would write a spurious "[Connection closed]" message.
|
||||
};
|
||||
|
||||
// Reconnect when the selected terminal changes
|
||||
$: if ($selectedTerminalId !== undefined && term) {
|
||||
// Clear the terminal screen and reconnect to the new server
|
||||
disconnect();
|
||||
term.clear();
|
||||
if ($selectedTerminalId) {
|
||||
connect();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initTerminal();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
term?.dispose();
|
||||
term = null;
|
||||
fitAddon = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full min-h-0 relative">
|
||||
<div bind:this={terminalEl} class="absolute inset-0 p-1" class:pointer-events-none={overlay} />
|
||||
</div>
|
||||
@@ -40,6 +40,8 @@
|
||||
let isAudio = false;
|
||||
let isImage = false;
|
||||
let isExcel = false;
|
||||
let isDocx = false;
|
||||
let isPptx = false;
|
||||
|
||||
let selectedTab = '';
|
||||
let excelWorkbook: WorkBook | null = null;
|
||||
@@ -49,6 +51,15 @@
|
||||
let excelError = '';
|
||||
let rowCount = 0;
|
||||
|
||||
// DOCX state
|
||||
let docxHtml = '';
|
||||
let docxError = '';
|
||||
|
||||
// PPTX state
|
||||
let pptxSlides: string[] = [];
|
||||
let pptxCurrentSlide = 0;
|
||||
let pptxError = '';
|
||||
|
||||
let pzInstance: PanZoom | null = null;
|
||||
|
||||
const initImagePanzoom = (node: HTMLElement) => {
|
||||
@@ -128,6 +139,16 @@
|
||||
item.name.toLowerCase().endsWith('.xlsx') ||
|
||||
item.name.toLowerCase().endsWith('.csv')));
|
||||
|
||||
$: isDocx =
|
||||
item?.meta?.content_type ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
(item?.name && item.name.toLowerCase().endsWith('.docx'));
|
||||
|
||||
$: isPptx =
|
||||
item?.meta?.content_type ===
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
|
||||
(item?.name && item.name.toLowerCase().endsWith('.pptx'));
|
||||
|
||||
const loadExcelContent = async () => {
|
||||
try {
|
||||
excelError = '';
|
||||
@@ -150,26 +171,48 @@
|
||||
|
||||
const renderExcelSheet = async () => {
|
||||
if (!excelWorkbook || !selectedSheet) return;
|
||||
|
||||
const { excelToTable } = await import('$lib/utils/excelToTable');
|
||||
const worksheet = excelWorkbook.Sheets[selectedSheet];
|
||||
// Calculate row count
|
||||
const XLSX = await import('xlsx');
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
|
||||
rowCount = range.e.r - range.s.r + 1;
|
||||
|
||||
excelHtml = DOMPurify.sanitize(
|
||||
XLSX.utils.sheet_to_html(worksheet, {
|
||||
id: 'excel-table',
|
||||
editable: false,
|
||||
header: ''
|
||||
})
|
||||
);
|
||||
const result = await excelToTable(worksheet);
|
||||
excelHtml = result.html;
|
||||
rowCount = result.rowCount;
|
||||
};
|
||||
|
||||
$: if (selectedSheet && excelWorkbook) {
|
||||
renderExcelSheet();
|
||||
}
|
||||
|
||||
const loadDocxContent = async () => {
|
||||
try {
|
||||
docxError = '';
|
||||
const [arrayBuffer, mammoth] = await Promise.all([
|
||||
getFileContentById(item.id),
|
||||
import('mammoth')
|
||||
]);
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
docxHtml = DOMPurify.sanitize(result.value);
|
||||
} catch (error) {
|
||||
console.error('Error loading DOCX file:', error);
|
||||
docxError = $i18n.t('Failed to load DOCX file. Please try downloading it instead.');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPptxContent = async () => {
|
||||
try {
|
||||
pptxError = '';
|
||||
const [arrayBuffer, { pptxToImages }] = await Promise.all([
|
||||
getFileContentById(item.id),
|
||||
import('$lib/utils/pptxToHtml')
|
||||
]);
|
||||
const result = await pptxToImages(arrayBuffer);
|
||||
pptxSlides = result.images;
|
||||
pptxCurrentSlide = 0;
|
||||
} catch (error) {
|
||||
console.error('Error loading PPTX file:', error);
|
||||
pptxError = $i18n.t('Failed to load PPTX file. Please try downloading it instead.');
|
||||
}
|
||||
};
|
||||
|
||||
const loadContent = async () => {
|
||||
selectedTab = '';
|
||||
expandedContent = false;
|
||||
@@ -201,6 +244,12 @@
|
||||
if (isExcel) {
|
||||
await loadExcelContent();
|
||||
}
|
||||
if (isDocx) {
|
||||
await loadDocxContent();
|
||||
}
|
||||
if (isPptx) {
|
||||
await loadPptxContent();
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
@@ -358,7 +407,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAudio || isPDF || isExcel || isCode || isMarkdown}
|
||||
{#if isAudio || isPDF || isExcel || isCode || isMarkdown || isDocx || isPptx}
|
||||
<div
|
||||
class="flex mb-2.5 scrollbar-none overflow-x-auto w-full border-b border-gray-50 dark:border-gray-850/30 text-center text-sm font-medium bg-transparent dark:text-gray-200"
|
||||
>
|
||||
@@ -510,7 +559,7 @@
|
||||
{/if}
|
||||
|
||||
{#if excelHtml}
|
||||
<div class="excel-table-container overflow-auto max-h-[60vh]">
|
||||
<div class="office-preview overflow-auto max-h-[60vh]">
|
||||
{@html excelHtml}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -534,6 +583,77 @@
|
||||
>
|
||||
<Markdown content={item.file.data.content} id="markdown-viewer" />
|
||||
</div>
|
||||
{:else if isDocx}
|
||||
{#if docxError}
|
||||
<div class="text-red-500 text-sm p-4">{docxError}</div>
|
||||
{:else if docxHtml}
|
||||
<div
|
||||
class="office-preview max-h-[60vh] overflow-auto p-4 prose dark:prose-invert max-w-full text-sm"
|
||||
>
|
||||
{@html docxHtml}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-500 text-sm p-4">No content available</div>
|
||||
{/if}
|
||||
{:else if isPptx}
|
||||
{#if pptxError}
|
||||
<div class="text-red-500 text-sm p-4">{pptxError}</div>
|
||||
{:else if pptxSlides.length > 0}
|
||||
<div class="max-h-[60vh] overflow-auto">
|
||||
<div class="flex justify-center p-4">
|
||||
<img
|
||||
src={pptxSlides[pptxCurrentSlide]}
|
||||
alt="Slide {pptxCurrentSlide + 1}"
|
||||
class="max-w-full max-h-[50vh] object-contain rounded-md shadow-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{#if pptxSlides.length > 1}
|
||||
<div class="flex items-center justify-center gap-3 pb-3 text-sm text-gray-500">
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={pptxCurrentSlide === 0}
|
||||
on:click={() => (pptxCurrentSlide = Math.max(0, pptxCurrentSlide - 1))}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<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>{pptxCurrentSlide + 1} / {pptxSlides.length}</span>
|
||||
<button
|
||||
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
|
||||
disabled={pptxCurrentSlide === pptxSlides.length - 1}
|
||||
on:click={() =>
|
||||
(pptxCurrentSlide = Math.min(pptxSlides.length - 1, pptxCurrentSlide + 1))}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<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}
|
||||
<div class="text-gray-500 text-sm p-4">No content available</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
|
||||
{(item?.file?.data?.content ?? '').trim() || 'No content'}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
import { Fragment, DOMParser } from 'prosemirror-model';
|
||||
import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view';
|
||||
import { Editor, Extension, mergeAttributes } from '@tiptap/core';
|
||||
import { Editor, Extension, markInputRule, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
|
||||
|
||||
@@ -135,8 +135,26 @@
|
||||
import FileHandler from '@tiptap/extension-file-handler';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
import Highlight from '@tiptap/extension-highlight';
|
||||
import Code from '@tiptap/extension-code';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
|
||||
// WORKAROUND: TipTap's default Code mark input rule regex captures the
|
||||
// character before the opening backtick, causing it to be deleted.
|
||||
// This uses a lookbehind assertion instead so the preceding character is
|
||||
// matched for position but not captured/deleted.
|
||||
// Upstream fix: https://github.com/ueberdosis/tiptap/pull/7124
|
||||
const backtickInputRegex = /(?<=\s|^)`([^`]+)`(?!`)$/;
|
||||
const FixedCode = Code.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
markInputRule({
|
||||
find: backtickInputRegex,
|
||||
type: this.type
|
||||
})
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
import Mention from '@tiptap/extension-mention';
|
||||
import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
|
||||
|
||||
@@ -467,6 +485,17 @@
|
||||
focus();
|
||||
};
|
||||
|
||||
// Convert text to ProseMirror nodes, using hardBreak for newlines
|
||||
const textToNodes = (state, text) => {
|
||||
if (!text.includes('\n')) return state.schema.text(text);
|
||||
const nodes = [];
|
||||
text.split('\n').forEach((line, i) => {
|
||||
if (i > 0) nodes.push(state.schema.nodes.hardBreak.create());
|
||||
if (line) nodes.push(state.schema.text(line));
|
||||
});
|
||||
return nodes;
|
||||
};
|
||||
|
||||
export const replaceVariables = (variables) => {
|
||||
if (!editor || !editor.view) return;
|
||||
const { state, view } = editor;
|
||||
@@ -501,7 +530,7 @@
|
||||
|
||||
// Apply replacements in reverse order to maintain correct positions
|
||||
replacements.reverse().forEach(({ from, to, text }) => {
|
||||
tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []);
|
||||
tr = tr.replaceWith(from, to, text !== '' ? textToNodes(state, text) : []);
|
||||
});
|
||||
|
||||
// Only dispatch if there are changes
|
||||
@@ -692,12 +721,14 @@
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: link,
|
||||
code: false, // Disabled in favor of FixedCode (see workaround above)
|
||||
// When rich text is off, disable Strike from StarterKit so we can
|
||||
// re-add it below without its Mod-Shift-s shortcut (which conflicts
|
||||
// with the Toggle Sidebar shortcut). When rich text is on, the user
|
||||
// can undo strikethrough via the toolbar, so the shortcut is fine.
|
||||
...(richText ? {} : { strike: false })
|
||||
}),
|
||||
FixedCode,
|
||||
...(dragHandle ? [ListItemDragHandle] : []),
|
||||
Placeholder.configure({ placeholder: () => _placeholder, showOnlyWhenEditable: false }),
|
||||
SelectionDecoration,
|
||||
|
||||
@@ -142,19 +142,20 @@
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async ({ name, data }) => {
|
||||
const createFolder = async ({ name, data, parent_id }) => {
|
||||
name = name?.trim();
|
||||
if (!name) {
|
||||
toast.error($i18n.t('Folder name cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const rootFolders = Object.values(folders).filter((folder) => folder.parent_id === null);
|
||||
if (rootFolders.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
|
||||
// Check for duplicate names in the same parent
|
||||
const siblings = Object.values(folders).filter((folder) => folder.parent_id === parent_id);
|
||||
if (siblings.find((folder) => folder.name.toLowerCase() === name.toLowerCase())) {
|
||||
// If a folder with the same name already exists, append a number to the name
|
||||
let i = 1;
|
||||
while (
|
||||
rootFolders.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
|
||||
siblings.find((folder) => folder.name.toLowerCase() === `${name} ${i}`.toLowerCase())
|
||||
) {
|
||||
i++;
|
||||
}
|
||||
@@ -166,9 +167,10 @@
|
||||
const tempId = uuidv4();
|
||||
folders = {
|
||||
...folders,
|
||||
tempId: {
|
||||
[tempId]: {
|
||||
id: tempId,
|
||||
name: name,
|
||||
parent_id: parent_id,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now()
|
||||
}
|
||||
@@ -176,7 +178,8 @@
|
||||
|
||||
const res = await createNewFolder(localStorage.token, {
|
||||
name,
|
||||
data
|
||||
data,
|
||||
parent_id
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
|
||||
@@ -79,6 +79,9 @@
|
||||
|
||||
let mouseOver = false;
|
||||
let draggable = false;
|
||||
$: if (mouseOver) {
|
||||
loadChat();
|
||||
}
|
||||
|
||||
const loadChat = async () => {
|
||||
if (!chat) {
|
||||
@@ -154,8 +157,14 @@
|
||||
};
|
||||
|
||||
const archiveChatHandler = async (id) => {
|
||||
await archiveChatById(localStorage.token, id);
|
||||
dispatch('change');
|
||||
try {
|
||||
await archiveChatById(localStorage.token, id);
|
||||
dispatch('change');
|
||||
toast.success($i18n.t('Chat archived.'));
|
||||
} catch (error) {
|
||||
console.error('Error archiving chat:', error);
|
||||
toast.error($i18n.t('Failed to archive chat.'));
|
||||
}
|
||||
};
|
||||
|
||||
const moveChatHandler = async (chatId, folderId) => {
|
||||
|
||||
@@ -11,11 +11,13 @@
|
||||
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import Folder from '$lib/components/icons/Folder.svelte';
|
||||
|
||||
export let align: 'start' | 'end' = 'start';
|
||||
export let onEdit = () => {};
|
||||
export let onExport = () => {};
|
||||
export let onDelete = () => {};
|
||||
export let onCreateSub = () => {};
|
||||
|
||||
let show = false;
|
||||
</script>
|
||||
@@ -47,6 +49,18 @@
|
||||
{align}
|
||||
transition={flyAndScale}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
onCreateSub();
|
||||
}}
|
||||
>
|
||||
<Folder />
|
||||
<div class="flex items-center">{$i18n.t('Create Folder')}</div>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1" />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
on:click={() => {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
export let onSubmit: Function = (e) => {};
|
||||
|
||||
export let folderId = null;
|
||||
export let parentId = null;
|
||||
export let edit = false;
|
||||
|
||||
let folder = null;
|
||||
@@ -55,7 +56,8 @@
|
||||
await onSubmit({
|
||||
name,
|
||||
meta,
|
||||
data
|
||||
data,
|
||||
parent_id: edit ? undefined : parentId
|
||||
});
|
||||
show = false;
|
||||
loading = false;
|
||||
|
||||
@@ -37,6 +37,20 @@
|
||||
|
||||
let unsubscribeSettings;
|
||||
|
||||
const cleanupStalePinnedModels = async (modelIds) => {
|
||||
const validModels = modelIds.filter((id) => {
|
||||
const model = $models.find((m) => m.id === id);
|
||||
// Remove if model not found (deleted) or if hidden
|
||||
return model && !(model?.info?.meta?.hidden ?? false);
|
||||
});
|
||||
|
||||
if (validModels.length !== modelIds.length) {
|
||||
pinnedModels = validModels;
|
||||
settings.set({ ...$settings, pinnedModels: validModels });
|
||||
await updateUserSettings(localStorage.token, { ui: $settings });
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
pinnedModels = $settings?.pinnedModels ?? [];
|
||||
|
||||
@@ -48,6 +62,11 @@
|
||||
await updateUserSettings(localStorage.token, { ui: $settings });
|
||||
}
|
||||
|
||||
// Auto-unpin hidden or deleted models
|
||||
if (pinnedModels.length > 0) {
|
||||
await cleanupStalePinnedModels(pinnedModels);
|
||||
}
|
||||
|
||||
unsubscribeSettings = settings.subscribe((value) => {
|
||||
pinnedModels = value?.pinnedModels ?? [];
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
updateFolderIsExpandedById,
|
||||
updateFolderById,
|
||||
updateFolderParentIdById,
|
||||
getFolderById
|
||||
getFolderById,
|
||||
createNewFolder
|
||||
} from '$lib/apis/folders';
|
||||
import {
|
||||
getChatById,
|
||||
@@ -64,6 +65,9 @@
|
||||
let showFolderModal = false;
|
||||
let edit = false;
|
||||
|
||||
let showCreateSubFolderModal = false;
|
||||
let createSubFolderParentId = null;
|
||||
|
||||
let draggedOver = false;
|
||||
let dragged = false;
|
||||
|
||||
@@ -414,6 +418,30 @@
|
||||
|
||||
saveAs(blob, `folder-${folders[folderId].name}-export-${Date.now()}.json`);
|
||||
};
|
||||
|
||||
const createSubFolderHandler = async ({ name, meta, data, parent_id }) => {
|
||||
if (name === '') {
|
||||
toast.error($i18n.t('Folder name cannot be empty.'));
|
||||
return;
|
||||
}
|
||||
|
||||
name = name.trim();
|
||||
|
||||
const res = await createNewFolder(localStorage.token, {
|
||||
name,
|
||||
data,
|
||||
meta,
|
||||
parent_id
|
||||
}).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
toast.success($i18n.t('Folder created successfully'));
|
||||
dispatch('update');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
@@ -444,6 +472,12 @@
|
||||
|
||||
<FolderModal bind:show={showFolderModal} edit={true} {folderId} onSubmit={updateHandler} />
|
||||
|
||||
<FolderModal
|
||||
bind:show={showCreateSubFolderModal}
|
||||
parentId={createSubFolderParentId}
|
||||
onSubmit={createSubFolderHandler}
|
||||
/>
|
||||
|
||||
{#if dragged && x && y}
|
||||
<DragGhost {x} {y}>
|
||||
<div class=" bg-black/80 backdrop-blur-2xl px-2 py-1 rounded-lg w-fit max-w-40">
|
||||
@@ -593,6 +627,10 @@
|
||||
onExport={() => {
|
||||
exportHandler();
|
||||
}}
|
||||
onCreateSub={() => {
|
||||
createSubFolderParentId = folderId;
|
||||
showCreateSubFolderModal = true;
|
||||
}}
|
||||
>
|
||||
<div class="p-1 dark:hover:bg-gray-850 rounded-lg touch-auto">
|
||||
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
|
||||
|
||||
@@ -218,7 +218,11 @@
|
||||
}
|
||||
|
||||
info.params.system = system.trim() === '' ? null : system;
|
||||
info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null;
|
||||
info.params.stop = params.stop
|
||||
? (typeof params.stop === 'string' ? params.stop.split(',') : params.stop).filter((s) =>
|
||||
s.trim()
|
||||
)
|
||||
: null;
|
||||
Object.keys(info.params).forEach((key) => {
|
||||
if (info.params[key] === '' || info.params[key] === null) {
|
||||
delete info.params[key];
|
||||
|
||||
@@ -252,15 +252,15 @@
|
||||
<div class="pb-3 flex justify-end">
|
||||
{#if !disabled}
|
||||
<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 items-center"
|
||||
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"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t(edit ? 'Save' : 'Save & Create')}
|
||||
{#if loading}
|
||||
<div class="ml-1.5">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -184,7 +184,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 {saving
|
||||
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 {saving
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
@@ -193,9 +193,9 @@
|
||||
{$i18n.t('Save')}
|
||||
|
||||
{#if saving}
|
||||
<div class="ml-2 self-center">
|
||||
<span class="shrink-0">
|
||||
<Spinner />
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "المحادثة",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "UI الدردشة",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "الإفتراضي صلاحيات المستخدم",
|
||||
"Defaults": "",
|
||||
"Delete": "حذف",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "حذف الموديل",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "حذف جميع الدردشات",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "e.g. '30s','10m'. الوحدات الزمنية الصالحة هي 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "فشل في قراءة محتويات الحافظة",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "الأسم",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "لا توجد نتائج",
|
||||
"No results found": "لا توجد نتايج",
|
||||
"No search query generated": "لم يتم إنشاء استعلام بحث",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "لا يوجد مصدر متاح",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "موقف ايجابي",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Ollama.com \"{{searchValue}}\" أسحب من ",
|
||||
"Pull a model from Ollama.com": "Ollama.com سحب الموديل من ",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "النتيجة",
|
||||
"Retrieval": "",
|
||||
@@ -1586,6 +1601,7 @@
|
||||
"Role": "منصب",
|
||||
"RTL": "من اليمين إلى اليسار",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "",
|
||||
"Running...": "جارٍ التنفيذ...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1776,6 +1792,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "بداية القناة",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2120,6 +2137,8 @@
|
||||
"You're now logged in.": "لقد قمت الآن بتسجيل الدخول.",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "هل أنت متأكد من رغبتك في حذف هذه القناة؟",
|
||||
"Are you sure you want to delete this message?": "هل أنت متأكد من رغبتك في حذف هذه الرسالة؟",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "هل أنت متأكد من رغبتك في إلغاء أرشفة جميع المحادثات المؤرشفة؟",
|
||||
"Arena Models": "نماذج الساحة",
|
||||
"Artifacts": "القطع الأثرية",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "حد الأحرف لمدخل توليد الإكمال التلقائي",
|
||||
"Chart new frontiers": "رسم آفاق جديدة",
|
||||
"Chat": "محادثة",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "صورة خلفية المحادثة",
|
||||
"Chat Bubble UI": "واجهة فقاعات المحادثة",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "الإفتراضي صلاحيات المستخدم",
|
||||
"Defaults": "",
|
||||
"Delete": "حذف",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "حذف الموديل",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "حذف جميع الدردشات",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "ارسم",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "e.g. '30s','10m'. الوحدات الزمنية الصالحة هي 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "فشل في إضافة الملف.",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "فشل في قراءة محتويات الحافظة",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "تم حذف المجلد بنجاح",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "الأسم",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "لم يتم تحديد ملف",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "لم يتم العثور على محتوى HTML أو CSS أو JavaScript.",
|
||||
"No inference engine with management support found": "لم يتم العثور على محرك استدلال يدعم الإدارة",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "لم يتم العثور على معرفة",
|
||||
"No memories to clear": "لا توجد ذاكرة لمسحها",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "لا توجد نتائج",
|
||||
"No results found": "لا توجد نتايج",
|
||||
"No search query generated": "لم يتم إنشاء استعلام بحث",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "لا يوجد مصدر متاح",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "المنفذ",
|
||||
"Ports": "",
|
||||
"Positive attitude": "موقف ايجابي",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "معرف البادئة",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Ollama.com \"{{searchValue}}\" أسحب من ",
|
||||
"Pull a model from Ollama.com": "Ollama.com سحب الموديل من ",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "توجيه إنشاء الاستعلام",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "تقسيم الرد",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "النتيجة",
|
||||
"RESULT": "النتيجة",
|
||||
"Retrieval": "الاسترجاع",
|
||||
@@ -1586,6 +1601,7 @@
|
||||
"Role": "منصب",
|
||||
"RTL": "من اليمين إلى اليسار",
|
||||
"Run": "تنفيذ",
|
||||
"Run All": "",
|
||||
"Running": "جارٍ التنفيذ",
|
||||
"Running...": "جارٍ التنفيذ...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1776,6 +1792,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "بداية القناة",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2120,6 +2137,8 @@
|
||||
"You're now logged in.": "لقد قمت الآن بتسجيل الدخول.",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "حالة حسابك حالياً بانتظار التفعيل.",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "سيتم توجيه كامل مساهمتك مباشرة إلى مطور المكون الإضافي؛ لا تأخذ Open WebUI أي نسبة. ومع ذلك، قد تفرض منصة التمويل المختارة رسومًا خاصة بها.",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "Сигурни ли сте, че искате да изтриете този канал?",
|
||||
"Are you sure you want to delete this message?": "Сигурни ли сте, че искате да изтриете това съобщение?",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "Сигурни ли сте, че искате да разархивирате всички архивирани чатове?",
|
||||
"Arena Models": "Арена Модели",
|
||||
"Artifacts": "Артефакти",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "Ограничение на символите за входа на генериране на автоматично довършване",
|
||||
"Chart new frontiers": "Начертайте нови граници",
|
||||
"Chat": "Чат",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "Фоново изображение на чата",
|
||||
"Chat Bubble UI": "Потребителски интерфейс за чат балон",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Ролята на потребителя по подразбиране",
|
||||
"Defaults": "",
|
||||
"Delete": "Изтриване",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Изтриване на модела",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "Изтриване на всички чатове",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "Рисуване",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "напр. '30с','10м'. Валидни единици са 'с', 'м', 'ч'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "Неуспешно добавяне на файл.",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Грешка при четене на съдържанието от клипборда",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "Папката е изтрита успешно",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Име",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "Не е избран файл",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "Не е намерено HTML, CSS или JavaScript съдържание.",
|
||||
"No inference engine with management support found": "Не е намерен механизъм за извод с поддръжка на управлението",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "Не са намерени знания",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Няма намерени резултати",
|
||||
"No results found": "Няма намерени резултати",
|
||||
"No search query generated": "Не е генерирана заявка за търсене",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "Няма наличен източник",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "Порт",
|
||||
"Ports": "",
|
||||
"Positive attitude": "Позитивно отношение",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "Префикс ID",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Извади \"{{searchValue}}\" от Ollama.com",
|
||||
"Pull a model from Ollama.com": "Издърпайте модела от Ollama.com",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "Промпт за генериране на запитвания",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "Разделяне на отговора",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "Резултат",
|
||||
"RESULT": "Резултат",
|
||||
"Retrieval": "Извличане",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "Роля",
|
||||
"RTL": "RTL",
|
||||
"Run": "Изпълни",
|
||||
"Run All": "",
|
||||
"Running": "Изпълнява се",
|
||||
"Running...": "Изпълнява се...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "Начало на канала",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "Сега вие влязохте в системата.",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "Статусът на вашия акаунт в момента очаква активиране.",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "Цялата ви вноска ще отиде директно при разработчика на плъгина; Open WebUI не взима никакъв процент. Въпреки това, избраната платформа за финансиране може да има свои собствени такси.",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "চ্যাট",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "চ্যাট বাবল UI",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "ইউজারের ডিফল্ট পদবি",
|
||||
"Defaults": "",
|
||||
"Delete": "মুছে ফেলুন",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "একটি মডেল মুছে ফেলুন",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "সব চ্যাট মুছে ফেলুন",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "যেমন '30s','10m'. সময়ের অনুমোদিত অনুমোদিত এককগুলি হচ্ছে 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "ক্লিপবোর্ডের বিষয়বস্তু পড়া সম্ভব হয়নি",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "নাম",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "কোন ফলাফল পাওয়া যায়নি",
|
||||
"No results found": "কোন ফলাফল পাওয়া যায়নি",
|
||||
"No search query generated": "কোনও অনুসন্ধান ক্যোয়ারী উত্পন্ন হয়নি",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "কোন উৎস পাওয়া যায়নি",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "পজিটিভ আক্রমণ",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Ollama.com থেকে \"{{searchValue}}\" টানুন",
|
||||
"Pull a model from Ollama.com": "Ollama.com থেকে একটি টেনে আনুন আনুন",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "ফলাফল",
|
||||
"Retrieval": "",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "পদবি",
|
||||
"RTL": "RTL",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "",
|
||||
"Running...": "চলমান...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "চ্যানেলের শুরু",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "আপনি এখন লগইন করা অবস্থায় আছেন",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "ཁྱེད་ཀྱིས་བགྲོ་གླེང་འདི་བསུབ་འདོད་ངེས་ཡིན་ནམ།",
|
||||
"Are you sure you want to delete this message?": "འཕྲིན་འདི་བསུབ་འདོད་ངེས་ཡིན་ནམ།",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "ཁྱེད་ཀྱིས་ཡིག་མཛོད་དུ་བཞག་པའི་ཁ་བརྡ་ཡོངས་རྫོགས་ཕྱིར་འདོན་འདོད་ངེས་ཡིན་ནམ།",
|
||||
"Arena Models": "Arena དཔེ་དབྱིབས།",
|
||||
"Artifacts": "རྫས་རྟེན།",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "རང་འཚང་བཟོ་སྐྲུན་ནང་འཇུག་གི་ཡིག་འབྲུ་ཚད་བཀག",
|
||||
"Chart new frontiers": "ས་མཚམས་གསར་པ་འགོད་པ།",
|
||||
"Chat": "ཁ་བརྡ།",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "ཁ་བརྡའི་རྒྱབ་ལྗོངས་པར།",
|
||||
"Chat Bubble UI": "ཁ་བརྡའི་ལྦུ་བའི་ UI",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "སྔོན་སྒྲིག་བེད་སྤྱོད་མཁན་གྱི་གནས་ཚད།",
|
||||
"Defaults": "",
|
||||
"Delete": "བསུབ་པ།",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "དཔེ་དབྱིབས་ཤིག་བསུབ་པ།",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "ཁ་བརྡ་ཡོངས་རྫོགས་བསུབ་པ།",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "རི་མོ་འབྲི་བ།",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "དཔེར་ན། '30s','10m'. ནུས་ལྡན་དུས་ཚོད་ཀྱི་ཚན་པ་ནི། 's', 'm', 'h' ཡིན།",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "ཡིག་ཆ་སྣོན་པར་མ་ཐུབ།",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "{{URL}} OpenAPI ལག་ཆའི་སར་བར་ལ་སྦྲེལ་མཐུད་བྱེད་མ་ཐུབ།",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "སྦྱར་སྡེར་གྱི་ནང་དོན་ཀློག་མ་ཐུབ།",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "ཡིག་སྣོད་ལེགས་པར་བསུབས་ཟིན།",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "མིང་།",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "ཡིག་ཆ་གདམ་ག་མ་བྱས།",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "HTML, CSS, ཡང་ན་ JavaScript གི་ནང་དོན་མ་རྙེད།",
|
||||
"No inference engine with management support found": "དོ་དམ་རྒྱབ་སྐྱོར་ཡོད་པའི་དཔོག་རྩིས་འཕྲུལ་འཁོར་མ་རྙེད།",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "ཤེས་བྱ་མ་རྙེད།",
|
||||
"No memories to clear": "གཙང་སེལ་བྱེད་རྒྱུའི་དྲན་ཤེས་མེད།",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "འབྲས་བུ་མ་རྙེད།",
|
||||
"No results found": "འབྲས་བུ་མ་རྙེད།",
|
||||
"No search query generated": "འཚོལ་བཤེར་འདྲི་བ་བཟོས་མེད།",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "འབྱུང་ཁུངས་མེད།",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "Port",
|
||||
"Ports": "",
|
||||
"Positive attitude": "ལྟ་སྟངས་དགེ་མཚན།",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "སྔོན་སྦྱོར་ ID",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Ollama.com ནས་ \"{{searchValue}}\" འཐེན་པ།",
|
||||
"Pull a model from Ollama.com": "Ollama.com ནས་དཔེ་དབྱིབས་ཤིག་འཐེན་པ།",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "འདྲི་བ་བཟོ་སྐྲུན་གྱི་འགུལ་སློང་།",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "ལན་བགོ་བ།",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "འབྲས་བུ།",
|
||||
"RESULT": "འབྲས་བུ།",
|
||||
"Retrieval": "ལེན་ཚུར་སྒྲུབ།",
|
||||
@@ -1581,6 +1596,7 @@
|
||||
"Role": "གནས་ཚད།",
|
||||
"RTL": "RTL",
|
||||
"Run": "ལག་བསྟར།",
|
||||
"Run All": "",
|
||||
"Running": "ལག་བསྟར་བྱེད་བཞིན་པ།",
|
||||
"Running...": "ལག་བསྟར་བྱེད་བཞིན་པ།...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1771,6 +1787,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "རྒྱས་ལམ་འགོ་རིམ་",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2115,6 +2132,8 @@
|
||||
"You're now logged in.": "ཁྱེད་ད་ལྟ་ནང་འཛུལ་བྱས་ཟིན།",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "ཁྱེད་ཀྱི་རྩིས་ཁྲའི་གནས་སྟངས་ད་ལྟ་སྒུལ་བསྐྱོད་སྒུག་བཞིན་པ།",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "ཁྱེད་ཀྱི་ཞལ་འདེབས་ཆ་ཚང་ཐད་ཀར་ plugin གསར་སྤེལ་བ་ལ་འགྲོ་ངེས། Open WebUI ཡིས་བརྒྱ་ཆ་གང་ཡང་མི་ལེན། འོན་ཀྱང་། གདམ་ཟིན་པའི་མ་དངུལ་གཏོང་བའི་སྟེགས་བུ་ལ་དེའི་རང་གི་འགྲོ་གྲོན་ཡོད་སྲིད།",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "Razgovor",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "Razgovor - Bubble UI",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Zadana korisnička uloga",
|
||||
"Defaults": "",
|
||||
"Delete": "Izbriši",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Izbriši model",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "Izbriši sve razgovore",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "npr. '30s','10m'. Važeće vremenske jedinice su 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Neuspješno čitanje sadržaja međuspremnika",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Ime",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Nema rezultata",
|
||||
"No results found": "Nema rezultata",
|
||||
"No search query generated": "Nije generiran upit za pretraživanje",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "Nema dostupnog izvora",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "Pozitivan stav",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Povucite \"{{searchValue}}\" s Ollama.com",
|
||||
"Pull a model from Ollama.com": "Povucite model s Ollama.com",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "REZULTAT",
|
||||
"Retrieval": "",
|
||||
@@ -1583,6 +1598,7 @@
|
||||
"Role": "Uloga",
|
||||
"RTL": "RTL",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "Pokrenuto",
|
||||
"Running...": "Pokrenuto...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1773,6 +1789,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "Početak kanala",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2117,6 +2134,8 @@
|
||||
"You're now logged in.": "Sada ste prijavljeni.",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "Status vašeg računa trenutno čeka aktivaciju.",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
"{{COUNT}} Rows": "{{COUNT}} files",
|
||||
"{{COUNT}} Sources": "{{COUNT}} fonts",
|
||||
"{{COUNT}} words": "{{COUNT}} paraules",
|
||||
"{{COUNT}}d_time_ago": "",
|
||||
"{{COUNT}}h_time_ago": "",
|
||||
"{{COUNT}}m_time_ago": "",
|
||||
"{{COUNT}}w_time_ago": "",
|
||||
"{{COUNT}}y_time_ago": "",
|
||||
"{{COUNT}}d_time_ago": "{{COUNT}}d_time_ago",
|
||||
"{{COUNT}}h_time_ago": "{{COUNT}}h_time_ago",
|
||||
"{{COUNT}}m_time_ago": "{{COUNT}}m_time_ago",
|
||||
"{{COUNT}}w_time_ago": "{{COUNT}}w_time_ago",
|
||||
"{{COUNT}}y_time_ago": "{{COUNT}}y_time_ago",
|
||||
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} a les {{LOCALIZED_TIME}}",
|
||||
"{{model}} download has been canceled": "La descàrrega del model {{model}} s'ha cancel·lat",
|
||||
"{{modelName}} profile image": "Imatge del perfil {{modelName}}",
|
||||
@@ -32,7 +32,7 @@
|
||||
"{{webUIName}} Backend Required": "El Backend de {{webUIName}} és necessari",
|
||||
"*Prompt node ID(s) are required for image generation": "*Els identificadors de nodes d'indicacions són necessaris per a la generació d'imatges",
|
||||
"1 Source": "1 font",
|
||||
"1m_time_ago": "",
|
||||
"1m_time_ago": "1m_time_ago",
|
||||
"A collaboration channel where people join as members": "Un canal de col·laboració on la gent s'uneix com a membres",
|
||||
"A discussion channel where access is controlled by groups and permissions": "Un canal de discussió on l'accés està controlat per grups i permisos",
|
||||
"A new version (v{{LATEST_VERSION}}) is now available.": "Hi ha una nova versió disponible (v{{LATEST_VERSION}}).",
|
||||
@@ -43,7 +43,7 @@
|
||||
"Accept Autocomplete Generation\nJump to Prompt Variable": "Accepta la generació d'autocompleció\nVés a la variable de la indicació",
|
||||
"Access": "Accés",
|
||||
"Access Control": "Control d'accés",
|
||||
"Access Grants": "",
|
||||
"Access Grants": "Assignacions d'accés",
|
||||
"Access List": "Llista d'accés",
|
||||
"Accessible to all users": "Accessible a tots els usuaris",
|
||||
"Account": "Compte",
|
||||
@@ -80,14 +80,14 @@
|
||||
"Add Reaction": "Afegir reacció",
|
||||
"Add tag": "Afegir etiqueta",
|
||||
"Add Tag": "Afegir etiqueta",
|
||||
"Add Terminal": "",
|
||||
"Add Terminal Connection": "",
|
||||
"Add Terminal": "Afegir terminal",
|
||||
"Add Terminal Connection": "Afegir connexió a terminal",
|
||||
"Add text content": "Afegir contingut de text",
|
||||
"Add to favorites": "Afegir als favorits",
|
||||
"Add User": "Afegir un usuari",
|
||||
"Add User Group": "Afegir grup d'usuaris",
|
||||
"Add webpage": "Afegir pàgina web",
|
||||
"Add your Open Terminal URL and API key in Settings → Integrations.": "",
|
||||
"Add your Open Terminal URL and API key in Settings → Integrations.": "Afegeix l'URL i la clau API de l'Open Terminal a Configuració → Integracions.",
|
||||
"Additional Config": "Configuració addicional",
|
||||
"Additional configuration options for marker. This should be a JSON string with key-value pairs. For example, '{\"key\": \"value\"}'. Supported keys include: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level": "Opcions de configuració addicionals per al marcador. Hauria de ser una cadena JSON amb parelles clau-valor. Per exemple, '{\"key\": \"value\"}'. Les claus compatibles inclouen: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level",
|
||||
"Additional feedback comments": "Comentaris addicionals de retorn",
|
||||
@@ -100,7 +100,7 @@
|
||||
"Admin Panel": "Panell d'administració",
|
||||
"Admin Settings": "Preferències d'administració",
|
||||
"Admins have access to all tools at all times; users need tools assigned per model in the workspace.": "Els administradors tenen accés a totes les eines en tot moment; els usuaris necessiten eines assignades per model a l'espai de treball.",
|
||||
"Advanced": "",
|
||||
"Advanced": "Avançat",
|
||||
"Advanced Parameters": "Paràmetres avançats",
|
||||
"Advanced parameters for MinerU parsing (enable_ocr, enable_formula, enable_table, language, model_version, page_ranges)": "Paràmetres avançats per a l'anàlisi sintàctica de MinerU (enable_ocr, enable_formula, enable_table, language, model_version, page_ranges)",
|
||||
"Advanced Params": "Paràmetres avançats",
|
||||
@@ -108,10 +108,10 @@
|
||||
"AI": "IA",
|
||||
"All": "Tots",
|
||||
"All chats have been unarchived.": "Tots els xats han estat desarxivats.",
|
||||
"All models are now hidden": "",
|
||||
"All models are now visible": "",
|
||||
"All models are now hidden": "Tots els models estan amagats, ara",
|
||||
"All models are now visible": "Tots els models són visibles, ara",
|
||||
"All models deleted successfully": "Tots els models s'han eliminat correctament",
|
||||
"All time": "",
|
||||
"All time": "Sempre",
|
||||
"All Users": "Tots els usuaris",
|
||||
"Allow Call": "Permetre la trucada",
|
||||
"Allow Chat Controls": "Permetre els controls de xat",
|
||||
@@ -129,7 +129,7 @@
|
||||
"Allow non-local voices": "Permetre veus no locals",
|
||||
"Allow Rate Response": "Permetre valorar les respostes",
|
||||
"Allow Regenerate Response": "Permetre regenerar respostes",
|
||||
"Allow Sharing With Users": "",
|
||||
"Allow Sharing With Users": "Permetre compartir amb usuaris",
|
||||
"Allow Speech to Text": "Permetre Parla a Text",
|
||||
"Allow Temporary Chat": "Permetre el xat temporal",
|
||||
"Allow Text to Speech": "Permetre Text a Parla",
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "Estàs segur que vols eliminar aquest canal?",
|
||||
"Are you sure you want to delete this message?": "Estàs segur que vols eliminar aquest missatge?",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "Estàs segur que vols suprimir aquesta versió? Les versions filles es tornaran a enllaçar amb la versió principal d'aquesta versió.",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "Estàs segur que vols desarxivar tots els xats arxivats?",
|
||||
"Arena Models": "Models de l'Arena",
|
||||
"Artifacts": "Artefactes",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "Límit de caràcters per a l'entrada de generació automàtica",
|
||||
"Chart new frontiers": "Traça noves fronteres",
|
||||
"Chat": "Xat",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "Imatge de fons del xat",
|
||||
"Chat Bubble UI": "Chat Bubble UI",
|
||||
"Chat Completions": "Completacions de xat",
|
||||
@@ -380,8 +382,8 @@
|
||||
"Confirm your new password": "Confirma la teva nova contrasenya",
|
||||
"Confirm Your Password": "Confirma la teva contrasenya",
|
||||
"Connect to an AI provider to start chatting": "Connectar a un proveidor d'IA per començar a xerrar",
|
||||
"Connect to Open Terminal instances to browse files and use them as always-on tools. Only one can be active at a time.": "",
|
||||
"Connect to Open Terminal instances. All users will have access to file browsing and terminal tools through these servers.": "",
|
||||
"Connect to Open Terminal instances to browse files and use them as always-on tools. Only one can be active at a time.": "Connecta't a instàncies d'Open Terminal per navegar per fitxers i utilitzar-los com a eines sempre actives. Només una pot estar activa alhora.",
|
||||
"Connect to Open Terminal instances. All users will have access to file browsing and terminal tools through these servers.": "Connecta't a instàncies d'Open Terminal. Tots els usuaris tindran accés a la navegació de fitxers i a les eines del terminal a través d'aquests servidors.",
|
||||
"Connect to your own OpenAI compatible API endpoints.": "Connecta als teus propis punts de connexió de l'API compatible amb OpenAI",
|
||||
"Connect to your own OpenAPI compatible external tool servers.": "Connecta als teus propis servidors d'eines externs compatibles amb OpenAPI",
|
||||
"Connection failed": "La connexió ha fallat",
|
||||
@@ -423,7 +425,7 @@
|
||||
"Copy URL": "Copiar la URL",
|
||||
"Copying to clipboard was successful!": "La còpia al porta-retalls s'ha realitzat correctament",
|
||||
"CORS must be properly configured by the provider to allow requests from Open WebUI.": "CORS ha de ser configurat correctament pel proveïdor per permetre les sol·licituds d'Open WebUI",
|
||||
"Could not read file.": "",
|
||||
"Could not read file.": "No s'ha pogut llegir l'arxiu",
|
||||
"Create": "Crear",
|
||||
"Create a knowledge base": "Crear una base de coneixement",
|
||||
"Create a model": "Crear un model",
|
||||
@@ -454,7 +456,7 @@
|
||||
"Custom Gender": "Gènere personalitzat",
|
||||
"Custom Parameter Name": "Nom del paràmetre personalitzat",
|
||||
"Custom Parameter Value": "Valor del paràmetre personalitzat",
|
||||
"Daily Messages": "",
|
||||
"Daily Messages": "Missatges diaris",
|
||||
"Danger Zone": "Zona de perill",
|
||||
"Dark": "Fosc",
|
||||
"Data Controls": "Controls de dades",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Rol d'usuari per defecte",
|
||||
"Defaults": "Valors per defecte",
|
||||
"Delete": "Eliminar",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Eliminar un model",
|
||||
"Delete All": "Eliminar tot",
|
||||
"Delete All Chats": "Eliminar tots els xats",
|
||||
@@ -577,7 +580,8 @@
|
||||
"Downloading stats...": "Descarregant estadístiques...",
|
||||
"Draw": "Dibuixar",
|
||||
"Drop any files here to upload": "Arrossega aquí qualsevol fitxer per pujar-lo",
|
||||
"Drop files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "Deixa anar els arxius aquí per pujar-los",
|
||||
"DuckDuckGo": "DuckDuckGo",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p. ex. '30s','10m'. Les unitats de temps vàlides són 's', 'm', 'h'.",
|
||||
"e.g. 'low', 'medium', 'high'": "p. ex. 'baix', 'mitjà', 'alt'",
|
||||
@@ -612,7 +616,7 @@
|
||||
"Edit Last Message": "Editar el darrer missatge",
|
||||
"Edit Memory": "Editar la memòria",
|
||||
"Edit Prompt": "Editar la indicació",
|
||||
"Edit Terminal Connection": "",
|
||||
"Edit Terminal Connection": "Editar la connexió al terminal",
|
||||
"Edit User": "Editar l'usuari",
|
||||
"Edit User Group": "Editar el grup d'usuaris",
|
||||
"Edit workflow.json content": "Editar el contingut de workflow.json",
|
||||
@@ -822,10 +826,11 @@
|
||||
"Fade Effect for Streaming Text": "Efecte de fos a negre per al text en streaming",
|
||||
"Failed to add file.": "No s'ha pogut afegir l'arxiu.",
|
||||
"Failed to add members": "No s'han pogut afegir el membres",
|
||||
"Failed to attach file": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "No s'ha pogut adjuntar l'arxiu",
|
||||
"Failed to clear status": "No s'ha pogut esborar l'estat",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "No s'ha pogut connecta al servidor d'eines OpenAPI {{URL}}",
|
||||
"Failed to connect to {{URL}} terminal server": "",
|
||||
"Failed to connect to {{URL}} terminal server": "No s'ha pogut connecta al servidor de terminal {{URL}}",
|
||||
"Failed to copy link": "No s'ha pogut copiar l'enllaç",
|
||||
"Failed to create API Key.": "No s'ha pogut crear la clau API.",
|
||||
"Failed to delete note": "No s'ha pogut eliminar la nota",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "No s'ha pogut generar el títol",
|
||||
"Failed to import models": "No s'han pogut importar el models",
|
||||
"Failed to load chat preview": "No s'ha pogut carregar la previsualització del xat",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "No s'ha pogut carregar el fitxer Excel/CSV. Si us plau, prova de descarregar-lo.",
|
||||
"Failed to load file content.": "No s'ha pogut carregar el contingut del fitxer",
|
||||
"Failed to load Interface settings": "No s'ha pogut carregar la configuració de la interficie",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "No s'ha pogut moure el xat",
|
||||
"Failed to process URL: {{url}}": "No s'ha pogut processar la URL: {{url}}",
|
||||
"Failed to read clipboard contents": "No s'ha pogut llegir el contingut del porta-retalls",
|
||||
@@ -848,7 +855,7 @@
|
||||
"Failed to save connections": "No s'han pogut desar les connexions",
|
||||
"Failed to save conversation": "No s'ha pogut desar la conversa",
|
||||
"Failed to save models configuration": "No s'ha pogut desar la configuració dels models",
|
||||
"Failed to save terminal servers": "",
|
||||
"Failed to save terminal servers": "No s'han pogut desar els servidors de terminal",
|
||||
"Failed to unshare chat.": "No s'ha pogut deixar de compartir el xat.",
|
||||
"Failed to update settings": "No s'han pogut actualitzar les preferències",
|
||||
"Failed to update status": "No s'ha pogut actualitzar l'estat",
|
||||
@@ -865,14 +872,14 @@
|
||||
"Female": "Dona",
|
||||
"File": "Arxiu",
|
||||
"File added successfully.": "L'arxiu s'ha afegit correctament.",
|
||||
"File attached to chat": "",
|
||||
"File browser": "",
|
||||
"File attached to chat": "L'arxiu s'ha adjuntat al xat",
|
||||
"File browser": "Navegador d'arxius",
|
||||
"File content": "Contingut de l'arxiu",
|
||||
"File content updated successfully.": "El contingut de l'arxiu s'ha actualitzat correctament.",
|
||||
"File Context": "Contingut de l'arxiu",
|
||||
"File deleted successfully.": "L'arxiu s'ha eliminat correctament",
|
||||
"File Mode": "Mode d'arxiu",
|
||||
"File name": "",
|
||||
"File name": "Nom d'arxiu",
|
||||
"File not found.": "No s'ha trobat l'arxiu.",
|
||||
"File removed successfully.": "Arxiu eliminat correctament.",
|
||||
"File size should not exceed {{maxSize}} MB.": "La mida del fitxer no ha de superar els {{maxSize}} MB.",
|
||||
@@ -893,9 +900,10 @@
|
||||
"Focus Chat Input": "Posa el focus en l'entrada del xat",
|
||||
"Folder": "Carpeta",
|
||||
"Folder Background Image": "Imatge del fons de la carpeta",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "Carpeta eliminada correctament",
|
||||
"Folder Max File Count": "Nombre màxim d'arxius per carpeta",
|
||||
"Folder name": "",
|
||||
"Folder name": "Nom de carpeta",
|
||||
"Folder Name": "Nom de la carpeta",
|
||||
"Folder name cannot be empty.": "El nom de la carpeta no pot ser buit.",
|
||||
"Folder name updated successfully": "Nom de la carpeta actualitzat correctament",
|
||||
@@ -984,7 +992,7 @@
|
||||
"Hex Color - Leave empty for default color": "Color hexadecimal - Deixar buit per a color per defecte",
|
||||
"Hidden": "Amagat",
|
||||
"Hide": "Amaga",
|
||||
"Hide All": "",
|
||||
"Hide All": "Amagar tots",
|
||||
"Hide from Sidebar": "Amagar de la barra lateral",
|
||||
"Hide Model": "Amagar el model",
|
||||
"High": "Alt",
|
||||
@@ -992,7 +1000,7 @@
|
||||
"History": "Historial",
|
||||
"Home": "Inici",
|
||||
"Host": "Servidor",
|
||||
"Hourly Messages": "",
|
||||
"Hourly Messages": "Missatges horaris",
|
||||
"How can I help you today?": "Com et puc ajudar avui?",
|
||||
"How would you rate this response?": "Com avaluaries aquesta resposta?",
|
||||
"HTML": "HTML",
|
||||
@@ -1103,19 +1111,19 @@
|
||||
"Landing Page Mode": "Mode de la pàgina d'entrada",
|
||||
"Language": "Idioma",
|
||||
"Language Locales": "Localització d'idiomes",
|
||||
"Last 24 hours": "",
|
||||
"Last 30 days": "",
|
||||
"Last 7 days": "",
|
||||
"Last 90 days": "",
|
||||
"Last 24 hours": "Darreres 24 hores",
|
||||
"Last 30 days": "Darrers 30 dies",
|
||||
"Last 7 days": "Darrers 7 dies",
|
||||
"Last 90 days": "Darrers 90 dies",
|
||||
"Last Active": "Activitat recent",
|
||||
"Last Modified": "Modificació",
|
||||
"Last reply": "Darrera resposta",
|
||||
"LDAP": "LDAP",
|
||||
"LDAP server updated": "Servidor LDAP actualitzat",
|
||||
"Leaderboard": "Tauler de classificació",
|
||||
"Learn more": "",
|
||||
"Learn more": "Aprèn-ne més",
|
||||
"Learn More": "Aprendre'n més",
|
||||
"Learn more about Open Terminal": "",
|
||||
"Learn more about Open Terminal": "Aprèn més sobre Open Terminal",
|
||||
"Learn more about OpenAPI tool servers.": "Aprèn més sobre els servidors d'eines OpenAPI.",
|
||||
"Learn more about Voxtral transcription.": "Aprèn més sobre la transcripció amb Voxtral.",
|
||||
"Leave a public review for {{modelName}}": "Deixa un comentari públic per a {{modelName}}",
|
||||
@@ -1257,7 +1265,8 @@
|
||||
"More options": "Més opcions",
|
||||
"More Options": "Més opcions",
|
||||
"Move": "Moure",
|
||||
"My Terminal": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "El meu terminal",
|
||||
"Name": "Nom",
|
||||
"Name and ID are required, please fill them out": "El nom i l'ID són necessaris, emplena'ls, si us plau",
|
||||
"Name your knowledge base": "Anomena la teva base de coneixement",
|
||||
@@ -1265,7 +1274,7 @@
|
||||
"New": "Nou",
|
||||
"New Button": "Botó nou",
|
||||
"New Chat": "Nou xat",
|
||||
"New File": "",
|
||||
"New File": "Nou arxiu",
|
||||
"New Folder": "Nova carpeta",
|
||||
"New Function": "Nova funció",
|
||||
"New Group": "Nou grup",
|
||||
@@ -1276,7 +1285,7 @@
|
||||
"New Prompt": "Nova indicació",
|
||||
"New Skill": "Nova habilitat",
|
||||
"New Temporary Chat": "Nou xat temporal",
|
||||
"New Terminal": "",
|
||||
"New Terminal": "Nou terminal",
|
||||
"New Tool": "Nova eina",
|
||||
"New Webhook": "Nou webhook",
|
||||
"new-channel": "nou-canal",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "No s'ha escollit cap fitxer",
|
||||
"No files found": "No s'han trobat arxius",
|
||||
"No files in this knowledge base.": "No hi ha arxius a la base de coneixement.",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "No s'han trobat funcions",
|
||||
"No groups found": "No s'han trobat grups",
|
||||
"No history available": "No hi ha historial disponible",
|
||||
"No HTML, CSS, or JavaScript content found.": "No s'ha trobat contingut HTML, CSS o JavaScript.",
|
||||
"No inference engine with management support found": "No s'ha trobat un motor d'inferència amb suport de gestió",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "No s'han trobat bases de coneixement.",
|
||||
"No knowledge found": "No s'ha trobat Coneixement",
|
||||
"No memories to clear": "No hi ha memòries per netejar",
|
||||
@@ -1319,13 +1330,14 @@
|
||||
"No results": "No s'han trobat resultats",
|
||||
"No results found": "No s'han trobat resultats",
|
||||
"No search query generated": "No s'ha generat cap consulta",
|
||||
"No servers detected": "",
|
||||
"No skills found": "No s'han trobat habilitats",
|
||||
"No source available": "Sense font disponible",
|
||||
"No sources found": "No s'han trobat fonts",
|
||||
"No suggestion prompts": "Cap prompt suggerit",
|
||||
"No Terminal connection configured.": "",
|
||||
"No terminal connections configured.": "",
|
||||
"No tool server connections configured.": "",
|
||||
"No Terminal connection configured.": "No hi ha cap configuració de terminal configurada.",
|
||||
"No terminal connections configured.": "No hi ha connexions de terminal configurades.",
|
||||
"No tool server connections configured.": "No hi ha connexions a servidors d'eines configurades.",
|
||||
"No tools found": "No s'han trobat eines",
|
||||
"No users were found.": "No s'han trobat usuaris",
|
||||
"No valves": "No hi ha valves",
|
||||
@@ -1384,7 +1396,7 @@
|
||||
"Open Model Selector": "Obrir el selector de models",
|
||||
"Open Settings": "Obrir les preferències",
|
||||
"Open Sidebar": "Obre la barra lateral",
|
||||
"Open Terminal": "",
|
||||
"Open Terminal": "Obrir el terminal",
|
||||
"Open User Profile Menu": "Obre el menú de perfil d'usuari",
|
||||
"Open WebUI can use tools provided by any OpenAPI server.": "Open WebUI pot utilitzar eines de servidors OpenAPI.",
|
||||
"Open WebUI uses faster-whisper internally.": "Open WebUI utilitza faster-whisper internament.",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "Selecciona com a mínim un usuari per al canal de missatge directe.",
|
||||
"Please wait until all files are uploaded.": "Si us plau, espera fins que s'hagin carregat tots els fitxers.",
|
||||
"Port": "Port",
|
||||
"Ports": "",
|
||||
"Positive attitude": "Actitud positiva",
|
||||
"Prefer not to say": "Prefereixo no dir-ho",
|
||||
"Prefix ID": "Identificador del prefix",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Obtenir \"{{searchValue}}\" de Ollama.com",
|
||||
"Pull a model from Ollama.com": "Obtenir un model d'Ollama.com",
|
||||
"Pull Model": "Obtenir un model",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "Indicació per a generació de consulta",
|
||||
"Querying": "Consultes",
|
||||
"Quick Actions": "Accions ràpides",
|
||||
@@ -1524,7 +1538,7 @@
|
||||
"Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative.": "Redueix la probabilitat de generar ximpleries. Un valor més alt (p. ex. 100) donarà respostes més diverses, mentre que un valor més baix (p. ex. 10) serà més conservador.",
|
||||
"Refer to yourself as \"User\" (e.g., \"User is learning Spanish\")": "Fes referència a tu mateix com a \"Usuari\" (p. ex., \"L'usuari està aprenent espanyol\")",
|
||||
"Reference Chats": "Xats de referència",
|
||||
"Refresh": "",
|
||||
"Refresh": "Refrescar",
|
||||
"Refused when it shouldn't have": "Refusat quan no hauria d'haver estat",
|
||||
"Regenerate": "Regenerar",
|
||||
"Regenerate Menu": "Regenerar el menú",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "Divisió de la resposta",
|
||||
"Response Watermark": "Marca d'aigua de la resposta",
|
||||
"Responses": "Respostes",
|
||||
"Restart": "",
|
||||
"Result": "Resultat",
|
||||
"RESULT": "Resultat",
|
||||
"Retrieval": "Retrieval",
|
||||
@@ -1583,6 +1598,7 @@
|
||||
"Role": "Rol",
|
||||
"RTL": "RTL",
|
||||
"Run": "Executar",
|
||||
"Run All": "",
|
||||
"Running": "S'està executant",
|
||||
"Running...": "S'està executant...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Executa tasques d'incrustació simultàniament per accelerar el processament. Desactiva-ho si els límits de velocitat es converteixen en un problema.",
|
||||
@@ -1718,7 +1734,7 @@
|
||||
"Show": "Mostrar",
|
||||
"Show \"What's New\" modal on login": "Veure 'Què hi ha de nou' a l'entrada",
|
||||
"Show Admin Details in Account Pending Overlay": "Mostrar els detalls de l'administrador a la superposició del compte pendent",
|
||||
"Show All": "",
|
||||
"Show All": "Mostrar tot",
|
||||
"Show all ({{COUNT}} characters)": "Mostra tot ({{COUNT}} caràcters",
|
||||
"Show Files": "Mostra els arxius",
|
||||
"Show Formatting Toolbar": "Mostrar la barra de format",
|
||||
@@ -1773,6 +1789,7 @@
|
||||
"Start a new conversation": "Iniciar una nova conversa",
|
||||
"Start of the channel": "Inici del canal",
|
||||
"Start Tag": "Etiqueta d'inici",
|
||||
"Starting kernel...": "",
|
||||
"Status": "Estat",
|
||||
"Status cleared successfully": "S'ha eliminat correctament el teu estat",
|
||||
"Status updated successfully": "S'ha actualitzat correctament el teu estat",
|
||||
@@ -1830,8 +1847,8 @@
|
||||
"Temperature": "Temperatura",
|
||||
"Temporary Chat": "Xat temporal",
|
||||
"Temporary Chat by Default": "Xat temporal per defecte",
|
||||
"Terminal": "",
|
||||
"Terminal servers saved": "",
|
||||
"Terminal": "Terminal",
|
||||
"Terminal servers saved": "Servidors de terminal desats",
|
||||
"Text Splitter": "Separador de text",
|
||||
"Text-to-Speech": "Text-a-veu",
|
||||
"Text-to-Speech Engine": "Motor de text a veu",
|
||||
@@ -1865,7 +1882,7 @@
|
||||
"This ensures that your valuable conversations are securely saved to your backend database. Thank you!": "Això assegura que les teves converses valuoses queden desades de manera segura a la teva base de dades. Gràcies!",
|
||||
"This feature is currently experimental and may not work as expected.": "Aquesta funció és actualment experimental i és possible que no funcioni com s'esperava.",
|
||||
"This feature is experimental and may be modified or discontinued without notice.": "Aquesta funció és experimental i es pot modificar o deixar de ser disponible sense previ avís.",
|
||||
"This folder is empty": "",
|
||||
"This folder is empty": "Aquesta carpeta està buida",
|
||||
"This is a default user permission and will remain enabled.": "Aquest és un permís d'usuari per defecte i romandrà habilitat.",
|
||||
"This is an experimental feature, it may not function as expected and is subject to change at any time.": "Aquesta és una funció experimental, és possible que no funcioni com s'espera i està subjecta a canvis en qualsevol moment.",
|
||||
"This model is not publicly available. Please select another model.": "Aquest model no està disponible públicament. Seleccioneu-ne un altre.",
|
||||
@@ -1907,15 +1924,15 @@
|
||||
"Toast notifications for new updates": "Notificacions Toast de noves actualitzacions",
|
||||
"Today": "Avui",
|
||||
"Today at {{LOCALIZED_TIME}}": "Avui a les {{LOCALIZED_TIME}}",
|
||||
"Toggle {{COUNT}} sources": "",
|
||||
"Toggle 1 source": "",
|
||||
"Toggle Dictation": "",
|
||||
"Toggle Sidebar": "Alterna la barra lateral",
|
||||
"Toggle status history": "",
|
||||
"Toggle {{COUNT}} sources": "Activa/Desactiva {{COUNT}} fonts",
|
||||
"Toggle 1 source": "Activa/Desactiva 1 font",
|
||||
"Toggle Dictation": "Activa/Desactiva el dictat",
|
||||
"Toggle Sidebar": "Activa/Desactiva la barra lateral",
|
||||
"Toggle status history": "Activa/Desactiva l'estat de l'històric",
|
||||
"Toggle whether current connection is active.": "Alterna si la connexió actual està activa.",
|
||||
"Token": "Token",
|
||||
"Token counts are estimates and may not reflect actual API usage": "El nombre de tokens és estimat i pot no reflectir l'ús real de l'API.",
|
||||
"tokens": "",
|
||||
"tokens": "tokens",
|
||||
"Tokens": "Tokens",
|
||||
"Too verbose": "Massa explicit",
|
||||
"Tool created successfully": "Eina creada correctament",
|
||||
@@ -1981,12 +1998,12 @@
|
||||
"Upload Files": "Pujar fitxers",
|
||||
"Upload Model": "Pujar model",
|
||||
"Upload Pipeline": "Pujar una Pipeline",
|
||||
"Upload profile image": "",
|
||||
"Upload profile image": "Pujar imatge de perfil",
|
||||
"Upload Progress": "Progrés de càrrega",
|
||||
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "Progrés de la pujada: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
|
||||
"Uploaded files or images": "Arxius o imatges pujats",
|
||||
"Uploading file...": "Pujant l'arxiu...",
|
||||
"Uploading...": "",
|
||||
"Uploading...": "Pujant...",
|
||||
"URL": "URL",
|
||||
"URL is required": "La URL és necessaria",
|
||||
"URL Mode": "Mode URL",
|
||||
@@ -1995,7 +2012,7 @@
|
||||
"Use '#' in the prompt input to load and include your knowledge.": "Utilitza '#' a l'entrada de la indicació per carregar i incloure els teus coneixements.",
|
||||
"Use /v1/chat/completions endpoint instead of /v1/audio/transcriptions for potentially better accuracy.": "Fes servir l'endpoint /v1/chat/completions en comptes de /v1/audio/transcriptions per a una precisió potencialment millor.",
|
||||
"Use Chat Completions API": "Utilitza l'API de completació de xat",
|
||||
"Use groups to organize your users and assign permissions.": "",
|
||||
"Use groups to organize your users and assign permissions.": "Utilitza grups per organitzar els usuaris i assignar permisos.",
|
||||
"Use LLM": "Utilizar model de llenguatge",
|
||||
"Use no proxy to fetch page contents.": "No utilitzis un proxy per obtenir contingut de la pàgina.",
|
||||
"Use proxy designated by http_proxy and https_proxy environment variables to fetch page contents.": "Utilitza el proxy designat per les variables d'entorn http_proxy i https_proxy per obtenir el contingut de la pàgina.",
|
||||
@@ -2029,8 +2046,8 @@
|
||||
"Version deleted": "Versió eliminada",
|
||||
"View Replies": "Veure les respostes",
|
||||
"View Result from **{{NAME}}**": "Veure el resultat de **{{NAME}}**",
|
||||
"View source: {{name}}": "",
|
||||
"View source: {{title}}": "",
|
||||
"View source: {{name}}": "Veure font: {{name}}",
|
||||
"View source: {{title}}": "Veure font: {{title}}",
|
||||
"Visibility": "Visibilitat",
|
||||
"Visible": "Visible",
|
||||
"Visible to all users": "Visible per a tots els usuaris",
|
||||
@@ -2040,7 +2057,7 @@
|
||||
"Voice mode": "Mode de veu",
|
||||
"Voice Mode Custom Prompt": "Indicació personalitzada per al mode de veu",
|
||||
"Voice Mode Prompt": "Indicació per al mode de veu",
|
||||
"Waiting for upload...": "",
|
||||
"Waiting for upload...": "Esperant per pujar...",
|
||||
"Warning": "Avís",
|
||||
"Warning:": "Avís:",
|
||||
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Avís: Habilitar això permetrà als usuaris penjar codi arbitrari al servidor.",
|
||||
@@ -2108,15 +2125,17 @@
|
||||
"You do not have permission to send messages in this thread.": "No tens permís per enviar missatges en aquest fil.",
|
||||
"You do not have permission to upload files to this knowledge base.": "No tens permís per carregar fitxers a aquesta base de coneixements.",
|
||||
"You do not have permission to upload files.": "No tens permisos per pujar arxius.",
|
||||
"You do not have permission to upload web content.": "",
|
||||
"You do not have permission to upload web content.": "No tens permisos per pujar contingut web",
|
||||
"You have no archived conversations.": "No tens converses arxivades.",
|
||||
"You have no shared conversations.": "No has compartit cap conversa.",
|
||||
"You have shared this chat": "Has compartit aquest xat",
|
||||
"You.com API Key": "",
|
||||
"You.com API Key": "Clau API de You.com",
|
||||
"You're a helpful assistant.": "Ets un assistent útil.",
|
||||
"You're now logged in.": "Ara estàs connectat.",
|
||||
"Your Account": "El teu compte",
|
||||
"Your account status is currently pending activation.": "El compte està actualment pendent d'activació",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "Tota la teva contribució anirà directament al desenvolupador del complement; Open WebUI no se'n queda cap percentatge. Tanmateix, la plataforma de finançament escollida pot tenir les seves pròpies comissions.",
|
||||
"Your message text or inputs": "El text o les entrades del teu missatge",
|
||||
"Your usage stats have been successfully synced.": "Les teves estadístiques d'ús s'han sincronitzat correctament.",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "Panaghisgot",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Default nga Papel sa Gumagamit",
|
||||
"Defaults": "",
|
||||
"Delete": "",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Pagtangtang sa usa ka template",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "p. ",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Napakyas sa pagbasa sa sulod sa clipboard",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Ngalan",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Walay resulta",
|
||||
"No results found": "",
|
||||
"No search query generated": "",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "Walay tinubdan nga anaa",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "",
|
||||
"Pull a model from Ollama.com": "Pagkuha ug template gikan sa Ollama.com",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "RESULTA",
|
||||
"Retrieval": "",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "Papel",
|
||||
"RTL": "",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "",
|
||||
"Running...": "Nagdagan...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "Sinugdan sa channel",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "Konektado ka na karon.",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "Opravdu chcete smazat tento kanál?",
|
||||
"Are you sure you want to delete this message?": "Opravdu chcete smazat tuto zprávu?",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "Opravdu chcete zrušit archivaci všech archivovaných konverzací?",
|
||||
"Arena Models": "Modely pro arénu",
|
||||
"Artifacts": "Artefakty",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "Limit znaků pro vstup generování automatického dokončování",
|
||||
"Chart new frontiers": "Objevujte nové hranice",
|
||||
"Chat": "Konverzace",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "Obrázek pozadí konverzace",
|
||||
"Chat Bubble UI": "Uživatelské rozhraní s bublinami konverzace",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Výchozí role uživatele",
|
||||
"Defaults": "",
|
||||
"Delete": "Smazat",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Smazat model",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "Smazat všechny konverzace",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "Kreslit",
|
||||
"Drop any files here to upload": "Přetáhněte sem soubory pro nahrání",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "např. '30s','10m'. Platné časové jednotky jsou 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "Efekt prolínání pro streamovaný text",
|
||||
"Failed to add file.": "Nepodařilo se přidat soubor.",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "Nepodařilo se připojit k serveru nástrojů OpenAPI {{URL}}",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "Nepodařilo se vygenerovat název",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "Nepodařilo se načíst náhled konverzace",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "Nepodařilo se načíst obsah souboru.",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Nepodařilo se přečíst obsah schránky",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "Složka",
|
||||
"Folder Background Image": "Obrázek na pozadí složky",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "Složka byla úspěšně smazána",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "Další možnosti",
|
||||
"Move": "Přesunout",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Jméno",
|
||||
"Name and ID are required, please fill them out": "Jméno a ID jsou povinné, prosím vyplňte je",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "Nebyl vybrán žádný soubor",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "Žádné funkce nenalezeny",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "Nebyl nalezen žádný obsah HTML, CSS ani JavaScriptu.",
|
||||
"No inference engine with management support found": "Nebyl nalezeno žádné inferenční jádro s podporou správy",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "Nebyly nalezeny žádné znalosti",
|
||||
"No memories to clear": "Žádné vzpomínky k vymazání",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Nebyly nalezeny žádné výsledky",
|
||||
"No results found": "Nebyly nalezeny žádné výsledky",
|
||||
"No search query generated": "Nebyl vygenerován žádný vyhledávací dotaz.",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "Není k dispozici žádný zdroj.",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "Prosím počkejte dokud nebudou všechny soubory nahrány.",
|
||||
"Port": "Port",
|
||||
"Ports": "",
|
||||
"Positive attitude": "Pozitivní přístup",
|
||||
"Prefer not to say": "Raději nevyberu",
|
||||
"Prefix ID": "Prefix ID",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Stáhnout \"{{searchValue}}\" z Ollama.com",
|
||||
"Pull a model from Ollama.com": "Stáhnout model z Ollama.com",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "Instrukce pro generování dotazu",
|
||||
"Querying": "",
|
||||
"Quick Actions": "Rychlé akce",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "Dělení odpovědi",
|
||||
"Response Watermark": "Vodoznak odpovědi",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "Výsledek",
|
||||
"RESULT": "Výsledek",
|
||||
"Retrieval": "Vyhledávání",
|
||||
@@ -1584,6 +1599,7 @@
|
||||
"Role": "Role",
|
||||
"RTL": "RTL",
|
||||
"Run": "Spustit",
|
||||
"Run All": "",
|
||||
"Running": "Běží",
|
||||
"Running...": "Běží...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1774,6 +1790,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "Začátek kanálu",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2118,6 +2135,8 @@
|
||||
"You're now logged in.": "Nyní jste přihlášeni.",
|
||||
"Your Account": "Váš účet",
|
||||
"Your account status is currently pending activation.": "Stav vašeho účtu aktuálně čeká na aktivaci.",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "Celý váš příspěvek půjde přímo vývojáři pluginu; Open WebUI si nebere žádné procento. Zvolená platforma pro financování však může mít vlastní poplatky.",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "Er du sikker på du vil slette denne kanal?",
|
||||
"Are you sure you want to delete this message?": "Er du sikker på du vil slette denne besked?",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "Er du sikker på du vil fjerne alle arkiverede chats?",
|
||||
"Arena Models": "Arena Modeller",
|
||||
"Artifacts": "Artifakter",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "Karaktergrænse for genereret autofuldførsel",
|
||||
"Chart new frontiers": "Udforsk nye grænser",
|
||||
"Chat": "Chat",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "Chat baggrundsbillede",
|
||||
"Chat Bubble UI": "Chat Bubble UI",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Brugers rolle som standard",
|
||||
"Defaults": "",
|
||||
"Delete": "Slet",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Slet en model",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "Slet alle chats",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "Tegn",
|
||||
"Drop any files here to upload": "Drop nogen filer her for at uploade",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "f.eks. '30s', '10m'. Tilladte værdier er 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "Fade-effekt for streaming tekst",
|
||||
"Failed to add file.": "Kunne ikke tilføje fil.",
|
||||
"Failed to add members": "Kunne ikke tilføje medlemmer",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "Kunne ikke fjerne status",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "Kunne ikke forbinde til {{URL}} OpenAPI tool server",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "Kunne ikke generere titel",
|
||||
"Failed to import models": "Kunne ikke importere modeller",
|
||||
"Failed to load chat preview": "Kunne ikke indlæse chat forhåndsvisning",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "Kunne ikke indlæse filindhold.",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "Kunne ikke flytte chat",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Kunne ikke læse indholdet af udklipsholderen",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "Fokus chat-input",
|
||||
"Folder": "Mappe",
|
||||
"Folder Background Image": "Mappe baggrundsbillede",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "Mappe fjernet.",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "Flere muligheder",
|
||||
"Move": "Flyt",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Navn",
|
||||
"Name and ID are required, please fill them out": "Navn og ID er påkrævet, venligst udfyld dem",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "Ingen fil valgt",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "Ingen funktioner fundet",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "Intet HTML-, CSS- eller JavaScript-indhold fundet.",
|
||||
"No inference engine with management support found": "Ingen inference-engine med støtte til administration fundet",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "Ingen viden fundet",
|
||||
"No memories to clear": "Ingen hukommelser at ryde",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Ingen resultater fundet",
|
||||
"No results found": "Ingen resultater fundet",
|
||||
"No search query generated": "Ingen søgeforespørgsel genereret",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "Ingen kilde tilgængelig",
|
||||
"No sources found": "Ingen kilder fundet",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "Vælg mindst én bruger til direkte besked-kanal.",
|
||||
"Please wait until all files are uploaded.": "Vent venligst indtil alle filerne er uploadet.",
|
||||
"Port": "Port",
|
||||
"Ports": "",
|
||||
"Positive attitude": "Positiv holdning",
|
||||
"Prefer not to say": "Foretrækker ikke at oplyse",
|
||||
"Prefix ID": "Prefix ID",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Hent \"{{searchValue}}\" fra Ollama.com",
|
||||
"Pull a model from Ollama.com": "Hent en model fra Ollama.com",
|
||||
"Pull Model": "Hent model",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "Forespørgsel genererings prompt",
|
||||
"Querying": "Undersøger",
|
||||
"Quick Actions": "Hurtig-handlinger",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "Svaropdeling",
|
||||
"Response Watermark": "Vandmærke på svar",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "Resultat",
|
||||
"RESULT": "Resultat",
|
||||
"Retrieval": "Hentning",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "Rolle",
|
||||
"RTL": "RTL",
|
||||
"Run": "Kør",
|
||||
"Run All": "",
|
||||
"Running": "Kører",
|
||||
"Running...": "Kører...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Kører embedding-opgaver sideløbende for at fremskynde behandlingen. Slå fra hvis hastighedsbegrænsninger bliver et problem.",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "Start en ny samtale",
|
||||
"Start of the channel": "Kanalens start",
|
||||
"Start Tag": "Start tag",
|
||||
"Starting kernel...": "",
|
||||
"Status": "Status",
|
||||
"Status cleared successfully": "Status slettet",
|
||||
"Status updated successfully": "Status opdateret",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "Du er nu logget ind.",
|
||||
"Your Account": "Din bruger",
|
||||
"Your account status is currently pending activation.": "Din kontostatus afventer i øjeblikket aktivering.",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "Hele dit bidrag går direkte til plugin-udvikleren; Open WebUI tager ikke nogen procentdel. Den valgte finansieringsplatform kan dog have sine egne gebyrer.",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "Sind Sie sicher, dass Sie diesen Kanal löschen möchten?",
|
||||
"Are you sure you want to delete this message?": "Sind Sie sicher, dass Sie diese Nachricht löschen möchten?",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "Sind Sie sicher, dass Sie diese Version löschen wollen? Child-Versionen werden zu dem Parent dieser Version verlinkt.",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "Sind Sie sicher, dass Sie alle archivierten Chats wiederherstellen möchten?",
|
||||
"Arena Models": "Arena-Modelle",
|
||||
"Artifacts": "Artefakte",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "Zeichenlimit für Eingabe der Autovervollständigung",
|
||||
"Chart new frontiers": "Neue Grenzen erkunden",
|
||||
"Chat": "Chat",
|
||||
"Chat archived.": "Chat archiviert.",
|
||||
"Chat Background Image": "Hintergrundbild des Chats",
|
||||
"Chat Bubble UI": "Sprechblasen-Ansicht",
|
||||
"Chat Completions": "Chat Completions",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Standard-Benutzerrolle",
|
||||
"Defaults": "Standardwerte",
|
||||
"Delete": "Löschen",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Ein Modell löschen",
|
||||
"Delete All": "Alle löschen",
|
||||
"Delete All Chats": "Alle Chats löschen",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "Statistik wird heruntergeladen...",
|
||||
"Draw": "Zeichnen",
|
||||
"Drop any files here to upload": "Dateien zum Hochladen hier ablegen",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "Dateien zum Hochladen hier fallen lassen",
|
||||
"DuckDuckGo": "DuckDuckGo",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "z. B. '30s', '10m'. Gültig: 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "Fade-Effekt für Text-Streaming",
|
||||
"Failed to add file.": "Hinzufügen der Datei fehlgeschlagen.",
|
||||
"Failed to add members": "Mitglieder konnten nicht hinzugefügt werden",
|
||||
"Failed to archive chat.": "Chat konnte nicht archiviert werden.",
|
||||
"Failed to attach file": "Datei konnte nicht hinzugefügt werden",
|
||||
"Failed to clear status": "Status konnte nicht geleert werden",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "Verbindung zum OpenAPI-Toolserver {{URL}} fehlgeschlagen",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "Titel konnte nicht generiert werden",
|
||||
"Failed to import models": "Modelle konnten nicht importiert werden",
|
||||
"Failed to load chat preview": "Chat-Vorschau konnte nicht geladen werden",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "DOCX Datei konnte nicht geladen werden. Versuche die Datei stattdessen herunterzuladen.",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "Excel/CSV-Datei konnte nicht geladen werden. Bitte versuchen Sie stattdessen, sie herunterzuladen.",
|
||||
"Failed to load file content.": "Dateiinhalt konnte nicht geladen werden.",
|
||||
"Failed to load Interface settings": "Interface Einstellungen konnten nicht geladen werden",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "PPTX Datei konnte nicht geladen werden. Versuche die Datei stattdessen herunterzuladen.",
|
||||
"Failed to move chat": "Chat konnte nicht verschoben werden",
|
||||
"Failed to process URL: {{url}}": "{{url}} konnte nicht verarbeitet werden",
|
||||
"Failed to read clipboard contents": "Zwischenablage konnte nicht gelesen werden",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "Chat-Eingabe fokussieren",
|
||||
"Folder": "Ordner",
|
||||
"Folder Background Image": "Ordner-Hintergrundbild",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "Ordner erfolgreich gelöscht",
|
||||
"Folder Max File Count": "Maximale Dateianzahl pro Ordner",
|
||||
"Folder name": "Ordnername",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "Mehr Optionen",
|
||||
"More Options": "Mehr Optionen",
|
||||
"Move": "Verschieben",
|
||||
"Moved {{name}}": "{{name}} verschoben",
|
||||
"My Terminal": "Meine Terminals",
|
||||
"Name": "Name",
|
||||
"Name and ID are required, please fill them out": "Name und ID sind erforderlich, bitte füllen Sie diese aus",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "Keine Datei ausgewählt",
|
||||
"No files found": "Keine Dateien gefunden",
|
||||
"No files in this knowledge base.": "Keine Dateien in diesem Wissensspeicher",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "Keine Funktionen gefunden",
|
||||
"No groups found": "Keine Gruppen gefunden",
|
||||
"No history available": "Keine History verfügbar",
|
||||
"No HTML, CSS, or JavaScript content found.": "Keine HTML-, CSS- oder JavaScript-Inhalte gefunden.",
|
||||
"No inference engine with management support found": "Keine Inferenz-Engine mit Verwaltungsunterstützung gefunden",
|
||||
"No kernel": "Kein Kernel",
|
||||
"No knowledge bases found.": "Keine Wissensspeicher gefunden.",
|
||||
"No knowledge found": "Kein Wissen gefunden",
|
||||
"No memories to clear": "Keine Erinnerungen zum Löschen",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Keine Ergebnisse",
|
||||
"No results found": "Keine Ergebnisse gefunden",
|
||||
"No search query generated": "Keine Suchanfrage generiert",
|
||||
"No servers detected": "Keine Server erkannt",
|
||||
"No skills found": "Keine Skills gefunden",
|
||||
"No source available": "Keine Quelle verfügbar",
|
||||
"No sources found": "Keine Quellen gefunden",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "Bitte wählen Sie mindestens einen Benutzer für den Direktnachrichten-Kanal aus.",
|
||||
"Please wait until all files are uploaded.": "Bitte warten Sie, bis alle Dateien hochgeladen sind.",
|
||||
"Port": "Port",
|
||||
"Ports": "Ports",
|
||||
"Positive attitude": "Positive Einstellung",
|
||||
"Prefer not to say": "Keine Angabe",
|
||||
"Prefix ID": "Präfix-ID",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "\"{{searchValue}}\" von Ollama.com laden",
|
||||
"Pull a model from Ollama.com": "Ein Modell von Ollama.com laden",
|
||||
"Pull Model": "Modell laden",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "Abfrage-Generierungs-Prompt",
|
||||
"Querying": "Frage ab...",
|
||||
"Quick Actions": "Schnellaktionen",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "Antwortaufteilung",
|
||||
"Response Watermark": "Antwort-Wasserzeichen",
|
||||
"Responses": "Responses",
|
||||
"Restart": "Neustarten",
|
||||
"Result": "Ergebnis",
|
||||
"RESULT": "ERGEBNIS",
|
||||
"Retrieval": "Abruf",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "Rolle",
|
||||
"RTL": "RTL",
|
||||
"Run": "Ausführen",
|
||||
"Run All": "Alle starten",
|
||||
"Running": "Läuft",
|
||||
"Running...": "Läuft...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Führt Embedding-Aufgaben parallel aus, um die Verarbeitung zu beschleunigen. Deaktivieren Sie dies, falls Rate-Limits oder Ressourcenprobleme auftreten.",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "Neue Unterhaltung beginnen",
|
||||
"Start of the channel": "Beginn des Kanals",
|
||||
"Start Tag": "Start-Tag",
|
||||
"Starting kernel...": "Kernel starten...",
|
||||
"Status": "Status",
|
||||
"Status cleared successfully": "Status erfolgreich gelöscht",
|
||||
"Status updated successfully": "Status erfolgreich aktualisiert",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "Sie sind jetzt angemeldet.",
|
||||
"Your Account": "Ihr Konto",
|
||||
"Your account status is currently pending activation.": "Ihr Kontostatus wartet derzeit auf Aktivierung.",
|
||||
"Your browser does not support the audio tag.": "Dein Browser unterstützt den Audio tag nicht.",
|
||||
"Your browser does not support the video tag.": "Dein Browser unterstützt den Video tag nicht.",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "Ihr gesamter Beitrag geht direkt an den Plugin-Entwickler; Open WebUI behält keinen Anteil ein. Die gewählte Plattform kann jedoch eigene Gebühren erheben.",
|
||||
"Your message text or inputs": "Ihre Nachrichtentexte oder Eingaben",
|
||||
"Your usage stats have been successfully synced.": "Ihre Nutzungsstatistiken wurden erfolgreich synchronisiert.",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "Chat",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Default User Role",
|
||||
"Defaults": "",
|
||||
"Delete": "",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Delete a model",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "e.g. '30s','10m'. Much time units are 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Failed to read clipboard borks",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Name",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "No results, very empty",
|
||||
"No results found": "",
|
||||
"No search query generated": "",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "No source available",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "",
|
||||
"Pull a model from Ollama.com": "Pull a wowdel from Ollama.com",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "RESULT much",
|
||||
"Retrieval": "",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "Role",
|
||||
"RTL": "",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "",
|
||||
"Running...": "Running... wow",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "Start of channel",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "You're now logged in. Much logged.",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το κανάλι;",
|
||||
"Are you sure you want to delete this message?": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το μήνυμα;",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "Είστε σίγουροι ότι θέλετε να απο-αρχειοθετήσετε όλες τις αρχειοθετημένες συνομιλίες;",
|
||||
"Arena Models": "Μοντέλα Arena",
|
||||
"Artifacts": "Αρχεία",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "Όριο χαρακτήρων για αυτόματη συμπλήρωση εισαγωγής",
|
||||
"Chart new frontiers": "Σχεδιάστε νέους ορίζοντες",
|
||||
"Chat": "Συνομιλία",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "Εικόνα Φόντου Συνομιλίας",
|
||||
"Chat Bubble UI": "Διεπαφή Φούσκας Συνομιλίας",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "Προεπιλεγμένος Ρόλος Χρήστη",
|
||||
"Defaults": "",
|
||||
"Delete": "Διαγραφή",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "Διαγραφή ενός μοντέλου",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "Διαγραφή Όλων των Συνομιλιών",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "Σχεδίαση",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "π.χ. '30s','10m'. Οι έγκυρες μονάδες χρόνου είναι 's', 'm', 'h'.",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "Αποτυχία προσθήκης αρχείου.",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "Αποτυχία σύνδεσης στο διακομιστή εργαλείων OpenAPI {{URL}}",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "Αποτυχία δημιουργίας τίτλου",
|
||||
"Failed to import models": "Αποτυχία εισαγωγής μοντέλων",
|
||||
"Failed to load chat preview": "Αποτυχία φόρτωσης προεπισκόπησης συνομιλίας",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "Αποτυχία φόρτωσης περιεχομένου αρχείου",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "Αποτυχία μετακίνησης συνομιλίας",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "Αποτυχία ανάγνωσης περιεχομένων πρόχειρου",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "Φάκελος",
|
||||
"Folder Background Image": "Εικόνα Φόντου Φακέλου",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "Ο φάκελος διαγράφηκε με επιτυχία",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "Μετακίνηση",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "Όνομα",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "Δεν έχει επιλεγεί αρχείο",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "Δεν βρέθηκαν λειτουργίες",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "Δεν βρέθηκε περιεχόμενο HTML, CSS ή JavaScript.",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "Δεν βρέθηκε Knowledge",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"No results found": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"No search query generated": "Δεν δημιουργήθηκε ερώτηση αναζήτησης",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "Δεν υπάρχει διαθέσιμη πηγή",
|
||||
"No sources found": "Δεν βρέθηκαν πηγές",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "Θύρα",
|
||||
"Ports": "",
|
||||
"Positive attitude": "Θετική στάση",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "ID Προθέματος",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "Τραβήξτε \"{{searchValue}}\" από το Ollama.com",
|
||||
"Pull a model from Ollama.com": "Τραβήξτε ένα μοντέλο από το Ollama.com",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "Προτροπή Δημιουργίας Ερωτήσεων",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "Διαχωρισμός απάντησης",
|
||||
"Response Watermark": "Υδρογράφημα Απάντησης",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "Αποτέλεσμα",
|
||||
"RESULT": "Αποτέλεσμα",
|
||||
"Retrieval": "Ανάκτηση",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "Ρόλος",
|
||||
"RTL": "RTL",
|
||||
"Run": "Εκτέλεση",
|
||||
"Run All": "",
|
||||
"Running": "Εκτέλεση",
|
||||
"Running...": "Εκτέλεση...",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "Αρχή του καναλιού",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "Τώρα είστε συνδεδεμένοι.",
|
||||
"Your Account": "Ο λογαριασμός σας",
|
||||
"Your account status is currently pending activation.": "Η κατάσταση του λογαριασμού σας είναι αυτή τη στιγμή σε εκκρεμότητα ενεργοποίησης.",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "Η ολόκληρη η συνεισφορά σας θα πάει απευθείας στον προγραμματιστή του plugin· το Open WebUI δεν παίρνει κανένα ποσοστό. Ωστόσο, η επιλεγμένη πλατφόρμα χρηματοδότησης μπορεί να έχει τα δικά της τέλη.",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "",
|
||||
"Defaults": "",
|
||||
"Delete": "",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "",
|
||||
"No results found": "",
|
||||
"No search query generated": "",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "",
|
||||
"Pull a model from Ollama.com": "",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "",
|
||||
"Retrieval": "",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "",
|
||||
"RTL": "",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "",
|
||||
"Running...": "",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
@@ -183,6 +183,7 @@
|
||||
"Are you sure you want to delete this channel?": "",
|
||||
"Are you sure you want to delete this message?": "",
|
||||
"Are you sure you want to delete this version? Child versions will be relinked to this version's parent.": "",
|
||||
"Are you sure you want to delete this?": "",
|
||||
"Are you sure you want to unarchive all archived chats?": "",
|
||||
"Arena Models": "",
|
||||
"Artifacts": "",
|
||||
@@ -282,6 +283,7 @@
|
||||
"Character limit for autocomplete generation input": "",
|
||||
"Chart new frontiers": "",
|
||||
"Chat": "",
|
||||
"Chat archived.": "",
|
||||
"Chat Background Image": "",
|
||||
"Chat Bubble UI": "",
|
||||
"Chat Completions": "",
|
||||
@@ -485,6 +487,7 @@
|
||||
"Default User Role": "",
|
||||
"Defaults": "",
|
||||
"Delete": "",
|
||||
"Delete {{name}}": "",
|
||||
"Delete a model": "",
|
||||
"Delete All": "",
|
||||
"Delete All Chats": "",
|
||||
@@ -577,6 +580,7 @@
|
||||
"Downloading stats...": "",
|
||||
"Draw": "",
|
||||
"Drop any files here to upload": "",
|
||||
"Drop files here": "",
|
||||
"Drop files here to upload": "",
|
||||
"DuckDuckGo": "",
|
||||
"e.g. '30s','10m'. Valid time units are 's', 'm', 'h'.": "",
|
||||
@@ -822,6 +826,7 @@
|
||||
"Fade Effect for Streaming Text": "",
|
||||
"Failed to add file.": "",
|
||||
"Failed to add members": "",
|
||||
"Failed to archive chat.": "",
|
||||
"Failed to attach file": "",
|
||||
"Failed to clear status": "",
|
||||
"Failed to connect to {{URL}} OpenAPI tool server": "",
|
||||
@@ -836,9 +841,11 @@
|
||||
"Failed to generate title": "",
|
||||
"Failed to import models": "",
|
||||
"Failed to load chat preview": "",
|
||||
"Failed to load DOCX file. Please try downloading it instead.": "",
|
||||
"Failed to load Excel/CSV file. Please try downloading it instead.": "",
|
||||
"Failed to load file content.": "",
|
||||
"Failed to load Interface settings": "",
|
||||
"Failed to load PPTX file. Please try downloading it instead.": "",
|
||||
"Failed to move chat": "",
|
||||
"Failed to process URL: {{url}}": "",
|
||||
"Failed to read clipboard contents": "",
|
||||
@@ -893,6 +900,7 @@
|
||||
"Focus Chat Input": "",
|
||||
"Folder": "",
|
||||
"Folder Background Image": "",
|
||||
"Folder created successfully": "",
|
||||
"Folder deleted successfully": "",
|
||||
"Folder Max File Count": "",
|
||||
"Folder name": "",
|
||||
@@ -1257,6 +1265,7 @@
|
||||
"More options": "",
|
||||
"More Options": "",
|
||||
"Move": "",
|
||||
"Moved {{name}}": "",
|
||||
"My Terminal": "",
|
||||
"Name": "",
|
||||
"Name and ID are required, please fill them out": "",
|
||||
@@ -1299,11 +1308,13 @@
|
||||
"No file selected": "",
|
||||
"No files found": "",
|
||||
"No files in this knowledge base.": "",
|
||||
"No files yet. Upload files or run Python code to create them.": "",
|
||||
"No functions found": "",
|
||||
"No groups found": "",
|
||||
"No history available": "",
|
||||
"No HTML, CSS, or JavaScript content found.": "",
|
||||
"No inference engine with management support found": "",
|
||||
"No kernel": "",
|
||||
"No knowledge bases found.": "",
|
||||
"No knowledge found": "",
|
||||
"No memories to clear": "",
|
||||
@@ -1319,6 +1330,7 @@
|
||||
"No results": "",
|
||||
"No results found": "",
|
||||
"No search query generated": "",
|
||||
"No servers detected": "",
|
||||
"No skills found": "",
|
||||
"No source available": "",
|
||||
"No sources found": "",
|
||||
@@ -1474,6 +1486,7 @@
|
||||
"Please select at least one user for Direct Message channel.": "",
|
||||
"Please wait until all files are uploaded.": "",
|
||||
"Port": "",
|
||||
"Ports": "",
|
||||
"Positive attitude": "",
|
||||
"Prefer not to say": "",
|
||||
"Prefix ID": "",
|
||||
@@ -1503,6 +1516,7 @@
|
||||
"Pull \"{{searchValue}}\" from Ollama.com": "",
|
||||
"Pull a model from Ollama.com": "",
|
||||
"Pull Model": "",
|
||||
"Pyodide file browser": "",
|
||||
"Query Generation Prompt": "",
|
||||
"Querying": "",
|
||||
"Quick Actions": "",
|
||||
@@ -1570,6 +1584,7 @@
|
||||
"Response splitting": "",
|
||||
"Response Watermark": "",
|
||||
"Responses": "",
|
||||
"Restart": "",
|
||||
"Result": "",
|
||||
"RESULT": "",
|
||||
"Retrieval": "",
|
||||
@@ -1582,6 +1597,7 @@
|
||||
"Role": "",
|
||||
"RTL": "",
|
||||
"Run": "",
|
||||
"Run All": "",
|
||||
"Running": "",
|
||||
"Running...": "",
|
||||
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
|
||||
@@ -1772,6 +1788,7 @@
|
||||
"Start a new conversation": "",
|
||||
"Start of the channel": "",
|
||||
"Start Tag": "",
|
||||
"Starting kernel...": "",
|
||||
"Status": "",
|
||||
"Status cleared successfully": "",
|
||||
"Status updated successfully": "",
|
||||
@@ -2116,6 +2133,8 @@
|
||||
"You're now logged in.": "",
|
||||
"Your Account": "",
|
||||
"Your account status is currently pending activation.": "",
|
||||
"Your browser does not support the audio tag.": "",
|
||||
"Your browser does not support the video tag.": "",
|
||||
"Your entire contribution will go directly to the plugin developer; Open WebUI does not take any percentage. However, the chosen funding platform might have its own fees.": "",
|
||||
"Your message text or inputs": "",
|
||||
"Your usage stats have been successfully synced.": "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user