mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-06 08:18:53 +00:00
Merge branch 'open-webui:dev' into dev
This commit is contained in:
commit
fe6a2d3064
221 changed files with 18492 additions and 7219 deletions
9
.github/workflows/format-backend.yaml
vendored
9
.github/workflows/format-backend.yaml
vendored
|
|
@ -40,10 +40,7 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install black
|
||||
pip install "ruff>=0.15.5"
|
||||
|
||||
- name: Format backend
|
||||
run: npm run format:backend
|
||||
|
||||
- name: Check for changes after format
|
||||
run: git diff --exit-code
|
||||
- name: Ruff format check
|
||||
run: ruff format --check . --exclude .venv --exclude venv
|
||||
|
|
|
|||
49
.github/workflows/ruff.yml
vendored
49
.github/workflows/ruff.yml
vendored
|
|
@ -1,49 +0,0 @@
|
|||
name: Ruff
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
|
||||
jobs:
|
||||
ruff:
|
||||
name: 'Lint & Format Backend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get changed Python files
|
||||
id: changed
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE=${{ github.event.pull_request.base.sha }}
|
||||
else
|
||||
BASE=${{ github.event.before }}
|
||||
fi
|
||||
FILES=$(git diff --name-only --diff-filter=ACMR "$BASE" HEAD -- 'backend/**.py' | xargs)
|
||||
echo "files=$FILES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Ruff check
|
||||
if: steps.changed.outputs.files != ''
|
||||
uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
args: check ${{ steps.changed.outputs.files }}
|
||||
|
||||
- name: Ruff format
|
||||
if: steps.changed.outputs.files != ''
|
||||
uses: astral-sh/ruff-action@v3
|
||||
with:
|
||||
args: format --check ${{ steps.changed.outputs.files }}
|
||||
163
CHANGELOG.md
163
CHANGELOG.md
|
|
@ -5,6 +5,168 @@ 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.12] - 2026-03-26
|
||||
|
||||
### Added
|
||||
|
||||
- 🌐 **Translation updates.** Translations for Simplified Chinese, Catalan, Portuguese (Brazil), Finnish, and Lithuanian were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒 **Terminal server connection security.** Terminal server verification and policy saving now proxy through the backend, preventing API key exposure and CORS errors when connecting to in-cluster services. [Commit](https://github.com/open-webui/open-webui/commit/a6413257079a52fa4487eda36543f3955d0fbd53), [Commit](https://github.com/open-webui/open-webui/commit/4567cdc0d9cb7b42b6eba7b676c0ced3f4850d31)
|
||||
- 🛠️ **Terminal tools exception handling.** Exceptions in middleware.py due to invalid return values from get_terminal_tools() have been resolved. [Commit](https://github.com/open-webui/open-webui/commit/52a06bd48aff34fb2211aac2879f0cd028129267)
|
||||
- 📦 **Missing beautifulsoup4 dependency.** Users can now start Open WebUI using uvx without encountering the "bs4 module missing" error. [Commit](https://github.com/open-webui/open-webui/commit/1994d65306bbcc7406584e1bfef82f5d353fc91c)
|
||||
- 🔌 **API files list error.** The /api/v1/files/ endpoint no longer returns a 500 error, fixing a regression that prevented file listing via the API. [Commit](https://github.com/open-webui/open-webui/commit/11f52921dc21c2dc61c03f12bcdf6f19140a350c)
|
||||
- 📜 **License data loading.** License data now loads correctly, displaying the expected color and logo in the interface. [Commit](https://github.com/open-webui/open-webui/commit/16335f866ea4cedf00c4971963622fcc1fe02d82)
|
||||
- 👑 **Admin model visibility.** Administrators can now see models even when no access control is configured yet, allowing them to manage all available models. [Commit](https://github.com/open-webui/open-webui/commit/f3f8f9874f55282603c2650b91801640cb3f69cb)
|
||||
- 📊 **Tool call embed visibility.** Rich UI embeds from tool calls (like visualizations) are now rendered outside collapsed groups and remain visible without requiring manual expansion. [Commit](https://github.com/open-webui/open-webui/commit/4c872a8d128757d4a6f311fb86bc382af2ba5d0d), [Commit](https://github.com/open-webui/open-webui/commit/308fa924a5b2b7e08cd1e8f15b9c8c96e1de8f02)
|
||||
|
||||
## [0.8.11] - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- 🔀 **Responses API streaming improvements.** The OpenAI proxy now properly handles tool call streaming and re-invocations in the Responses API, preventing duplicate tool calls and preserving output during model re-invocations. [Commit](https://github.com/open-webui/open-webui/commit/93415a48e8893139db13d02d0a6d24e8604a2ac5), [Commit](https://github.com/open-webui/open-webui/commit/f8b3a32caf00dad76687fd8fe698b86f304f3997), [Commit](https://github.com/open-webui/open-webui/commit/2ae47cf20057e92a83fd618b938f3ee9bb124e5b), [Commit](https://github.com/open-webui/open-webui/commit/adcbba34f8bbfbab3e4041269a084f2b71c076d9)
|
||||
- 🔀 **Responses API stateful sessions.** Administrators can now enable experimental stateful session support via the ENABLE_RESPONSES_API_STATEFUL environment variable, allowing compatible backends to store responses server-side with previous_response_id anchoring for improved multi-turn conversations. [Commit](https://github.com/open-webui/open-webui/commit/dfc2dc2c0bd298cb4bfcf212ef11223586aa54f1)
|
||||
- 📄 **File viewing pagination.** The view_file and view_knowledge_file tools now support pagination with offset and max_chars parameters, allowing models to read large files in chunks. [Commit](https://github.com/open-webui/open-webui/commit/5d7766e1b6f7ca7749c5a5a780d7b1bb2da28a2f)
|
||||
- 🗺️ **Knowledge search scoping.** The search_knowledge_files tool now respects model-attached knowledge, searching only within attached knowledge bases and files when available. [Commit](https://github.com/open-webui/open-webui/commit/0f0ba7dadd043460d205477fd3b57556aa970847)
|
||||
- 🛠️ **Tool HTML embed context.** Tools can now return custom context alongside HTML embeds by using a tuple format, providing the LLM with actionable information instead of a generic message. [#22691](https://github.com/open-webui/open-webui/pull/22691)
|
||||
- 🔒 **Trusted role header configuration.** Administrators can now configure the WEBUI_AUTH_TRUSTED_ROLE_HEADER environment variable to set user roles (admin, user, or pending) via a trusted header from their identity provider or reverse proxy. [#22523](https://github.com/open-webui/open-webui/pull/22523)
|
||||
- 🔑 **OIDC authorization parameter injection.** Administrators can now inject extra parameters into the OIDC authorization redirect URL via the OAUTH_AUTHORIZE_PARAMS environment variable, enabling IdP pre-selection for brokers like CILogon and Keycloak. [#22863](https://github.com/open-webui/open-webui/issues/22863), [Commit](https://github.com/open-webui/open-webui/commit/69171a4c8bb7f995461b4a2feef194f112b32004)
|
||||
- 🔑 **Google OAuth session persistence.** Administrators can now configure Google OAuth to issue refresh tokens via the GOOGLE_OAUTH_AUTHORIZE_PARAMS environment variable, preventing OAuth sessions from expiring after one hour and ensuring tools and integrations that rely on OAuth tokens remain functional. [#22652](https://github.com/open-webui/open-webui/pull/22652)
|
||||
- 🔌 **Embed prompt confirmation.** Interactive tool embeds can now submit prompts to the chat without requiring same-origin access, showing a confirmation dialog for cross-origin requests to prevent abuse. [#22908](https://github.com/open-webui/open-webui/pull/22908)
|
||||
- 🏮 **Tool binary response handling.** Tool servers can now return binary data such as images, which are properly processed and displayed in chat for both multimodal and non-multimodal models. [Commit](https://github.com/open-webui/open-webui/commit/1c25b06dca83ad491b4dc3d373b1c215a7a8fd3e), [Commit](https://github.com/open-webui/open-webui/commit/108a019cb8e63a533250abe84f2b6f2b7c2131c4)
|
||||
- ⚡ **Svelte upgrade performance.** Page and markdown rendering are now approximately 25% faster across the board, with significantly less memory usage for smoother UI interactions. [#22611](https://github.com/open-webui/open-webui/issues/22611)
|
||||
- 🧩 **Model and filter lookup optimization.** Model and filter membership lookups are now faster thanks to optimized data structure operations during model list loading. [Commit](https://github.com/open-webui/open-webui/commit/7eae377c01f8d2de94a694b72279f769c82658cd)
|
||||
- 💨 **Chat render throttling.** Chat message rendering now uses requestAnimationFrame batching to stay smooth during rapid model responses, preventing dropped frames when fast models send many events per second. [#22947](https://github.com/open-webui/open-webui/pull/22947)
|
||||
- 🚀 **Function list API optimization.** The functions list API now returns only essential metadata without function source code, reducing payload sizes by over 99% and making the Functions admin page load significantly faster. [#22788](https://github.com/open-webui/open-webui/pull/22788)
|
||||
- ✨ **Smoother loading animation.** The loading shimmer animation now looks smoother and more natural, with softer highlight colors. [#22516](https://github.com/open-webui/open-webui/pull/22516)
|
||||
- 🧪 **Terminal connection verification.** Users can now verify their terminal server connection is working before saving the configuration, making setup more reliable. [#22567](https://github.com/open-webui/open-webui/pull/22567)
|
||||
- 📁 **Chat folder emoji reset.** Users can now reset chat folder emojis back to the default icon using a "Reset to Default" button in the emoji picker, making it easier to revert custom icons. [#22554](https://github.com/open-webui/open-webui/pull/22554)
|
||||
- 📊 **Metrics export interval configuration.** Administrators can now control OpenTelemetry metrics export frequency via the OTEL_METRICS_EXPORT_INTERVAL_MILLIS environment variable, enabling cost optimization for metrics services like Grafana Cloud. [#22529](https://github.com/open-webui/open-webui/pull/22529)
|
||||
- 🏥 **Readiness probe endpoint.** A new /ready endpoint is now available for Kubernetes deployments, returning 200 only after startup completes and database/Redis are reachable, enabling more reliable container orchestration. [#22507](https://github.com/open-webui/open-webui/pull/22507)
|
||||
- 🔩 **Tool server timeout configuration.** Administrators can now configure a separate HTTP timeout for tool server requests via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable, enabling fine-tuned control over how long tool calls are allowed to take. [Commit](https://github.com/open-webui/open-webui/commit/a3238aa79f344765f5b62cb64eba71ffd001abaf)
|
||||
- 📌 **Knowledge file previews.** Knowledge base files can now be opened in a new tab directly from the file list, making it easier to view content without downloading. [#22629](https://github.com/open-webui/open-webui/pull/22629)
|
||||
- 🎯 **Knowledge tool hybrid search support.** The built-in query_knowledge_files tool now respects hybrid search and reranking settings, matching the behavior of the middleware RAG pipeline. [Commit](https://github.com/open-webui/open-webui/commit/9a2c60d5954ecbc172d09e9955d52a07d135dcbc)
|
||||
- 🗣️ **Temporary chat folder support.** Temporary chats can now use folder-level system prompts and knowledge files, making them more powerful for quick explorations. [Commit](https://github.com/open-webui/open-webui/commit/adcc50d3370301afd5561e0f58ff6f3ab3750818)
|
||||
- 📡 **Terminal port previews.** Detected ports in the File Navigator can now be previewed inline with a browser-style view, navigation controls, and an address bar, instead of only opening in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/689061822173e561a153290b2bb816f4cb6f4959), [Commit](https://github.com/open-webui/open-webui/commit/1dc647f43b1929f5c4d1af393a90a47f56cb745e)
|
||||
- ✏️ **File renaming.** Files and folders in the File Navigator can now be renamed by double-clicking or using the context menu, with Enter to confirm and Escape to cancel. [Commit](https://github.com/open-webui/open-webui/commit/637cd136c2271baf4787815bc8bc25241626a943)
|
||||
- 🧭 **File Navigator navigation history.** The File Navigator toolbar now includes Back and Forward buttons for navigating through folder and file history, similar to a web browser. [Commit](https://github.com/open-webui/open-webui/commit/3a4b862e818c69fff6f3a3c67b50c51aa00c03e9)
|
||||
- 🗑️ **Delete connection confirmations.** Users are now prompted with a confirmation dialog before deleting connections, preventing accidental deletions. [Commit](https://github.com/open-webui/open-webui/commit/157ff57c40bc40c53bc608828dac3779e95c2ffa)
|
||||
- 📦 **Document loader fallbacks.** Excel and PowerPoint files can now be processed even when the unstructured package is not installed, using pandas and python-pptx as fallback loaders. [Commit](https://github.com/open-webui/open-webui/commit/6862d618ee17f95d3cae78819ed993e7fbc7e632)
|
||||
- 🧠 **Memory management search and sort.** Users can now search and sort their personal memories in the Memory management modal, making it easier to find specific memories. [Commit](https://github.com/open-webui/open-webui/commit/47ab4c71d50fd631b04c95f2febb085dd0a13083)
|
||||
- 📦 **SBOM generation script.** A new script for generating CycloneDX Software Bill of Materials is now available in the scripts directory. [Commit](https://github.com/open-webui/open-webui/commit/39100eca4915e4fe86a6912aa97dde86ed72e015)
|
||||
- ⚙️ **Ruff linter and formatter.** Added Ruff as the Python linter and formatter, replacing the black-based workflow for better code quality with near-instant execution. [#22576](https://github.com/open-webui/open-webui/pull/22576), [#22462](https://github.com/open-webui/open-webui/discussions/22462)
|
||||
- 🖥️ **Offline code formatting support.** The black formatter for Python code editing is now bundled locally in the Docker image, enabling code formatting to work in air-gapped deployments where client browsers cannot reach PyPI. Formatting failures no longer block saves, allowing code to be preserved even when offline. [#22509](https://github.com/open-webui/open-webui/issues/22509), [Commit](https://github.com/open-webui/open-webui/commit/8507e5eb0d18896f1bbf990a00a4361aec171a30)
|
||||
- ✏️ **Markdown file editing.** Users can now edit and save Markdown files directly in the file navigator, with empty files automatically switching to editor mode for immediate editing. [Commit](https://github.com/open-webui/open-webui/commit/47e47e42af682e7f75c8359999f7cdf969bf903e)
|
||||
- 🍔 **Model bulk actions menu.** Users can now quickly enable, disable, show, or hide multiple models at once using a new hamburger menu on the workspace Models page filter bar, with actions respecting the current search and filter settings. [#22484](https://github.com/open-webui/open-webui/pull/22484)
|
||||
- 📂 **Files list pagination.** The files list API now supports pagination, returning paginated results with a total count for easier navigation through large file collections. [Commit](https://github.com/open-webui/open-webui/commit/f9756de693a93e918c037d757afddb7defc847e4)
|
||||
- 🖇 **Web fetch content length config.** Administrators can now configure the maximum characters to return from fetched URLs via WEB_FETCH_MAX_CONTENT_LENGTH environment variable or the admin settings page, instead of the previous hardcoded 50K limit. [Commit](https://github.com/open-webui/open-webui/commit/b171b0216b916745420c7caf513093a315ed9560), [#22774](https://github.com/open-webui/open-webui/issues/22774)
|
||||
- 🤖 **Ollama Anthropic endpoint support.** The Ollama proxy now supports the Anthropic-compatible /v1/messages endpoint, allowing clients using the Anthropic API format to work through Open WebUI with proper authentication and model access controls. [Commit](https://github.com/open-webui/open-webui/commit/f23296b22d3304e5bfcd19151e5802eec55bd98f), [#22861](https://github.com/open-webui/open-webui/issues/22861)
|
||||
- 📝 **Writing block rendering.** Responses from OpenAI models that include :::writing blocks are now rendered as formatted content in a styled container with a copy button, instead of displaying raw marker text. [#22672](https://github.com/open-webui/open-webui/issues/22672), [Commit](https://github.com/open-webui/open-webui/commit/53b8a1f71bd0cb0a0122175ad5210da492018728)
|
||||
- 💡 **Memory deletion confirmation.** Users are now asked to confirm before deleting individual memory entries, with the memory content displayed for review. [#22888](https://github.com/open-webui/open-webui/pull/22888)
|
||||
- 📓 **Multi-artifact HTML rendering.** Code blocks with multiple HTML sections now render as separate artifacts instead of merging into one, allowing models to display distinct interactive components. [Commit](https://github.com/open-webui/open-webui/commit/9a6bf78e14a13864e72db87426da4f5996abe716)
|
||||
- 🚩 **Drag chats as references.** Users can now drag chats from the sidebar and drop them into the message input to add them as Reference Chats. [Commit](https://github.com/open-webui/open-webui/commit/ebb7ce2092efc8d78da4974623647dbd18b6e372)
|
||||
- ⌨️ **Terminal system prompts.** Terminal servers can now provide custom system prompts that are automatically included when their tools are used. [Commit](https://github.com/open-webui/open-webui/commit/6a9d67b5bb4c93fd343b334bee3e37703dff59f6)
|
||||
- 💾 **Terminal state persistence.** The selected terminal server and its enabled state now persist across page loads, making terminal usage more seamless. [Commit](https://github.com/open-webui/open-webui/commit/d577ff1e4af750dda09e558dac7edb8dd2470850)
|
||||
- 💾 **Terminal folder downloads.** Users can now download folders as ZIP archives and bulk-download multiple selected files as a single ZIP directly from the File Navigator toolbar, making file exports faster and more convenient. [Commit](https://github.com/open-webui/open-webui/commit/3841e85abb3ea3e8d8b364dff0102f0124844d22), [Commit](https://github.com/open-webui/open-webui/commit/cf60b1882f1929200649b59f867289dea54e4210)
|
||||
- 🔐 **MCP OAuth 2.1 static credentials.** MCP servers that require static client_id and client_secret can now be connected using a new OAuth 2.1 Static auth type, enabling integration with MCP servers that don't support dynamic client registration. [#22266](https://github.com/open-webui/open-webui/pull/22266), [Commit](https://github.com/open-webui/open-webui/commit/601bb783587a3e965cf88c148e4856b988655b13)
|
||||
- 🎪 **Collapsible tool and thinking groups.** Consecutive tool calls and reasoning blocks are now grouped into a single collapsible summary (e.g., "Explored tool1, tool2"), keeping chat responses clean and readable while preserving full detail on expand. [#21604](https://github.com/open-webui/open-webui/issues/21604), [Commit](https://github.com/open-webui/open-webui/commit/261aec8c864646eb7215be0d5c14a79cad3cb93f)
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 Translations for Finnish, Portuguese (Portugal), Catalan, Turkish, Japanese, Simplified Chinese, Traditional Chinese, Estonian, Spanish, Azerbaijani, and German were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒 **Model access control bypass.** Fixed a security vulnerability where external clients could bypass model access controls by setting a URL parameter, preventing unauthorized access to restricted models. [Commit](https://github.com/open-webui/open-webui/commit/c0385f60ba049da48d2d5452068586d375303c37)
|
||||
- 🛡️ **Terminal proxy path sanitization.** The terminal server proxy now properly sanitizes paths to prevent directory traversal and SSRF attacks, protecting against security vulnerabilities. [Commit](https://github.com/open-webui/open-webui/commit/f9d38a073fae32032ed44073cf2817cba20210bb)
|
||||
- 🛡️ **Tool configuration access control.** Tool configuration endpoints now properly verify user permissions, preventing unauthorized access to tool settings. [Commit](https://github.com/open-webui/open-webui/commit/bc5b3ec6b8ec0fef894eb8046c636ee33688b8c4)
|
||||
- 🗝️ **Tool valves access control.** The tool user valves endpoints now properly verify ownership and access grants before returning or updating configuration, with appropriate 404 responses for missing tools and 401 for unauthorized access. [Commit](https://github.com/open-webui/open-webui/commit/f949d17db1e62e0b79aecbbcbcabe3d57d8d4af6)
|
||||
- 🔐 **Collaborative document authorization.** Fixed a security vulnerability in collaborative documents where authorization could be bypassed using alternative document ID formats, preventing unauthorized access to notes. [Commit](https://github.com/open-webui/open-webui/commit/3107a5363d13c899a995c930cbb1121a80f754f9)
|
||||
- 🔏 **OAuth session persistence.** Users logging in via OAuth or OIDC providers now stay logged in for the configured JWT expiry duration instead of being logged out when closing the browser. [#22809](https://github.com/open-webui/open-webui/pull/22809)
|
||||
- 🚪 **OAuth sub claim configuration crash.** Using the OAUTH_SUB_CLAIM environment variable no longer causes crashes during token exchange requests, fixing a missing configuration registration. [#22865](https://github.com/open-webui/open-webui/pull/22865)
|
||||
- 🔍 **OAuth discovery header parsing.** The OAuth protected resource discovery now correctly handles both quoted and unquoted values in the WWW-Authenticate header, fixing compatibility with MCP servers that return unquoted metadata. [#22646](https://github.com/open-webui/open-webui/discussions/22646), [Commit](https://github.com/open-webui/open-webui/commit/fe7e002fea7283abcf901e22de5c8a7d86e336ea)
|
||||
- 👤 **Admin OAuth group sync.** Admin user group memberships from OAuth and LDAP providers are now properly synced to Open WebUI, fixing a limitation where admin role excluded users from group updates. [#22537](https://github.com/open-webui/open-webui/pull/22537), [Commit](https://github.com/open-webui/open-webui/commit/a1aceb5f879abd130ef83085d98a0d51316a8fc3)
|
||||
- 🎫 **Password change complexity validation.** Password complexity rules are now properly enforced when users change their password, closing a security gap where new passwords could bypass configured complexity requirements. [Commit](https://github.com/open-webui/open-webui/commit/bd8aa3b6a0b6a2320f41b20a51b9842f39aadb7f)
|
||||
- 🔏 **OAuth role enforcement.** OAuth role management now properly denies access when a user's roles don't match any configured OAUTH_ALLOWED_ROLES or OAUTH_ADMIN_ROLES, instead of silently bypassing the restriction. [#13676](https://github.com/open-webui/open-webui/issues/13676), [#15551](https://github.com/open-webui/open-webui/issues/15551), [Commit](https://github.com/open-webui/open-webui/commit/6d7744c21903ec5a9ad951770dea76e9ba19cbcc)
|
||||
- 🔑 **Microsoft Entra ID role claim preservation.** Role claims from Microsoft Entra ID tokens are now preserved during OAuth login, fixing ENABLE_OAUTH_ROLE_MANAGEMENT for Microsoft OAuth which was previously ignored because the userinfo endpoint stripped the roles claim. [#20518](https://github.com/open-webui/open-webui/issues/20518), [Commit](https://github.com/open-webui/open-webui/commit/aa2f7fbe5229c3985ce427602069cdeababda481)
|
||||
- 🔍 **SCIM group filtering.** The SCIM endpoint now properly handles displayName and externalId filters when provisioning groups from identity providers like Microsoft Entra ID, preventing all groups from being returned instead of the filtered subset. [#21543](https://github.com/open-webui/open-webui/pull/21543)
|
||||
- 🔐 **Forwarded allow IPs configuration.** The FORWARDED_ALLOW_IPS environment variable is now properly respected by the startup scripts instead of being hardcoded to '\*', allowing administrators to restrict which proxies are trusted for request forwarding. [#22539](https://github.com/open-webui/open-webui/issues/22539), [Commit](https://github.com/open-webui/open-webui/commit/0aebdd5f83cd1d811009edcbb2bec432a34e7c81)
|
||||
- 🍪 **Model list auth cookie forwarding.** Model list requests to backends that require cookie-based authentication now properly forward auth headers and cookies, preventing "Unauthorized" errors when loading models. [Commit](https://github.com/open-webui/open-webui/commit/76ece4049e96bd6890593f17a946a9af6b082fab)
|
||||
- 🔱 **Model lookup race condition.** Fixed a race condition in Redis model storage that caused intermittent "model not found" errors in multi-replica deployments under heavy load, by eliminating the window between hash deletion and updates. [Commit](https://github.com/open-webui/open-webui/commit/ee901fcd2ca82d7a7dad48170c64df782d3e040a)
|
||||
- 🎚️ **Bulk model action reliability.** Bulk enable, disable, show, and hide operations in the admin Models settings now properly refresh the model list after completion, ensuring changes are reflected immediately and correct toast notifications are shown. [#22962](https://github.com/open-webui/open-webui/pull/22962), [Commit](https://github.com/open-webui/open-webui/commit/75932be880f3b86f78f00b4352b9f1350b8f53fa), [Commit](https://github.com/open-webui/open-webui/commit/15ae3f588b1aa4ddb686ae68afebd6064036a201)
|
||||
- 🔄 **Paginated list duplicates.** Fixed duplicate items appearing in paginated lists when loading more items in chats, knowledge, notes, and search across the UI. [Commit](https://github.com/open-webui/open-webui/commit/58e78e8946fb3644107489fe8e01b17709302b2f)
|
||||
- 🧽 **Duplicate chat list refresh.** Sending messages no longer triggers duplicate sidebar chat list refreshes, eliminating an unnecessary database query that was already handled by the save and completion handlers. [#22982](https://github.com/open-webui/open-webui/pull/22982)
|
||||
- 🧹 **Chat history save optimization.** The chat list is no longer refreshed on every chat history save, branch navigation, or edit — only on meaningful state changes like new chat creation, title generation, and response completion. [#22983](https://github.com/open-webui/open-webui/pull/22983)
|
||||
- 💬 **Message queue responsiveness.** The message queue no longer waits for background tasks like title generation and follow-up suggestions to complete, allowing users to send new messages immediately after a response finishes without unnecessary delays. [Commit](https://github.com/open-webui/open-webui/commit/486c004cbb43f15d5c3e31561f51f22effff1f6c), [#22565](https://github.com/open-webui/open-webui/issues/22565)
|
||||
- 🗄️ **Migration reliability.** Database migrations no longer fail when chat data has unexpected format, making upgrades more reliable. [#22588](https://github.com/open-webui/open-webui/pull/22588), [#22568](https://github.com/open-webui/open-webui/issues/22568)
|
||||
- 🫧 **Memory modal event bubbling.** Fixed an issue where clicking the Delete button in the Memory management modal would also open the Edit Memory modal due to event bubbling. [#22783](https://github.com/open-webui/open-webui/issues/22783)
|
||||
- 🧩 **Memory tool registration.** Models with capabilities.memory: true now correctly have memory tools available for execution, fixing a retry loop where add_memory appeared in the tool schema but was not registered for backend execution. [#22666](https://github.com/open-webui/open-webui/issues/22666), [#22675](https://github.com/open-webui/open-webui/pull/22675), [Commit](https://github.com/open-webui/open-webui/commit/d9339919046c3e977f313f603782d220aab4257f)
|
||||
- 📝 **Input variables modal crash.** Fixed a crash that occurred when selecting custom prompts with prompt variables, causing the Input Variables modal to display an infinite loading spinner instead of the variable input fields. [#22748](https://github.com/open-webui/open-webui/issues/22748), [Commit](https://github.com/open-webui/open-webui/commit/0dcd6ac983bede06b8477179192154467f5b24a2)
|
||||
- 🪛 **Function list API crash fix.** Fixed a 500 error on the functions list API endpoint that was introduced by the recent optimization, by adding proper model configuration for SQLAlchemy ORM objects. [#22924](https://github.com/open-webui/open-webui/pull/22924)
|
||||
- 🗂️ **Sidebar chat menu closure.** Sidebar chat dropdown menus now close properly after clicking "Clone", "Share", "Download", "Rename", "Pin", "Move", "Archive", or "Delete", instead of remaining visible. [#22884](https://github.com/open-webui/open-webui/pull/22884), [#22784](https://github.com/open-webui/open-webui/issues/22784)
|
||||
- 🧭 **Chat deletion and archive redirection.** Users are now redirected to the chat list when deleting or archiving the currently active chat, instead of being left on a stale chat page. [#22755](https://github.com/open-webui/open-webui/pull/22755)
|
||||
- 🚩 **User menu navigation fix.** Clicking Playground or Admin Panel from the user menu now uses client-side routing instead of causing full page reloads, restoring smooth SPA navigation. [Commit](https://github.com/open-webui/open-webui/commit/7ffcd3908ee90f88a4c4684d6cd6e75efd117461)
|
||||
- 🔧 **Tool server connection persistence.** Fixed a bug where tool server connection updates were not being saved to persistent storage, ensuring OAuth client information is now properly preserved. [Commit](https://github.com/open-webui/open-webui/commit/b8ea267f8ec3931de55db7801156b9c07d3ad5f6)
|
||||
- 🔩 **Tool server index bounds checking.** Tool servers with invalid indices no longer crash the application with IndexError after upgrades, preventing tool server configuration loss. [#22490](https://github.com/open-webui/open-webui/issues/22490), [Commit](https://github.com/open-webui/open-webui/commit/8da29566a1f81c38e80009bdea3ce4d9be860605)
|
||||
- 🔌 **Tool server frontend timeout.** Fetch requests to external tool servers now time out after 10 seconds, preventing the UI from hanging indefinitely when a configured tool server is unreachable. [#22543](https://github.com/open-webui/open-webui/issues/22543), [Commit](https://github.com/open-webui/open-webui/commit/adf7af34ff934319a35470c572237d2d08f1de0b)
|
||||
- 🔌 **MCP OAuth tool auto-selection.** MCP tools requiring OAuth authentication are now automatically re-selected after completing the auth flow, instead of leaving users to manually re-enable the tool on return to the chat. [#22994](https://github.com/open-webui/open-webui/issues/22994), [#22995](https://github.com/open-webui/open-webui/pull/22995), [Commit](https://github.com/open-webui/open-webui/commit/4d50001c4192c609b1010626ebb6496692823873)
|
||||
- 🏷️ **Channel @mentions.** Direct connection models no longer appear in channel @mention suggestions, preventing confusion since they don't work in channels. [#22553](https://github.com/open-webui/open-webui/issues/22553), [Commit](https://github.com/open-webui/open-webui/commit/0a87c1ecd078320a08c4cc62d41fe8727fb3b5f7)
|
||||
- 📎 **Channel message attachments.** Users can now press Enter to send messages with only file or image attachments in channels, direct messages, and threads, aligning with the behavior of the Send button. [#22752](https://github.com/open-webui/open-webui/pull/22752)
|
||||
- 🗣️ **Image-only message handling.** Models like Gemini and Claude no longer fail when receiving messages with only file or image attachments and no text, by stripping empty text content blocks before sending to the API. [Commit](https://github.com/open-webui/open-webui/commit/ea515fa26e11faac146c48a5e3a2a284e1792bb3), [#22880](https://github.com/open-webui/open-webui/issues/22880)
|
||||
- 🧹 **Channel thread sidebar cleanup.** The thread sidebar in channels and direct messages now automatically closes when the parent message is deleted, preventing orphaned threads. [#22890](https://github.com/open-webui/open-webui/pull/22890)
|
||||
- 💡 **Chat input suggestion modal.** The suggestion modal for tags, mentions, and commands now correctly reappears when backspacing into a trigger character after it was dismissed. [#22899](https://github.com/open-webui/open-webui/pull/22899)
|
||||
- ⏱️ **Chat action button timing.** Action buttons under assistant messages no longer appear prematurely when switching chats while a response is still streaming. [Commit](https://github.com/open-webui/open-webui/commit/ecba37070d6eb3cb033195a070b6c4ab5f396415), [#22891](https://github.com/open-webui/open-webui/issues/22891)
|
||||
- 💬 **Skill and model mention persistence.** Skills selected via $ and models selected via @ in the chat input are now properly restored after a page refresh, instead of reverting to plain text while losing their interactive state. [#22913](https://github.com/open-webui/open-webui/issues/22913), [Commit](https://github.com/open-webui/open-webui/commit/be21db706993c0db95ac09509dfdb023de64daff)
|
||||
- 🧹 **Webhook profile image errors.** Fixed 404 errors appearing in the browser console when scrolling through channel messages sent by webhooks, by skipping the user profile preview for webhook senders. [#22893](https://github.com/open-webui/open-webui/pull/22893)
|
||||
- 🧮 **Logit bias parameter handling.** Using logit_bias parameters no longer causes errors when the input is already in dictionary format. [#22597](https://github.com/open-webui/open-webui/issues/22597), [Commit](https://github.com/open-webui/open-webui/commit/e34ed72e1e958505e940b74bf1c6a4808640bd17)
|
||||
- 🪛 **Temp chat tool calling.** Temporary chats now properly preserve tool call information, fixing native tool calling with JSON schema that was previously broken. [#22475](https://github.com/open-webui/open-webui/pull/22475), [Commit](https://github.com/open-webui/open-webui/commit/bcd313c363ca50d71aa80bcb2f29c81fad3dff37)
|
||||
- 🔗 **Multi-system message merging.** Models with strict chat templates like Qwen no longer fail when multiple pipeline stages inject separate system messages, as all system messages are now merged into one at the start. [#22505](https://github.com/open-webui/open-webui/issues/22505), [Commit](https://github.com/open-webui/open-webui/commit/631bd20c3537ce85bbaec02f9e0049c88fa8fdd4)
|
||||
- 📜 **Public note access.** Opening public notes via direct share link no longer returns a 500 error caused by a missing function import. [#22680](https://github.com/open-webui/open-webui/issues/22680), [Commit](https://github.com/open-webui/open-webui/commit/566e25569e5e7d9c1e42db840ba4ba578887d208)
|
||||
- 👤 **Terminal access user visibility.** The terminal connection access dialog now shows the currently logged-in user when searching for users to grant access, fixing an issue where users with identical display names were filtered incorrectly. [#22491](https://github.com/open-webui/open-webui/issues/22491), [Commit](https://github.com/open-webui/open-webui/commit/4a8f995c3fd4602ec2aaccc07efc4e8504dda84d)
|
||||
- 👥 **User groups display.** User groups in the admin panel profile preview now wrap properly instead of overflowing horizontally, with a scrollbar when the list is long. [#22547](https://github.com/open-webui/open-webui/pull/22547)
|
||||
- 🔧 **Model list drag-and-drop.** Fixed drag-and-drop reordering of models in admin settings, preventing UI glitches and state synchronization issues. [Commit](https://github.com/open-webui/open-webui/commit/753589e51ccbbe5c4f78a7d13e19c67e6c0000d7)
|
||||
- 🖼️ **Model profile image fallbacks.** Model profile images now display a fallback icon when they fail to load, and model icons no longer disappear on paginated Models pages in admin and workspace settings. [#22485](https://github.com/open-webui/open-webui/pull/22485)
|
||||
- 🖼️ **Profile image fallbacks.** Added fallback handlers for model and user profile images throughout the chat interface, preventing broken image icons when avatars fail to load. [#22486](https://github.com/open-webui/open-webui/pull/22486)
|
||||
- 🧲 **RAG thinking model support.** Knowledge base queries now correctly parse JSON responses from thinking models like GLM-5 and DeepSeek-R1 by stripping their reasoning blocks before JSON extraction. [#22400](https://github.com/open-webui/open-webui/pull/22400)
|
||||
- 🔍 **RAG query generation robustness.** The RAG query generation, web search, and image generation handlers now correctly extract JSON from model responses containing thinking tags by finding the last JSON block instead of the first, preventing "No sources found" errors with thinking models. [#21888](https://github.com/open-webui/open-webui/issues/21888), [Commit](https://github.com/open-webui/open-webui/commit/c0fcbc5b4cb29012e2913983c632edc5d24b9aea)
|
||||
- 🔍 **Ollama embedding robustness.** Ollama embedding requests now include the truncate parameter to handle inputs exceeding the context window, preventing 500 errors when processing long documents. Error messages from failed embedding requests are also now properly surfaced instead of being silently swallowed. [#22671](https://github.com/open-webui/open-webui/issues/22671), [Commit](https://github.com/open-webui/open-webui/commit/d738044f47c70c755bec9bf244aa11878fe98d9c)
|
||||
- 🔄 **Ollama embedding retry logic.** Embedding requests to Ollama now retry with exponential backoff when encountering 503 errors (such as when the model reloads mid-processing), preventing files from being silently dropped from knowledge bases. [#22571](https://github.com/open-webui/open-webui/issues/22571), [Commit](https://github.com/open-webui/open-webui/commit/8b6fa1f4ab6099a305de08706621075c205f65c4)
|
||||
- 🗄️ **Oracle 23AI hybrid search.** Fixed an UnboundLocalError that occurred when using hybrid search with Oracle 23AI as the vector store, preventing knowledge base queries from failing. [Commit](https://github.com/open-webui/open-webui/commit/fcf720835285a4cea10fc1ebed0b454971463b20), [#22616](https://github.com/open-webui/open-webui/issues/22616)
|
||||
- 🌐 **Dynamic HTML language attribute.** The HTML lang attribute now dynamically updates when users change their interface language, preventing browsers from triggering unwanted translation popups. [Commit](https://github.com/open-webui/open-webui/commit/de5e0fbc00e7abcd84e1272c301b0707f8ea5ac6)
|
||||
- 📐 **File upload deduplication.** Attaching files that are already in the chat no longer triggers duplicate uploads. [Commit](https://github.com/open-webui/open-webui/commit/10f06a64fed474e9958b96295a953e0eebf9e4be)
|
||||
- 🕵️ **Serper.dev search results.** Fixed web search results not displaying properly when using the Serper.dev provider by using the correct API response field. [#22869](https://github.com/open-webui/open-webui/pull/22869)
|
||||
- 🔲 **Markdown task list checkbox styling.** Fixed task list checkboxes in markdown rendering to display consistently without shrinking in narrow layouts. [#22886](https://github.com/open-webui/open-webui/pull/22886)
|
||||
- 🎨 **Artifacts sidebar tab background fix.** The Artifacts sidebar now correctly updates and displays when switching back to a browser tab that was in the background, ensuring artifacts are visible without requiring a manual refresh. [#22889](https://github.com/open-webui/open-webui/issues/22889)
|
||||
- 🔃 **Chat input URL indexing fix.** Fixed an issue where URLs could be indexed twice when using multiple triggers followed by backspace and re-entering a URL. [#22749](https://github.com/open-webui/open-webui/issues/22749)
|
||||
- 🔎 **Search modal chat preview avatars.** Fixed assistant profile images not displaying in the chat preview pane of the Search Modal. [#22782](https://github.com/open-webui/open-webui/pull/22782)
|
||||
- 📋 **Prompts search pagination fix.** Fixed a bug where searching prompts from a paginated page would incorrectly use the current page number, resulting in "No prompts found" even when matching results existed. [#22912](https://github.com/open-webui/open-webui/pull/22912)
|
||||
- 🗂️ **Reasoning block copy cleanup.** Copied chat responses no longer include reasoning block content or excess whitespace, ensuring only the intended message text is captured. [#22786](https://github.com/open-webui/open-webui/issues/22786), [Commit](https://github.com/open-webui/open-webui/commit/4f0e57420154800946394bc986b2c691462b2782)
|
||||
- 🔤 **Emoji removal for text normalization.** Fixed the emoji removal function used in search and title generation to correctly handle all emoji types, including those with variation selectors (❤️, ☀️, ✅), keycap sequences (1️⃣), and ZWJ family sequences (👨👩👧👦). [#22915](https://github.com/open-webui/open-webui/pull/22915)
|
||||
- ⏹️ **Task cancellation status tracking.** Cancelled tasks now correctly mark only the affected messages as done instead of clearing all task statuses for the chat, ensuring proper status tracking when multiple messages have pending tasks. [#22743](https://github.com/open-webui/open-webui/pull/22743)
|
||||
- 🎨 **Filter icon display fix.** Fixed filter icons showing the wrong icon after removing one of multiple active filters below the chat input. [#22862](https://github.com/open-webui/open-webui/pull/22862)
|
||||
- 📊 **Channel message data loading.** Fixed redundant 404 API calls that occurred when rendering channel messages, preventing unnecessary requests and console errors. [#22894](https://github.com/open-webui/open-webui/pull/22894)
|
||||
- 👻 **Response message skeleton display.** Fixed an issue where the skeleton loader would incorrectly show or hide based on complex status history conditions, by extracting the visibility logic into a cleaner reactive variable. [Commit](https://github.com/open-webui/open-webui/commit/5df4277216fbb9de603fdf4289f8366292568234)
|
||||
- 🐛 **Shared chat viewing crash.** Shared chats can now be viewed by unauthenticated users without crashing, with proper fallback handling for missing user profile information. [#22751](https://github.com/open-webui/open-webui/pull/22751), [#22742](https://github.com/open-webui/open-webui/issues/22742)
|
||||
- 🛠️ **Plugin ID sanitization.** Creating Functions or Tools with emojis or special characters in their names now generates valid IDs that pass backend validation, instead of failing with an error. [#22695](https://github.com/open-webui/open-webui/pull/22695)
|
||||
- 📋 **Chat title preservation.** Regenerating responses or using branches no longer overwrites user-specified chat titles when auto-naming is disabled, by checking the full chat message count instead of just the current branch. [#22754](https://github.com/open-webui/open-webui/pull/22754)
|
||||
- 🎧 **Read Aloud in chat preview.** The Read Aloud button in the Search Chats modal preview no longer causes crashes, and TTS functionality is now properly hidden in read-only chat contexts. [Commit](https://github.com/open-webui/open-webui/commit/d8fa0f426a88f5c27b3216b7db35e1db47bbba28)
|
||||
- 📡 **Heartbeat event loop blocking.** The WebSocket heartbeat handler no longer blocks the event loop when updating user activity, improving responsiveness under heavy load with many concurrent connections. [#22980](https://github.com/open-webui/open-webui/pull/22980)
|
||||
- 🗝️ **Message upsert API reliability.** The message upsert API endpoint no longer crashes when called, fixing an error where a database session was incorrectly passed to a function that doesn't accept it. [#22959](https://github.com/open-webui/open-webui/issues/22959), [Commit](https://github.com/open-webui/open-webui/commit/70285fb6cad26b50d783583b68be5227ace16055)
|
||||
- 🔓 **Forward auth proxy compatibility.** Fixed error pages that could appear when using authenticating reverse-proxies by properly handling 401 responses from background API requests, allowing the browser to re-authenticate with the identity provider. [#22942](https://github.com/open-webui/open-webui/pull/22942)
|
||||
- 🔃 **Tool call streaming display.** Sequential tool calls are now properly accumulated during streaming, fixing an issue where completed tool calls could disappear from the display before the next tool call finished streaming. [Commit](https://github.com/open-webui/open-webui/commit/a9c5c787b9f6b10491924d38645042064b3c941e)
|
||||
- 🧠 **Reasoning spinner content preservation.** Prior assistant content and tool call blocks no longer disappear during the reasoning spinner when responding after tool execution. [#23001](https://github.com/open-webui/open-webui/pull/23001)
|
||||
- 🖥️ **Pyodide file list refresh.** Files created or modified during manual code execution now appear immediately in the pyodide files list without requiring a browser tab refresh. [Commit](https://github.com/open-webui/open-webui/commit/5c4062c64841974bf193ff321d92d10f28a09746)
|
||||
- 🖱️ **Dropdown submenu hover stability.** Secondary hover menus like Download and Move now remain open while navigating into them, fixing an issue where an 8px gap between the trigger and submenu would cause the menu to disappear before a selection could be made. [#22744](https://github.com/open-webui/open-webui/issues/22744), [Commit](https://github.com/open-webui/open-webui/commit/cffbc3558e911abd6c4780cd028794b2f7282cd7)
|
||||
- 📊 **Model tag normalization.** Model tags from backends that return them as string arrays are now properly normalized to object format, preventing crashes when filtering models by tag in the admin and workspace models pages. [#20819](https://github.com/open-webui/open-webui/issues/20819), [Commit](https://github.com/open-webui/open-webui/commit/90ca2e9b0f15cc9be7cf298fbefacaa45074cae9)
|
||||
- 🎯 **Arena model sub-model settings.** Arena models now properly use the selected sub-model's settings — including RAG knowledge bases, web access, code interpreter, and tool capabilities — instead of the arena wrapper's empty defaults. [#16950](https://github.com/open-webui/open-webui/issues/16950), [Commit](https://github.com/open-webui/open-webui/commit/857d7e6f373d26a7a8989417c3a7fe99cdc03f20)
|
||||
- 🧩 **Model editor default metadata.** The Model Editor now loads admin-configured default model metadata instead of hardcoded values, preventing admin defaults from being silently overwritten when users save models without realizing they were overriding system-wide settings. [#22996](https://github.com/open-webui/open-webui/issues/22996), [Commit](https://github.com/open-webui/open-webui/commit/cdc2b3bf850044051aafcd46f22fb25a1899788c)
|
||||
- ✏️ **Rich text paste sanitization.** Copying and pasting text with HTML characters (like `<` or `>`) no longer corrupts the editor content, as the paste handler now properly escapes HTML entities before processing mentions and special syntax. [Commit](https://github.com/open-webui/open-webui/commit/94f877ff328d410339308ad2c566c9afcdf43014)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🪝 **User webhooks disabled by default.** User webhook notifications are now disabled by default and properly gated by the ENABLE_USER_WEBHOOKS configuration, ensuring webhooks only fire when explicitly enabled. [Commit](https://github.com/open-webui/open-webui/commit/c24a4da17dbaddf47e2e0f865c1d602d0ff36ee6)
|
||||
- 🧩 **MCP integration visibility.** MCP (Streamable HTTP) integrations are now hidden from user-level settings, matching the intended behavior where only administrators can configure MCP connections through the admin panel. User-level connections now show the connection type as read-only. [#22615](https://github.com/open-webui/open-webui/issues/22615), [Commit](https://github.com/open-webui/open-webui/commit/1eef5b4f6a718c0fcf3605f1ed62669aca07b454)
|
||||
- 🧲 **Web search result limit.** The configured web search result count now acts as a maximum limit, preventing models from requesting more results than administrators allow. [#22577](https://github.com/open-webui/open-webui/pull/22577)
|
||||
|
||||
## [0.8.10] - 2026-03-08
|
||||
|
||||
### Added
|
||||
|
|
@ -3972,7 +4134,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
- **🔗 Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality.
|
||||
|
||||
- Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag.
|
||||
- When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
|
||||
PORT="${PORT:-8080}"
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload
|
||||
uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload
|
||||
|
|
|
|||
|
|
@ -2,18 +2,17 @@ import base64
|
|||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
import uvicorn
|
||||
from typing import Optional
|
||||
from typing_extensions import Annotated
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
KEY_FILE = Path.cwd() / '.webui_secret_key'
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
def version_callback(value: bool) -> None:
|
||||
if value:
|
||||
from open_webui.env import VERSION
|
||||
|
||||
|
|
@ -23,7 +22,7 @@ def version_callback(value: bool):
|
|||
|
||||
@app.command()
|
||||
def main(
|
||||
version: Annotated[Optional[bool], typer.Option('--version', callback=version_callback)] = None,
|
||||
version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None,
|
||||
):
|
||||
pass
|
||||
|
||||
|
|
@ -66,7 +65,7 @@ def serve(
|
|||
os.environ['USE_CUDA_DOCKER'] = 'false'
|
||||
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
|
||||
|
||||
import open_webui.main # we need set environment variables before importing main
|
||||
import open_webui.main # noqa: F401
|
||||
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||
|
||||
uvicorn.run(
|
||||
|
|
|
|||
|
|
@ -362,6 +362,18 @@ GOOGLE_REDIRECT_URI = PersistentConfig(
|
|||
os.environ.get('GOOGLE_REDIRECT_URI', ''),
|
||||
)
|
||||
|
||||
GOOGLE_OAUTH_AUTHORIZE_PARAMS = {}
|
||||
_google_oauth_authorize_params = os.environ.get('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '')
|
||||
if _google_oauth_authorize_params:
|
||||
try:
|
||||
_parsed = json.loads(_google_oauth_authorize_params)
|
||||
if isinstance(_parsed, dict):
|
||||
GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed
|
||||
else:
|
||||
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
MICROSOFT_CLIENT_ID = PersistentConfig(
|
||||
'MICROSOFT_CLIENT_ID',
|
||||
'oauth.microsoft.client_id',
|
||||
|
|
@ -642,6 +654,18 @@ OAUTH_AUDIENCE = PersistentConfig(
|
|||
os.environ.get('OAUTH_AUDIENCE', ''),
|
||||
)
|
||||
|
||||
OAUTH_AUTHORIZE_PARAMS = {}
|
||||
_oauth_authorize_params = os.environ.get('OAUTH_AUTHORIZE_PARAMS', '')
|
||||
if _oauth_authorize_params:
|
||||
try:
|
||||
_parsed = json.loads(_oauth_authorize_params)
|
||||
if isinstance(_parsed, dict):
|
||||
OAUTH_AUTHORIZE_PARAMS = _parsed
|
||||
else:
|
||||
log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring')
|
||||
|
||||
|
||||
def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
|
|
@ -658,6 +682,7 @@ def load_oauth_providers():
|
|||
**({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}),
|
||||
},
|
||||
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
||||
**({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}),
|
||||
)
|
||||
return client
|
||||
|
||||
|
|
@ -1577,7 +1602,7 @@ ENABLE_MESSAGE_RATING = PersistentConfig(
|
|||
ENABLE_USER_WEBHOOKS = PersistentConfig(
|
||||
'ENABLE_USER_WEBHOOKS',
|
||||
'ui.enable_user_webhooks',
|
||||
os.environ.get('ENABLE_USER_WEBHOOKS', 'True').lower() == 'true',
|
||||
os.environ.get('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true',
|
||||
)
|
||||
|
||||
# FastAPI / AnyIO settings
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class MESSAGES(str, Enum):
|
|||
|
||||
class WEBHOOK_MESSAGES(str, Enum):
|
||||
DEFAULT = lambda msg='': f'{msg if msg else ""}'
|
||||
USER_SIGNUP = lambda username='': (f'New user signed up: {username}' if username else 'New user signed up')
|
||||
USER_SIGNUP = lambda username='': f'New user signed up: {username}' if username else 'New user signed up'
|
||||
|
||||
|
||||
class ERROR_MESSAGES(str, Enum):
|
||||
|
|
@ -80,8 +80,8 @@ class ERROR_MESSAGES(str, Enum):
|
|||
|
||||
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'
|
||||
|
||||
FILE_TOO_LARGE = (
|
||||
lambda size='': f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
|
||||
FILE_TOO_LARGE = lambda size='': (
|
||||
f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}."
|
||||
)
|
||||
|
||||
DUPLICATE_CONTENT = 'Duplicate content detected. Please provide unique content to proceed.'
|
||||
|
|
@ -89,7 +89,7 @@ class ERROR_MESSAGES(str, Enum):
|
|||
'Extracted content is not available for this file. Please ensure that the file is processed before proceeding.'
|
||||
)
|
||||
|
||||
INVALID_PASSWORD = lambda err='': (err if err else 'The password does not meet the required validation criteria.')
|
||||
INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.'
|
||||
|
||||
|
||||
class TASKS(str, Enum):
|
||||
|
|
|
|||
|
|
@ -760,6 +760,16 @@ AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
|
|||
os.environ.get('AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL', 'True').lower() == 'true'
|
||||
)
|
||||
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = os.environ.get('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER', '')
|
||||
|
||||
if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER == '':
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
|
||||
else:
|
||||
try:
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = int(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
|
||||
except Exception:
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT
|
||||
|
||||
|
||||
RAG_EMBEDDING_TIMEOUT = os.environ.get('RAG_EMBEDDING_TIMEOUT', '')
|
||||
|
||||
|
|
|
|||
|
|
@ -543,6 +543,7 @@ from open_webui.utils.auth import (
|
|||
from open_webui.utils.plugin import install_tool_and_function_dependencies
|
||||
from open_webui.utils.oauth import (
|
||||
get_oauth_client_info_with_dynamic_client_registration,
|
||||
get_oauth_client_info_with_static_credentials,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
OAuthManager,
|
||||
|
|
@ -2261,7 +2262,7 @@ if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
|||
server_id = tool_server_connection.get('info', {}).get('id')
|
||||
auth_type = tool_server_connection.get('auth_type', 'none')
|
||||
|
||||
if server_id and auth_type == 'oauth_2.1':
|
||||
if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '')
|
||||
|
||||
try:
|
||||
|
|
@ -2321,17 +2322,33 @@ async def register_client(request, client_id: str) -> bool:
|
|||
return False
|
||||
|
||||
server_url = connection.get('url')
|
||||
auth_type = connection.get('auth_type', 'none')
|
||||
oauth_server_key = (connection.get('config') or {}).get('oauth_server_key')
|
||||
|
||||
try:
|
||||
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request,
|
||||
client_id,
|
||||
server_url,
|
||||
oauth_server_key,
|
||||
)
|
||||
if auth_type == 'oauth_2.1_static':
|
||||
# Static credentials: rebuild from stored credentials + fresh metadata
|
||||
existing_client_info = connection.get('info', {}).get('oauth_client_info', '')
|
||||
if not existing_client_info:
|
||||
log.error(f'No stored OAuth client info for static client {client_id}')
|
||||
return False
|
||||
existing_data = decrypt_data(existing_client_info)
|
||||
oauth_client_info = await get_oauth_client_info_with_static_credentials(
|
||||
request,
|
||||
client_id,
|
||||
server_url,
|
||||
oauth_client_id=existing_data.get('client_id', ''),
|
||||
oauth_client_secret=existing_data.get('client_secret', ''),
|
||||
)
|
||||
else:
|
||||
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request,
|
||||
client_id,
|
||||
server_url,
|
||||
oauth_server_key,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f'Dynamic client re-registration failed for {client_id}: {e}')
|
||||
log.error(f'OAuth client re-registration failed for {client_id}: {e}')
|
||||
return False
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""Add tasks and summary columns to chat table
|
||||
|
||||
Revision ID: a3dd5bedd151
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-03-29 22:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a3dd5bedd151'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
|
||||
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'summary')
|
||||
op.drop_column('chat', 'tasks')
|
||||
|
|
@ -29,6 +29,8 @@ from sqlalchemy.sql.expression import bindparam
|
|||
|
||||
####################
|
||||
# Chat DB Schema
|
||||
# Let no word spoken in this house be lost, and when the
|
||||
# record is read again, let it still serve the one who spoke.
|
||||
####################
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -52,6 +54,9 @@ class Chat(Base):
|
|||
meta = Column(JSON, server_default='{}')
|
||||
folder_id = Column(Text, nullable=True)
|
||||
|
||||
tasks = Column(JSON, nullable=True)
|
||||
summary = Column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# Performance indexes for common queries
|
||||
# WHERE folder_id = ...
|
||||
|
|
@ -85,6 +90,9 @@ class ChatModel(BaseModel):
|
|||
meta: dict = {}
|
||||
folder_id: Optional[str] = None
|
||||
|
||||
tasks: Optional[list] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class ChatFile(Base):
|
||||
__tablename__ = 'chat_file'
|
||||
|
|
@ -159,6 +167,9 @@ class ChatResponse(BaseModel):
|
|||
meta: dict = {}
|
||||
folder_id: Optional[str] = None
|
||||
|
||||
tasks: Optional[list] = None
|
||||
summary: Optional[str] = None
|
||||
|
||||
|
||||
class ChatTitleIdResponse(BaseModel):
|
||||
id: str
|
||||
|
|
@ -1459,8 +1470,8 @@ class ChatTable:
|
|||
def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
|
||||
shared_chat_ids = [f'shared-{chat.id}' for chat in chats_by_user]
|
||||
id_rows = db.query(Chat.id).filter_by(user_id=user_id).all()
|
||||
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
|
||||
|
||||
# Use subquery to delete chat_messages for shared chats
|
||||
shared_id_subq = db.query(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)).subquery()
|
||||
|
|
@ -1550,5 +1561,27 @@ class ChatTable:
|
|||
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
|
||||
"""Update the tasks list on a chat."""
|
||||
try:
|
||||
with get_db_context() as db:
|
||||
chat = db.get(Chat, id)
|
||||
if chat is None:
|
||||
return None
|
||||
chat.tasks = tasks
|
||||
db.commit()
|
||||
db.refresh(chat)
|
||||
return ChatModel.model_validate(chat)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_chat_tasks_by_id(self, id: str) -> list[dict]:
|
||||
"""Read the tasks list from a chat (lightweight column query)."""
|
||||
with get_db_context() as db:
|
||||
result = db.query(Chat.tasks).filter_by(id=id).first()
|
||||
if result is None or result[0] is None:
|
||||
return []
|
||||
return result[0]
|
||||
|
||||
|
||||
Chats = ChatTable()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Files DB Schema
|
||||
# What is written here bears witness. Let the testimony
|
||||
# remain as it was given, and let none tamper with it.
|
||||
####################
|
||||
|
||||
|
||||
|
|
@ -85,7 +87,7 @@ class FileModelResponse(BaseModel):
|
|||
|
||||
filename: str
|
||||
data: Optional[dict] = None
|
||||
meta: FileMeta
|
||||
meta: Optional[FileMeta] = None
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files
|
||||
|
|
@ -244,7 +246,7 @@ class FilesTable:
|
|||
total = query.count()
|
||||
|
||||
items = [
|
||||
FileModel.model_validate(file)
|
||||
FileModelResponse.model_validate(file, from_attributes=True)
|
||||
for file in query.order_by(File.updated_at.desc(), File.id.desc()).offset(skip).limit(limit).all()
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Folder DB Schema
|
||||
# Let every room in this house shelter someone who needs it,
|
||||
# and let no chamber stand empty while there is want.
|
||||
####################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Functions DB Schema
|
||||
# Each function here is a promise made. Let no promise
|
||||
# go unkept, and let none be called who cannot answer.
|
||||
####################
|
||||
|
||||
|
||||
|
|
@ -226,12 +228,7 @@ class FunctionsTable:
|
|||
|
||||
def get_function_list(self, db: Optional[Session] = None) -> list[FunctionUserResponse]:
|
||||
with get_db_context(db) as db:
|
||||
functions = (
|
||||
db.query(Function)
|
||||
.options(defer(Function.content))
|
||||
.order_by(Function.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
functions = db.query(Function).options(defer(Function.content)).order_by(Function.updated_at.desc()).all()
|
||||
user_ids = list(set(func.user_id for func in functions))
|
||||
|
||||
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# UserGroup DB Schema
|
||||
# Let none who belong to this house be turned away,
|
||||
# and let the covenant hold for every member.
|
||||
####################
|
||||
|
||||
|
||||
|
|
@ -174,6 +176,11 @@ class GroupTable:
|
|||
groups = db.query(Group).order_by(Group.updated_at.desc()).all()
|
||||
return [GroupModel.model_validate(group) for group in groups]
|
||||
|
||||
def get_group_by_name(self, name: str, db: Optional[Session] = None) -> Optional[GroupModel]:
|
||||
with get_db_context(db) as db:
|
||||
group = db.query(Group).filter(Group.name == name).first()
|
||||
return GroupModel.model_validate(group) if group else None
|
||||
|
||||
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
|
||||
with get_db_context(db) as db:
|
||||
member_count = (
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Knowledge DB Schema
|
||||
# Let what was gathered here outlast the one who gathered it,
|
||||
# and still teach when the builder is gone.
|
||||
####################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from sqlalchemy import BigInteger, Column, String, Text
|
|||
|
||||
####################
|
||||
# Memory DB Schema
|
||||
# What was learned at cost should not need to be paid
|
||||
# for again. Let the memory hold.
|
||||
####################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from open_webui.models.users import User, UserModel, Users, UserResponse
|
|||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from sqlalchemy import String, cast, or_, and_, func
|
||||
from sqlalchemy.dialects import postgresql, sqlite
|
||||
|
|
@ -23,6 +23,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Models DB Schema
|
||||
# A misconfigured model wastes the time of everyone
|
||||
# who trusts it. Let what is set here be set with care.
|
||||
####################
|
||||
|
||||
|
||||
|
|
@ -45,7 +47,20 @@ class ModelMeta(BaseModel):
|
|||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
pass
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def normalize_tags(cls, data):
|
||||
if isinstance(data, dict) and 'tags' in data:
|
||||
raw_tags = data['tags']
|
||||
if isinstance(raw_tags, list):
|
||||
normalized = []
|
||||
for tag in raw_tags:
|
||||
if isinstance(tag, str):
|
||||
normalized.append({'name': tag})
|
||||
elif isinstance(tag, dict) and 'name' in tag:
|
||||
normalized.append(tag)
|
||||
data['tags'] = normalized
|
||||
return data
|
||||
|
||||
|
||||
class Model(Base):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, fun
|
|||
|
||||
####################
|
||||
# Prompts DB Schema
|
||||
# Every word here was weighed before it was set down.
|
||||
# Let the weight not be wasted when it is spoken aloud.
|
||||
####################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Tag DB Schema
|
||||
# To name a thing is to claim it. The creator has
|
||||
# already named everything stored in this table.
|
||||
####################
|
||||
class Tag(Base):
|
||||
__tablename__ = 'tag'
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
####################
|
||||
# Tools DB Schema
|
||||
# A tool that fails silently is worse than one that
|
||||
# refuses outright. Let each one here be honest in its work.
|
||||
####################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import datetime
|
|||
|
||||
####################
|
||||
# User DB Schema
|
||||
# Hallowed be the columns defined here, for they hold the
|
||||
# daily bread of every session. Let none go hungry.
|
||||
####################
|
||||
|
||||
|
||||
|
|
@ -586,18 +588,13 @@ class UsersTable:
|
|||
return None
|
||||
|
||||
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
|
||||
def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]:
|
||||
def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> None:
|
||||
try:
|
||||
with get_db_context(db) as db:
|
||||
user = db.query(User).filter_by(id=id).first()
|
||||
if not user:
|
||||
return None
|
||||
user.last_active_at = int(time.time())
|
||||
db.query(User).filter_by(id=id).update({'last_active_at': int(time.time())})
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return UserModel.model_validate(user)
|
||||
except Exception:
|
||||
return None
|
||||
pass
|
||||
|
||||
def update_user_oauth_by_id(
|
||||
self, id: str, provider: str, sub: str, db: Optional[Session] = None
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from open_webui.models.knowledge import Knowledges
|
|||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
|
||||
from open_webui.retrieval.vector.main import GetResult
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
|
|
@ -550,34 +551,30 @@ def generate_openai_batch_embeddings(
|
|||
key: str = '',
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
json_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'generate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
json_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
r = requests.post(
|
||||
f'{url}/embeddings',
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if 'data' in data:
|
||||
return [elem['embedding'] for elem in data['data']]
|
||||
else:
|
||||
raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key")
|
||||
except Exception as e:
|
||||
log.exception(f'Error generating openai batch embeddings: {e}')
|
||||
return None
|
||||
r = requests.post(
|
||||
f'{url}/embeddings',
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if 'data' in data:
|
||||
return [elem['embedding'] for elem in data['data']]
|
||||
else:
|
||||
raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key")
|
||||
|
||||
|
||||
async def agenerate_openai_batch_embeddings(
|
||||
|
|
@ -587,38 +584,34 @@ async def agenerate_openai_batch_embeddings(
|
|||
key: str = '',
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
form_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
form_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
f'{url}/embeddings',
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if 'data' in data:
|
||||
return [item['embedding'] for item in data['data']]
|
||||
else:
|
||||
raise Exception('Something went wrong :/')
|
||||
except Exception as e:
|
||||
log.exception(f'Error generating openai batch embeddings: {e}')
|
||||
return None
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
f'{url}/embeddings',
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if 'data' in data:
|
||||
return [item['embedding'] for item in data['data']]
|
||||
else:
|
||||
raise ValueError("Unexpected OpenAI embeddings response: missing 'data' key")
|
||||
|
||||
|
||||
def generate_azure_openai_batch_embeddings(
|
||||
|
|
@ -629,42 +622,38 @@ def generate_azure_openai_batch_embeddings(
|
|||
version: str = '',
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
|
||||
json_data = {'input': texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'generate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
|
||||
json_data = {'input': texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
|
||||
url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
|
||||
|
||||
for _ in range(5):
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': key,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
for _ in range(5):
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': key,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
)
|
||||
if r.status_code == 429:
|
||||
retry = float(r.headers.get('Retry-After', '1'))
|
||||
time.sleep(retry)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if 'data' in data:
|
||||
return [elem['embedding'] for elem in data['data']]
|
||||
else:
|
||||
raise Exception('Something went wrong :/')
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception(f'Error generating azure openai batch embeddings: {e}')
|
||||
return None
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
)
|
||||
if r.status_code == 429:
|
||||
retry = float(r.headers.get('Retry-After', '1'))
|
||||
time.sleep(retry)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if 'data' in data:
|
||||
return [elem['embedding'] for elem in data['data']]
|
||||
else:
|
||||
raise ValueError("Unexpected Azure OpenAI embeddings response: missing 'data' key")
|
||||
raise Exception('Azure OpenAI embedding request failed: max retries (429) exceeded')
|
||||
|
||||
|
||||
async def agenerate_azure_openai_batch_embeddings(
|
||||
|
|
@ -675,40 +664,36 @@ async def agenerate_azure_openai_batch_embeddings(
|
|||
version: str = '',
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
|
||||
form_data = {'input': texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}')
|
||||
form_data = {'input': texts}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
|
||||
full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}'
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': key,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': key,
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
full_url,
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if 'data' in data:
|
||||
return [item['embedding'] for item in data['data']]
|
||||
else:
|
||||
raise Exception('Something went wrong :/')
|
||||
except Exception as e:
|
||||
log.exception(f'Error generating azure openai batch embeddings: {e}')
|
||||
return None
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
full_url,
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if 'data' in data:
|
||||
return [item['embedding'] for item in data['data']]
|
||||
else:
|
||||
raise ValueError("Unexpected Azure OpenAI embeddings response: missing 'data' key")
|
||||
|
||||
|
||||
def generate_ollama_batch_embeddings(
|
||||
|
|
@ -718,35 +703,33 @@ def generate_ollama_batch_embeddings(
|
|||
key: str = '',
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
json_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'generate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
json_data = {'input': texts, 'model': model, 'truncate': True}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
json_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
r = requests.post(
|
||||
f'{url}/api/embed',
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
r = requests.post(
|
||||
f'{url}/api/embed',
|
||||
headers=headers,
|
||||
json=json_data,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
error_detail = r.json().get('error', r.text)
|
||||
raise Exception(f'Ollama embed error ({r.status_code}): {error_detail}')
|
||||
data = r.json()
|
||||
|
||||
if 'embeddings' in data:
|
||||
return data['embeddings']
|
||||
else:
|
||||
raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key")
|
||||
except Exception as e:
|
||||
log.exception(f'Error generating ollama batch embeddings: {e}')
|
||||
return None
|
||||
if 'embeddings' in data:
|
||||
return data['embeddings']
|
||||
else:
|
||||
raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key")
|
||||
|
||||
|
||||
async def agenerate_ollama_batch_embeddings(
|
||||
|
|
@ -756,38 +739,37 @@ async def agenerate_ollama_batch_embeddings(
|
|||
key: str = '',
|
||||
prefix: str = None,
|
||||
user: UserModel = None,
|
||||
) -> Optional[list[list[float]]]:
|
||||
try:
|
||||
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
form_data = {'input': texts, 'model': model}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
) -> list[list[float]]:
|
||||
log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}')
|
||||
form_data = {'input': texts, 'model': model, 'truncate': True}
|
||||
if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str):
|
||||
form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {key}',
|
||||
}
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
f'{url}/api/embed',
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
data = await r.json()
|
||||
if 'embeddings' in data:
|
||||
return data['embeddings']
|
||||
else:
|
||||
raise Exception('Something went wrong :/')
|
||||
except Exception as e:
|
||||
log.exception(f'Error generating ollama batch embeddings: {e}')
|
||||
return None
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
) as session:
|
||||
async with session.post(
|
||||
f'{url}/api/embed',
|
||||
headers=headers,
|
||||
json=form_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
error_data = await r.json()
|
||||
error_detail = error_data.get('error', str(error_data))
|
||||
raise Exception(f'Ollama embed error ({r.status}): {error_detail}')
|
||||
data = await r.json()
|
||||
if 'embeddings' in data:
|
||||
return data['embeddings']
|
||||
else:
|
||||
raise ValueError("Unexpected Ollama embeddings response: missing 'embeddings' key")
|
||||
|
||||
|
||||
def get_embedding_function(
|
||||
|
|
@ -855,11 +837,12 @@ def get_embedding_function(
|
|||
for batch in batches:
|
||||
batch_results.append(await embedding_function(batch, prefix=prefix, user=user))
|
||||
|
||||
# Flatten results
|
||||
# Flatten results — raise if any batch failed
|
||||
embeddings = []
|
||||
for batch_embeddings in batch_results:
|
||||
if isinstance(batch_embeddings, list):
|
||||
embeddings.extend(batch_embeddings)
|
||||
for i, batch_embeddings in enumerate(batch_results):
|
||||
if batch_embeddings is None:
|
||||
raise Exception(f'Embedding generation failed for batch {i + 1}/{len(batches)}')
|
||||
embeddings.extend(batch_embeddings)
|
||||
|
||||
log.debug(
|
||||
f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches'
|
||||
|
|
@ -901,11 +884,15 @@ async def generate_embeddings(
|
|||
'user': user,
|
||||
}
|
||||
)
|
||||
if embeddings is None:
|
||||
return None
|
||||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
elif engine == 'openai':
|
||||
embeddings = await agenerate_openai_batch_embeddings(
|
||||
model, text if isinstance(text, list) else [text], url, key, prefix, user
|
||||
)
|
||||
if embeddings is None:
|
||||
return None
|
||||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
elif engine == 'azure_openai':
|
||||
azure_api_version = kwargs.get('azure_api_version', '')
|
||||
|
|
@ -918,6 +905,8 @@ async def generate_embeddings(
|
|||
prefix,
|
||||
user,
|
||||
)
|
||||
if embeddings is None:
|
||||
return None
|
||||
return embeddings[0] if isinstance(text, str) else embeddings
|
||||
|
||||
|
||||
|
|
@ -1054,7 +1043,11 @@ async def get_sources_from_items(
|
|||
}
|
||||
elif item.get('id'):
|
||||
file_object = Files.get_file_by_id(item.get('id'))
|
||||
if file_object:
|
||||
if file_object and (
|
||||
user.role == 'admin'
|
||||
or file_object.user_id == user.id
|
||||
or has_access_to_file(item.get('id'), 'read', user)
|
||||
):
|
||||
query_result = {
|
||||
'documents': [[file_object.data.get('content', '')]],
|
||||
'metadatas': [
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
# Let what is spoken here be heard clearly, and let
|
||||
# no voice be reduced to noise along the way.
|
||||
#
|
||||
##########################################
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ router = APIRouter()
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Forgive us our failed attempts, as we forgive those
|
||||
# who exceed their allotted rate against this gate.
|
||||
signin_rate_limiter = RateLimiter(redis_client=get_redis_client(), limit=5 * 3, window=60 * 3)
|
||||
|
||||
|
||||
|
|
@ -289,7 +291,7 @@ async def update_password(
|
|||
|
||||
if user:
|
||||
try:
|
||||
validate_password(form_data.password)
|
||||
validate_password(form_data.new_password)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, detail=str(e))
|
||||
hashed = get_password_hash(form_data.new_password)
|
||||
|
|
@ -580,9 +582,7 @@ async def signin(
|
|||
if user.role != trusted_role:
|
||||
Users.update_user_role_by_id(user.id, trusted_role, db=db)
|
||||
elif trusted_role:
|
||||
log.warning(
|
||||
f'Ignoring invalid trusted role header value: {trusted_role}'
|
||||
)
|
||||
log.warning(f'Ignoring invalid trusted role header value: {trusted_role}')
|
||||
|
||||
elif WEBUI_AUTH == False:
|
||||
admin_email = 'admin@localhost'
|
||||
|
|
@ -1155,8 +1155,9 @@ async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=D
|
|||
# create api key
|
||||
@router.post('/api_key', response_model=ApiKey)
|
||||
async def generate_api_key(request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)):
|
||||
if not request.app.state.config.ENABLE_API_KEYS or not has_permission(
|
||||
user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS
|
||||
if not request.app.state.config.ENABLE_API_KEYS or (
|
||||
user.role != 'admin'
|
||||
and not has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ def get_channel_permitted_group_and_user_ids(
|
|||
|
||||
############################
|
||||
# Channels Enabled Dependency
|
||||
# The creator has set this table; let every voice that
|
||||
# gathers here find shelter under the same roof.
|
||||
############################
|
||||
|
||||
|
||||
|
|
@ -813,12 +815,16 @@ async def get_pinned_channel_messages(
|
|||
############################
|
||||
|
||||
|
||||
async def send_notification(name, webui_url, channel, message, active_user_ids, db=None):
|
||||
async def send_notification(request, channel, message, active_user_ids, db=None):
|
||||
name = request.app.state.WEBUI_NAME
|
||||
webui_url = request.app.state.config.WEBUI_URL
|
||||
enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS
|
||||
|
||||
users = get_channel_users_with_access(channel, 'read', db=db)
|
||||
|
||||
for user in users:
|
||||
if (user.id not in active_user_ids) and Channels.is_user_channel_member(channel.id, user.id, db=db):
|
||||
if user.settings:
|
||||
if enable_user_webhooks and user.settings:
|
||||
webhook_url = user.settings.ui.get('notifications', {}).get('webhook_url', None)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
|
|
@ -1107,8 +1113,7 @@ async def post_new_message(
|
|||
async def background_handler():
|
||||
await model_response_handler(request, channel, message, user)
|
||||
await send_notification(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBUI_URL,
|
||||
request,
|
||||
channel,
|
||||
message,
|
||||
active_user_ids,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ router = APIRouter()
|
|||
|
||||
############################
|
||||
# GetChatList
|
||||
# Let the record outlive the session, so that what was
|
||||
# learned here not need to be learned again.
|
||||
############################
|
||||
|
||||
|
||||
|
|
@ -930,7 +932,6 @@ async def update_chat_message_by_id(
|
|||
{
|
||||
'content': form_data.content,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
|
||||
event_emitter = get_event_emitter(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from open_webui.models.oauth_sessions import OAuthSessions
|
|||
from open_webui.utils.oauth import (
|
||||
get_discovery_urls,
|
||||
get_oauth_client_info_with_dynamic_client_registration,
|
||||
get_oauth_client_info_with_static_credentials,
|
||||
encrypt_data,
|
||||
decrypt_data,
|
||||
OAuthClientInformationFull,
|
||||
|
|
@ -37,6 +38,8 @@ log = logging.getLogger(__name__)
|
|||
|
||||
############################
|
||||
# ImportConfig
|
||||
# Thy configuration come, thy settings be done,
|
||||
# in production as it is in development.
|
||||
############################
|
||||
|
||||
|
||||
|
|
@ -97,6 +100,7 @@ class OAuthClientRegistrationForm(BaseModel):
|
|||
url: str
|
||||
client_id: str
|
||||
client_name: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
|
||||
|
||||
@router.post('/oauth/clients/register')
|
||||
|
|
@ -111,9 +115,19 @@ async def register_oauth_client(
|
|||
if type:
|
||||
oauth_client_id = f'{type}:{form_data.client_id}'
|
||||
|
||||
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request, oauth_client_id, form_data.url
|
||||
)
|
||||
if form_data.client_secret:
|
||||
# Static credentials: skip dynamic registration, build from provided credentials
|
||||
oauth_client_info = await get_oauth_client_info_with_static_credentials(
|
||||
request,
|
||||
oauth_client_id,
|
||||
form_data.url,
|
||||
oauth_client_id=form_data.client_id,
|
||||
oauth_client_secret=form_data.client_secret,
|
||||
)
|
||||
else:
|
||||
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request, oauth_client_id, form_data.url
|
||||
)
|
||||
return {
|
||||
'status': True,
|
||||
'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')),
|
||||
|
|
@ -164,7 +178,7 @@ async def set_tool_servers_config(
|
|||
server_type = connection.get('type', 'openapi')
|
||||
auth_type = connection.get('auth_type', 'none')
|
||||
|
||||
if auth_type == 'oauth_2.1':
|
||||
if auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
# Remove existing OAuth clients for tool servers
|
||||
server_id = connection.get('info', {}).get('id')
|
||||
client_key = f'{server_type}:{server_id}'
|
||||
|
|
@ -187,7 +201,7 @@ async def set_tool_servers_config(
|
|||
server_id = connection.get('info', {}).get('id')
|
||||
auth_type = connection.get('auth_type', 'none')
|
||||
|
||||
if auth_type == 'oauth_2.1' and server_id:
|
||||
if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id:
|
||||
try:
|
||||
oauth_client_info = connection.get('info', {}).get('oauth_client_info', '')
|
||||
oauth_client_info = decrypt_data(oauth_client_info)
|
||||
|
|
@ -255,6 +269,92 @@ async def set_terminal_servers_config(
|
|||
}
|
||||
|
||||
|
||||
@router.post('/terminal_servers/verify')
|
||||
async def verify_terminal_server_connection(
|
||||
request: Request, form_data: TerminalServerConnection, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Verify the connection to a terminal server by detecting its type.
|
||||
|
||||
Tries GET {url}/api/v1/policies (orchestrator) then GET {url}/api/config
|
||||
(plain terminal). Returns ``{status: true, type: "orchestrator"|"terminal"}``.
|
||||
"""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
||||
headers = {}
|
||||
if form_data.auth_type == 'bearer' and form_data.key:
|
||||
headers['Authorization'] = f'Bearer {form_data.key}'
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
# Orchestrators expose a policies API; plain terminals don't.
|
||||
try:
|
||||
async with session.get(f'{base_url}/api/v1/policies', headers=headers) as resp:
|
||||
if resp.ok:
|
||||
return {'status': True, 'type': 'orchestrator'}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fall back to open-terminal config endpoint.
|
||||
try:
|
||||
async with session.get(f'{base_url}/api/config', headers=headers) as resp:
|
||||
if resp.ok:
|
||||
return {'status': True, 'type': 'terminal'}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to connect to the terminal server: {e}')
|
||||
|
||||
raise HTTPException(status_code=400, detail='Failed to connect to the terminal server')
|
||||
|
||||
|
||||
class TerminalServerPolicyForm(BaseModel):
|
||||
url: str
|
||||
key: Optional[str] = ''
|
||||
auth_type: Optional[str] = 'bearer'
|
||||
policy_id: str
|
||||
policy_data: dict
|
||||
|
||||
|
||||
@router.post('/terminal_servers/policy')
|
||||
async def put_terminal_server_policy(
|
||||
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy PUT to an orchestrator terminal server.
|
||||
"""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if form_data.auth_type == 'bearer' and form_data.key:
|
||||
headers['Authorization'] = f'Bearer {form_data.key}'
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
|
||||
async with session.put(policy_url, headers=headers, json=form_data.policy_data) as resp:
|
||||
if resp.ok:
|
||||
return await resp.json()
|
||||
detail = await resp.text()
|
||||
raise HTTPException(status_code=resp.status, detail=detail)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save policy to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
|
||||
|
||||
|
||||
@router.post('/tool_servers/verify')
|
||||
async def verify_tool_servers_config(request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)):
|
||||
"""
|
||||
|
|
@ -262,7 +362,7 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
|||
"""
|
||||
try:
|
||||
if form_data.type == 'mcp':
|
||||
if form_data.auth_type == 'oauth_2.1':
|
||||
if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
discovery_urls = await get_discovery_urls(form_data.url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}')
|
||||
|
|
@ -475,6 +575,13 @@ class ModelsConfigForm(BaseModel):
|
|||
DEFAULT_MODEL_PARAMS: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get('/models/defaults')
|
||||
async def get_models_defaults(request: Request, user=Depends(get_verified_user)):
|
||||
return {
|
||||
'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/models', response_model=ModelsConfigForm)
|
||||
async def get_models_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ router = APIRouter()
|
|||
|
||||
|
||||
# Leaderboard Elo Rating Computation
|
||||
# The judgment has already been rendered with grace;
|
||||
# the scales have been balanced by a hand that never errs.
|
||||
#
|
||||
# How it works:
|
||||
# 1. Each model starts with a rating of 1000
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ from open_webui.utils.access_control.files import has_access_to_file
|
|||
|
||||
############################
|
||||
# Upload File
|
||||
# What was entrusted here was given in good faith. Let it
|
||||
# be returned the same way, whole and undiminished.
|
||||
############################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ router = APIRouter()
|
|||
|
||||
############################
|
||||
# GetFunctions
|
||||
# Our daily functions give us, and forgive us
|
||||
# our deprecated methods, as we refactor those who depend on us.
|
||||
############################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ from pydantic import BaseModel
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# An image can lie as easily as it can illuminate. Let what
|
||||
# is generated here be honest about what it shows.
|
||||
IMAGE_CACHE_DIR = CACHE_DIR / 'image' / 'generations'
|
||||
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from fastapi.concurrency import run_in_threadpool
|
|||
import logging
|
||||
import io
|
||||
import zipfile
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from open_webui.internal.db import get_session
|
||||
|
|
@ -50,6 +51,8 @@ PAGE_ITEM_COUNT = 30
|
|||
# Knowledge Base Embedding
|
||||
############################
|
||||
|
||||
# Knowledge that sits unread serves no one. Let what is
|
||||
# stored here find the ones who need it.
|
||||
KNOWLEDGE_BASES_COLLECTION = 'knowledge-bases'
|
||||
|
||||
|
||||
|
|
@ -1087,11 +1090,16 @@ async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Sess
|
|||
zip_buffer.seek(0)
|
||||
|
||||
# Sanitize knowledge name for filename
|
||||
safe_name = ''.join(c if c.isalnum() or c in ' -_' else '_' for c in knowledge.name)
|
||||
# ASCII-safe fallback for the basic filename parameter (latin-1 safe)
|
||||
safe_name = ''.join(c if c.isascii() and (c.isalnum() or c in ' -_') else '_' for c in knowledge.name)
|
||||
zip_filename = f'{safe_name}.zip'
|
||||
|
||||
# Use RFC 5987 filename* for non-ASCII names so the browser gets the real name
|
||||
quoted_name = quote(f'{knowledge.name}.zip')
|
||||
content_disposition = f'attachment; filename="{zip_filename}"; filename*=UTF-8\'\'{quoted_name}'
|
||||
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type='application/zip',
|
||||
headers={'Content-Disposition': f'attachment; filename={zip_filename}'},
|
||||
headers={'Content-Disposition': content_disposition},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ router = APIRouter()
|
|||
|
||||
############################
|
||||
# GetMemories
|
||||
# Let what is remembered here spare someone the cost
|
||||
# of learning it twice.
|
||||
############################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ def is_valid_model_id(model_id: str) -> bool:
|
|||
|
||||
###########################
|
||||
# GetModels
|
||||
# Let each model here be judged by what it does and not
|
||||
# by what it claims. The house deserves honest servants.
|
||||
###########################
|
||||
|
||||
|
||||
|
|
@ -149,10 +151,14 @@ async def get_model_tags(user=Depends(get_verified_user), db: Session = Depends(
|
|||
if model.meta:
|
||||
meta = model.meta.model_dump()
|
||||
for tag in meta.get('tags', []):
|
||||
tags_set.add((tag.get('name')))
|
||||
try:
|
||||
name = tag.get('name') if isinstance(tag, dict) else str(tag)
|
||||
if name:
|
||||
tags_set.add(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
tags = [tag for tag in tags_set]
|
||||
tags.sort()
|
||||
tags = sorted(tags_set)
|
||||
return tags
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ log = logging.getLogger(__name__)
|
|||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
# Let what runs locally be trusted, and let no weight
|
||||
# be loaded without serving the one who waits for the answer.
|
||||
#
|
||||
##########################################
|
||||
|
||||
|
|
|
|||
|
|
@ -66,24 +66,37 @@ log = logging.getLogger(__name__)
|
|||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
# Let the responses returned through this gate be worth
|
||||
# the question that summoned them.
|
||||
#
|
||||
##########################################
|
||||
|
||||
|
||||
async def send_get_request(url, key=None, user: UserModel = None):
|
||||
async def send_get_request(
|
||||
request: Request = None,
|
||||
url=None,
|
||||
key=None,
|
||||
user: UserModel = None,
|
||||
config=None,
|
||||
):
|
||||
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
headers = {
|
||||
**({'Authorization': f'Bearer {key}'} if key else {}),
|
||||
}
|
||||
if request and config:
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user)
|
||||
else:
|
||||
headers = {
|
||||
**({'Authorization': f'Bearer {key}'} if key else {}),
|
||||
}
|
||||
cookies = None
|
||||
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
headers = include_user_info_headers(headers, user)
|
||||
|
||||
async with session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as response:
|
||||
return await response.json()
|
||||
|
|
@ -93,10 +106,16 @@ async def send_get_request(url, key=None, user: UserModel = None):
|
|||
return None
|
||||
|
||||
|
||||
async def get_models_request(url, key=None, user: UserModel = None):
|
||||
async def get_models_request(
|
||||
request: Request = None,
|
||||
url=None,
|
||||
key=None,
|
||||
user: UserModel = None,
|
||||
config=None,
|
||||
):
|
||||
if is_anthropic_url(url):
|
||||
return await get_anthropic_models(url, key, user=user)
|
||||
return await send_get_request(f'{url}/models', key, user=user)
|
||||
return await send_get_request(request, f'{url}/models', key, user=user, config=config)
|
||||
|
||||
|
||||
def openai_reasoning_model_handler(payload):
|
||||
|
|
@ -358,7 +377,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
|||
request_tasks = []
|
||||
for idx, url in enumerate(api_base_urls):
|
||||
if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support
|
||||
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
|
||||
request_tasks.append(get_models_request(request, url, api_keys[idx], user=user))
|
||||
else:
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
|
|
@ -370,7 +389,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
|||
|
||||
if enable:
|
||||
if len(model_ids) == 0:
|
||||
request_tasks.append(get_models_request(url, api_keys[idx], user=user))
|
||||
request_tasks.append(get_models_request(request, url, api_keys[idx], user=user, config=api_config))
|
||||
else:
|
||||
model_list = {
|
||||
'object': 'list',
|
||||
|
|
@ -837,32 +856,41 @@ def convert_to_responses_payload(payload: dict) -> dict:
|
|||
if role == 'assistant' and msg.get('tool_calls'):
|
||||
# Add text content as message if present
|
||||
if content:
|
||||
text = content if isinstance(content, str) else '\n'.join(
|
||||
p.get('text', '') for p in content if p.get('type') == 'text'
|
||||
text = (
|
||||
content
|
||||
if isinstance(content, str)
|
||||
else '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text')
|
||||
)
|
||||
if text.strip():
|
||||
input_items.append({
|
||||
'type': 'message', 'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': text}],
|
||||
})
|
||||
input_items.append(
|
||||
{
|
||||
'type': 'message',
|
||||
'role': 'assistant',
|
||||
'content': [{'type': 'output_text', 'text': text}],
|
||||
}
|
||||
)
|
||||
# Convert each tool_call to a function_call input item
|
||||
for tool_call in msg['tool_calls']:
|
||||
func = tool_call.get('function', {})
|
||||
input_items.append({
|
||||
'type': 'function_call',
|
||||
'call_id': tool_call.get('id', ''),
|
||||
'name': func.get('name', ''),
|
||||
'arguments': func.get('arguments', '{}'),
|
||||
})
|
||||
input_items.append(
|
||||
{
|
||||
'type': 'function_call',
|
||||
'call_id': tool_call.get('id', ''),
|
||||
'name': func.get('name', ''),
|
||||
'arguments': func.get('arguments', '{}'),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle tool result messages
|
||||
if role == 'tool':
|
||||
input_items.append({
|
||||
'type': 'function_call_output',
|
||||
'call_id': msg.get('tool_call_id', ''),
|
||||
'output': msg.get('content', ''),
|
||||
})
|
||||
input_items.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'call_id': msg.get('tool_call_id', ''),
|
||||
'output': msg.get('content', ''),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Convert content format
|
||||
|
|
@ -1124,6 +1152,14 @@ async def generate_chat_completion(
|
|||
request_url = f'{url}/responses'
|
||||
else:
|
||||
request_url = f'{url}/chat/completions'
|
||||
# For Chat Completions, strip image parts from multimodal tool messages
|
||||
# (Chat Completions doesn't support images in tool content).
|
||||
if not is_responses and 'messages' in payload:
|
||||
for message in payload['messages']:
|
||||
if message.get('role') == 'tool' and isinstance(message.get('content'), list):
|
||||
message['content'] = ''.join(
|
||||
part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text')
|
||||
)
|
||||
|
||||
payload = json.dumps(payload)
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ log = logging.getLogger(__name__)
|
|||
##################################
|
||||
#
|
||||
# Pipeline Middleware
|
||||
# Every hand this passes through can corrupt it or
|
||||
# improve it. Let each stage leave it better than it found.
|
||||
#
|
||||
##################################
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ PAGE_ITEM_COUNT = 30
|
|||
|
||||
############################
|
||||
# GetPrompts
|
||||
# The hardest part is knowing what to ask. Let the right
|
||||
# question already be here when it is needed.
|
||||
############################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ log = logging.getLogger(__name__)
|
|||
##########################################
|
||||
#
|
||||
# Utility functions
|
||||
# Give us this day our relevant chunks, and lead us
|
||||
# not into hallucination, but deliver us from noise.
|
||||
#
|
||||
##########################################
|
||||
|
||||
|
|
|
|||
|
|
@ -790,8 +790,17 @@ async def get_groups(
|
|||
startIndex = max(1, startIndex)
|
||||
count = max(0, min(100, count))
|
||||
|
||||
# Get all groups
|
||||
groups_list = Groups.get_all_groups(db=db)
|
||||
# Get groups, applying filter if provided
|
||||
if filter:
|
||||
if 'displayName eq' in filter:
|
||||
display_name = filter.split('"')[1]
|
||||
group = Groups.get_group_by_name(display_name, db=db)
|
||||
groups_list = [group] if group else []
|
||||
else:
|
||||
# Unrecognized filter — fall back to all groups
|
||||
groups_list = Groups.get_all_groups(db=db)
|
||||
else:
|
||||
groups_list = Groups.get_all_groups(db=db)
|
||||
|
||||
# Apply pagination
|
||||
total = len(groups_list)
|
||||
|
|
|
|||
|
|
@ -31,14 +31,20 @@ def _sanitize_proxy_path(path: str) -> str | None:
|
|||
"""Sanitize a proxy path to prevent directory traversal / SSRF.
|
||||
|
||||
Returns the cleaned path, or None if the path is invalid.
|
||||
Trailing slashes are preserved — many upstream frameworks treat
|
||||
``/path`` and ``/path/`` differently.
|
||||
"""
|
||||
decoded = unquote(path)
|
||||
had_trailing_slash = decoded.endswith('/')
|
||||
normalized = posixpath.normpath(decoded)
|
||||
# Remove any leading slashes that would reset the base
|
||||
cleaned = normalized.lstrip('/')
|
||||
# Reject if normpath resolved to parent traversal or current-dir only
|
||||
if cleaned.startswith('..') or cleaned == '.':
|
||||
return None
|
||||
# Restore trailing slash if the original path had one
|
||||
if had_trailing_slash and cleaned and not cleaned.endswith('/'):
|
||||
cleaned += '/'
|
||||
return cleaned
|
||||
|
||||
|
||||
|
|
@ -99,6 +105,10 @@ async def proxy_terminal(
|
|||
target_url += f'?{request.query_params}'
|
||||
|
||||
headers = {'X-User-Id': user.id}
|
||||
# Forward per-session cwd tracking header
|
||||
session_id = request.headers.get('x-session-id')
|
||||
if session_id:
|
||||
headers['X-Session-Id'] = session_id
|
||||
cookies = {}
|
||||
auth_type = connection.get('auth_type', 'bearer')
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ def get_tool_module(request, tool_id, load_from_db=True):
|
|||
|
||||
############################
|
||||
# GetTools
|
||||
# The danger is not in having tools, but in reaching
|
||||
# for the wrong one. Let the choice here be deliberate.
|
||||
############################
|
||||
|
||||
|
||||
|
|
@ -760,19 +762,35 @@ async def update_tools_valves_by_id(
|
|||
@router.get('/id/{id}/valves/user', response_model=Optional[dict])
|
||||
async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)):
|
||||
tools = Tools.get_tool_by_id(id, db=db)
|
||||
if tools:
|
||||
try:
|
||||
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db)
|
||||
return user_valves
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
else:
|
||||
if not tools:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='tool',
|
||||
resource_id=tools.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
and user.role != 'admin'
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
try:
|
||||
user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db)
|
||||
return user_valves
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -784,26 +802,42 @@ async def get_tools_user_valves_spec_by_id(
|
|||
db: Session = Depends(get_session),
|
||||
):
|
||||
tools = Tools.get_tool_by_id(id, db=db)
|
||||
if tools:
|
||||
if id in request.app.state.TOOLS:
|
||||
tools_module = request.app.state.TOOLS[id]
|
||||
else:
|
||||
tools_module, _ = load_tool_module_by_id(id)
|
||||
request.app.state.TOOLS[id] = tools_module
|
||||
|
||||
if hasattr(tools_module, 'UserValves'):
|
||||
UserValves = tools_module.UserValves
|
||||
schema = UserValves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(UserValves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
else:
|
||||
if not tools:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='tool',
|
||||
resource_id=tools.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
and user.role != 'admin'
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if id in request.app.state.TOOLS:
|
||||
tools_module = request.app.state.TOOLS[id]
|
||||
else:
|
||||
tools_module, _ = load_tool_module_by_id(id)
|
||||
request.app.state.TOOLS[id] = tools_module
|
||||
|
||||
if hasattr(tools_module, 'UserValves'):
|
||||
UserValves = tools_module.UserValves
|
||||
schema = UserValves.schema()
|
||||
# Resolve dynamic options for select dropdowns
|
||||
schema = resolve_valves_schema_options(UserValves, schema, user)
|
||||
return schema
|
||||
return None
|
||||
|
||||
|
||||
@router.post('/id/{id}/valves/user/update', response_model=Optional[dict])
|
||||
async def update_tools_user_valves_by_id(
|
||||
|
|
@ -814,33 +848,48 @@ async def update_tools_user_valves_by_id(
|
|||
db: Session = Depends(get_session),
|
||||
):
|
||||
tools = Tools.get_tool_by_id(id, db=db)
|
||||
if not tools:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if tools:
|
||||
if id in request.app.state.TOOLS:
|
||||
tools_module = request.app.state.TOOLS[id]
|
||||
else:
|
||||
tools_module, _ = load_tool_module_by_id(id)
|
||||
request.app.state.TOOLS[id] = tools_module
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='tool',
|
||||
resource_id=tools.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
and user.role != 'admin'
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
if hasattr(tools_module, 'UserValves'):
|
||||
UserValves = tools_module.UserValves
|
||||
if id in request.app.state.TOOLS:
|
||||
tools_module = request.app.state.TOOLS[id]
|
||||
else:
|
||||
tools_module, _ = load_tool_module_by_id(id)
|
||||
request.app.state.TOOLS[id] = tools_module
|
||||
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db)
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to update user valves by id {id}: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
else:
|
||||
if hasattr(tools_module, 'UserValves'):
|
||||
UserValves = tools_module.UserValves
|
||||
|
||||
try:
|
||||
form_data = {k: v for k, v in form_data.items() if v is not None}
|
||||
user_valves = UserValves(**form_data)
|
||||
user_valves_dict = user_valves.model_dump(exclude_unset=True)
|
||||
Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db)
|
||||
return user_valves_dict
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to update user valves by id {id}: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.DEFAULT(str(e)),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ router = APIRouter()
|
|||
|
||||
############################
|
||||
# GetUsers
|
||||
# A house is only as strong as its care for the least of
|
||||
# its members. Let none here be counted without being served.
|
||||
############################
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ async def format_code(form_data: CodeForm, user=Depends(get_admin_user)):
|
|||
|
||||
@router.post('/code/execute')
|
||||
async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)):
|
||||
if not request.app.state.config.ENABLE_CODE_EXECUTION:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail='Code execution is disabled',
|
||||
)
|
||||
|
||||
if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter':
|
||||
output = await execute_code_jupyter(
|
||||
request.app.state.config.CODE_EXECUTION_JUPYTER_URL,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Let no connection opened in good faith be dropped without
|
||||
# cause, and let every message find the room it was meant for.
|
||||
REDIS = None
|
||||
|
||||
# Configure CORS for Socket.IO
|
||||
|
|
@ -393,7 +395,7 @@ async def heartbeat(sid, data):
|
|||
user = SESSION_POOL.get(sid)
|
||||
if user:
|
||||
SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())}
|
||||
Users.update_last_active_by_id(user['id'])
|
||||
await asyncio.to_thread(Users.update_last_active_by_id, user['id'])
|
||||
|
||||
|
||||
@sio.on('join-channels')
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
}
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
|
||||
'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium',
|
||||
Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px; /* Default font size */
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -393,7 +393,8 @@ async def execute_code(
|
|||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
import textwrap
|
||||
|
||||
blocking_code = textwrap.dedent(f"""
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
import builtins
|
||||
|
||||
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
|
||||
|
|
@ -409,7 +410,8 @@ async def execute_code(
|
|||
return _real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = restricted_import
|
||||
""")
|
||||
"""
|
||||
)
|
||||
code = blocking_code + '\n' + code
|
||||
|
||||
engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
|
||||
|
|
@ -1528,9 +1530,11 @@ async def search_knowledge_files(
|
|||
skip: int = 0,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__model_knowledge__: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search files across knowledge bases the user has access to.
|
||||
Search files by filename across knowledge bases the user has access to.
|
||||
When the model has attached knowledge, searches only within attached KBs and files.
|
||||
|
||||
:param query: The search query to find matching files by filename
|
||||
:param knowledge_id: Optional KB id to limit search to a specific knowledge base
|
||||
|
|
@ -1546,10 +1550,91 @@ async def search_knowledge_files(
|
|||
|
||||
try:
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
|
||||
|
||||
# When model has attached knowledge, scope to attached KBs/files only
|
||||
if __model_knowledge__:
|
||||
attached_kb_ids = set()
|
||||
attached_file_ids = set()
|
||||
|
||||
for item in __model_knowledge__:
|
||||
item_type = item.get('type')
|
||||
item_id = item.get('id')
|
||||
if item_type == 'collection':
|
||||
attached_kb_ids.add(item_id)
|
||||
elif item_type == 'file':
|
||||
attached_file_ids.add(item_id)
|
||||
|
||||
# If knowledge_id specified, verify it's in the attached set
|
||||
if knowledge_id:
|
||||
if knowledge_id not in attached_kb_ids:
|
||||
return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'})
|
||||
attached_kb_ids = {knowledge_id}
|
||||
|
||||
all_files = []
|
||||
|
||||
# Search within attached KBs
|
||||
for kb_id in attached_kb_ids:
|
||||
knowledge = Knowledges.get_knowledge_by_id(kb_id)
|
||||
if not knowledge:
|
||||
continue
|
||||
|
||||
if not (
|
||||
user_role == 'admin'
|
||||
or knowledge.user_id == user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge.id,
|
||||
permission='read',
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
result = Knowledges.search_files_by_id(
|
||||
knowledge_id=kb_id,
|
||||
user_id=user_id,
|
||||
filter={'query': query},
|
||||
skip=0,
|
||||
limit=count + skip,
|
||||
)
|
||||
|
||||
for file in result.items:
|
||||
all_files.append(
|
||||
{
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'knowledge_id': knowledge.id,
|
||||
'knowledge_name': knowledge.name,
|
||||
'updated_at': file.updated_at,
|
||||
}
|
||||
)
|
||||
|
||||
# Search within directly attached files (filename match)
|
||||
if not knowledge_id and attached_file_ids:
|
||||
query_lower = query.lower() if query else ''
|
||||
for file_id in attached_file_ids:
|
||||
file = Files.get_file_by_id(file_id)
|
||||
if file and (not query_lower or query_lower in file.filename.lower()):
|
||||
all_files.append(
|
||||
{
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'updated_at': file.updated_at,
|
||||
}
|
||||
)
|
||||
|
||||
# Apply pagination across combined results
|
||||
all_files = all_files[skip : skip + count]
|
||||
return json.dumps(all_files, ensure_ascii=False)
|
||||
|
||||
# No attached knowledge - search all accessible KBs
|
||||
if knowledge_id:
|
||||
result = Knowledges.search_files_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
|
|
@ -1587,17 +1672,26 @@ async def search_knowledge_files(
|
|||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
# Hard cap for view_file / view_knowledge_file output
|
||||
MAX_VIEW_FILE_CHARS = 100_000
|
||||
DEFAULT_VIEW_FILE_MAX_CHARS = 10_000
|
||||
|
||||
|
||||
async def view_file(
|
||||
file_id: str,
|
||||
offset: int = 0,
|
||||
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__model_knowledge__: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the full content of a file by its ID.
|
||||
Get the content of a file by its ID. Supports pagination for large files.
|
||||
|
||||
:param file_id: The ID of the file to retrieve
|
||||
:return: JSON with the file's id, filename, and full text content
|
||||
:param offset: Character offset to start reading from (default: 0)
|
||||
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
|
||||
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
|
@ -1605,6 +1699,22 @@ async def view_file(
|
|||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
# Coerce parameters from LLM tool calls (may come as strings)
|
||||
if isinstance(offset, str):
|
||||
try:
|
||||
offset = int(offset)
|
||||
except ValueError:
|
||||
offset = 0
|
||||
if isinstance(max_chars, str):
|
||||
try:
|
||||
max_chars = int(max_chars)
|
||||
except ValueError:
|
||||
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
|
||||
|
||||
# Enforce hard cap
|
||||
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
|
||||
offset = max(offset, 0)
|
||||
|
||||
try:
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
|
|
@ -1634,16 +1744,27 @@ async def view_file(
|
|||
if file.data:
|
||||
content = file.data.get('content', '')
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'content': content,
|
||||
'updated_at': file.updated_at,
|
||||
'created_at': file.created_at,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
total_chars = len(content)
|
||||
sliced = content[offset : offset + max_chars]
|
||||
is_truncated = (offset + len(sliced)) < total_chars
|
||||
|
||||
result = {
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'content': sliced,
|
||||
'updated_at': file.updated_at,
|
||||
'created_at': file.created_at,
|
||||
}
|
||||
|
||||
if is_truncated or offset > 0:
|
||||
result['truncated'] = is_truncated
|
||||
result['total_chars'] = total_chars
|
||||
result['returned_chars'] = len(sliced)
|
||||
result['offset'] = offset
|
||||
if is_truncated:
|
||||
result['next_offset'] = offset + len(sliced)
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
log.exception(f'view_file error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
|
@ -1651,14 +1772,18 @@ async def view_file(
|
|||
|
||||
async def view_knowledge_file(
|
||||
file_id: str,
|
||||
offset: int = 0,
|
||||
max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the full content of a file from a knowledge base.
|
||||
Get the content of a file from a knowledge base. Supports pagination for large files.
|
||||
|
||||
:param file_id: The ID of the file to retrieve
|
||||
:return: JSON with the file's id, filename, and full text content
|
||||
:param offset: Character offset to start reading from (default: 0)
|
||||
:param max_chars: Maximum characters to return (default: 10000, hard cap: 100000)
|
||||
:return: JSON with the file's id, filename, content, and pagination metadata if truncated
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
|
@ -1666,6 +1791,22 @@ async def view_knowledge_file(
|
|||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
# Coerce parameters from LLM tool calls (may come as strings)
|
||||
if isinstance(offset, str):
|
||||
try:
|
||||
offset = int(offset)
|
||||
except ValueError:
|
||||
offset = 0
|
||||
if isinstance(max_chars, str):
|
||||
try:
|
||||
max_chars = int(max_chars)
|
||||
except ValueError:
|
||||
max_chars = DEFAULT_VIEW_FILE_MAX_CHARS
|
||||
|
||||
# Enforce hard cap
|
||||
max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS)
|
||||
offset = max(offset, 0)
|
||||
|
||||
try:
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
|
|
@ -1708,10 +1849,14 @@ async def view_knowledge_file(
|
|||
if file.data:
|
||||
content = file.data.get('content', '')
|
||||
|
||||
total_chars = len(content)
|
||||
sliced = content[offset : offset + max_chars]
|
||||
is_truncated = (offset + len(sliced)) < total_chars
|
||||
|
||||
result = {
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'content': content,
|
||||
'content': sliced,
|
||||
'updated_at': file.updated_at,
|
||||
'created_at': file.created_at,
|
||||
}
|
||||
|
|
@ -1719,12 +1864,130 @@ async def view_knowledge_file(
|
|||
result['knowledge_id'] = knowledge_info['id']
|
||||
result['knowledge_name'] = knowledge_info['name']
|
||||
|
||||
if is_truncated or offset > 0:
|
||||
result['truncated'] = is_truncated
|
||||
result['total_chars'] = total_chars
|
||||
result['returned_chars'] = len(sliced)
|
||||
result['offset'] = offset
|
||||
if is_truncated:
|
||||
result['next_offset'] = offset + len(sliced)
|
||||
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
log.exception(f'view_knowledge_file error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def list_knowledge(
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__model_knowledge__: Optional[list[dict]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
List all knowledge bases, files, and notes attached to the current model.
|
||||
Use this first to discover what knowledge is available before querying or reading files.
|
||||
|
||||
:return: JSON with knowledge_bases, files, and notes attached to this model
|
||||
"""
|
||||
if __request__ is None:
|
||||
return json.dumps({'error': 'Request context not available'})
|
||||
|
||||
if not __user__:
|
||||
return json.dumps({'error': 'User context not available'})
|
||||
|
||||
if not __model_knowledge__:
|
||||
return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []})
|
||||
|
||||
try:
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_id = __user__.get('id')
|
||||
user_role = __user__.get('role', 'user')
|
||||
user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)]
|
||||
|
||||
knowledge_bases = []
|
||||
files = []
|
||||
notes = []
|
||||
|
||||
for item in __model_knowledge__:
|
||||
item_type = item.get('type')
|
||||
item_id = item.get('id')
|
||||
|
||||
if item_type == 'collection':
|
||||
knowledge = Knowledges.get_knowledge_by_id(item_id)
|
||||
if knowledge and (
|
||||
user_role == 'admin'
|
||||
or knowledge.user_id == user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge.id,
|
||||
permission='read',
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
kb_files = Knowledges.get_files_by_id(knowledge.id)
|
||||
file_count = len(kb_files) if kb_files else 0
|
||||
|
||||
kb_entry = {
|
||||
'id': knowledge.id,
|
||||
'name': knowledge.name,
|
||||
'description': knowledge.description or '',
|
||||
'file_count': file_count,
|
||||
}
|
||||
|
||||
# Include file listing for each KB
|
||||
if kb_files:
|
||||
kb_entry['files'] = [{'id': f.id, 'filename': f.filename} for f in kb_files]
|
||||
|
||||
knowledge_bases.append(kb_entry)
|
||||
|
||||
elif item_type == 'file':
|
||||
file = Files.get_file_by_id(item_id)
|
||||
if file:
|
||||
files.append(
|
||||
{
|
||||
'id': file.id,
|
||||
'filename': file.filename,
|
||||
'updated_at': file.updated_at,
|
||||
}
|
||||
)
|
||||
|
||||
elif item_type == 'note':
|
||||
note = Notes.get_note_by_id(item_id)
|
||||
if note and (
|
||||
user_role == 'admin'
|
||||
or note.user_id == user_id
|
||||
or AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
)
|
||||
):
|
||||
notes.append(
|
||||
{
|
||||
'id': note.id,
|
||||
'title': note.title,
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'knowledge_bases': knowledge_bases,
|
||||
'files': files,
|
||||
'notes': notes,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'list_knowledge error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
async def query_knowledge_files(
|
||||
query: str,
|
||||
knowledge_ids: Optional[list[str]] = None,
|
||||
|
|
@ -2063,3 +2326,174 @@ async def view_skill(
|
|||
except Exception as e:
|
||||
log.exception(f'view_skill error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TASK MANAGEMENT TOOLS
|
||||
# =============================================================================
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal
|
||||
|
||||
VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'}
|
||||
|
||||
|
||||
class TaskItem(BaseModel):
|
||||
id: Optional[str] = Field(None, description="Unique identifier for the task. Auto-generated if omitted.")
|
||||
content: Optional[str] = Field(None, description="Task description. Aliases: title, name, description.")
|
||||
status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description="Task status.")
|
||||
|
||||
|
||||
async def tasks(
|
||||
tasks: Optional[list[TaskItem]] = None,
|
||||
overwrite: bool = True,
|
||||
__chat_id__: str = None,
|
||||
__message_id__: str = None,
|
||||
__event_emitter__: callable = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Track progress on multi-step work by maintaining a task checklist.
|
||||
Use this whenever a request involves multiple steps or could take
|
||||
significant effort. Call to set the full list, then call again
|
||||
with overwrite=false after completing each task to mark it
|
||||
completed. Do not leave tasks in_progress when the work is done.
|
||||
Each task has an id, content, and status (pending, in_progress,
|
||||
completed, cancelled).
|
||||
|
||||
:param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying.
|
||||
:param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones.
|
||||
:return: JSON with the full task list and summary counts
|
||||
"""
|
||||
if __chat_id__ is None:
|
||||
return json.dumps({'error': 'Chat context not available'})
|
||||
|
||||
try:
|
||||
|
||||
def _to_dict(task) -> dict:
|
||||
"""Convert TaskItem or dict to plain dict."""
|
||||
if hasattr(task, 'model_dump'):
|
||||
d = task.model_dump(exclude_none=True)
|
||||
# Include any extra fields the model sent
|
||||
if hasattr(task, 'model_extra') and task.model_extra:
|
||||
d.update(task.model_extra)
|
||||
return d
|
||||
return dict(task) if not isinstance(task, dict) else task
|
||||
|
||||
def _resolve_content(d: dict) -> str:
|
||||
"""Accept content, title, name, or description as the task text."""
|
||||
for key in ('content', 'title', 'name', 'description'):
|
||||
val = str(d.get(key, '')).strip()
|
||||
if val:
|
||||
return val
|
||||
return ''
|
||||
|
||||
def _resolve_id(d: dict, idx: int) -> str:
|
||||
"""Use provided id, or auto-generate from index."""
|
||||
item_id = str(d.get('id', '') or '').strip()
|
||||
return item_id if item_id else str(idx + 1)
|
||||
|
||||
if tasks is None:
|
||||
# Read-only - return current list
|
||||
all_tasks = Chats.get_chat_tasks_by_id(__chat_id__)
|
||||
elif overwrite:
|
||||
# Full replacement - validate and write
|
||||
all_tasks = []
|
||||
for idx, task in enumerate(tasks):
|
||||
d = _to_dict(task)
|
||||
item_id = _resolve_id(d, idx)
|
||||
content = _resolve_content(d)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
status = str(d.get('status', 'pending')).strip().lower()
|
||||
if status not in VALID_TASK_STATUSES:
|
||||
status = 'pending'
|
||||
|
||||
all_tasks.append(
|
||||
{
|
||||
'id': item_id,
|
||||
'content': content,
|
||||
'status': status,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Partial update - merge by id
|
||||
existing_tasks = Chats.get_chat_tasks_by_id(__chat_id__)
|
||||
existing_by_id = {t['id']: t for t in existing_tasks}
|
||||
|
||||
seen_ids = set()
|
||||
for idx, task in enumerate(tasks):
|
||||
d = _to_dict(task)
|
||||
item_id = _resolve_id(d, len(existing_tasks) + idx)
|
||||
|
||||
seen_ids.add(item_id)
|
||||
|
||||
if item_id in existing_by_id:
|
||||
resolved = _resolve_content(d)
|
||||
if resolved:
|
||||
existing_by_id[item_id]['content'] = resolved
|
||||
status = str(d.get('status', '')).strip().lower()
|
||||
if status and status in VALID_TASK_STATUSES:
|
||||
existing_by_id[item_id]['status'] = status
|
||||
else:
|
||||
content = _resolve_content(d)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
status = str(d.get('status', 'pending')).strip().lower()
|
||||
if status not in VALID_TASK_STATUSES:
|
||||
status = 'pending'
|
||||
|
||||
existing_by_id[item_id] = {
|
||||
'id': item_id,
|
||||
'content': content,
|
||||
'status': status,
|
||||
}
|
||||
|
||||
# Preserve order of existing, append new
|
||||
all_tasks = []
|
||||
for t in existing_tasks:
|
||||
if t['id'] in existing_by_id:
|
||||
all_tasks.append(existing_by_id[t['id']])
|
||||
for item_id in seen_ids:
|
||||
if not any(t['id'] == item_id for t in existing_tasks):
|
||||
all_tasks.append(existing_by_id[item_id])
|
||||
|
||||
# Persist to DB and emit (skip for read-only)
|
||||
if tasks is not None:
|
||||
Chats.update_chat_tasks_by_id(__chat_id__, all_tasks)
|
||||
|
||||
if __event_emitter__:
|
||||
await __event_emitter__(
|
||||
{
|
||||
'type': 'chat:message:tasks',
|
||||
'data': {
|
||||
'tasks': all_tasks,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Build summary counts
|
||||
pending = sum(1 for t in all_tasks if t['status'] == 'pending')
|
||||
in_progress = sum(1 for t in all_tasks if t['status'] == 'in_progress')
|
||||
completed = sum(1 for t in all_tasks if t['status'] == 'completed')
|
||||
cancelled = sum(1 for t in all_tasks if t['status'] == 'cancelled')
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'tasks': all_tasks,
|
||||
'summary': {
|
||||
'total': len(all_tasks),
|
||||
'pending': pending,
|
||||
'in_progress': in_progress,
|
||||
'completed': completed,
|
||||
'cancelled': cancelled,
|
||||
},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'tasks error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
|
|
|
|||
|
|
@ -226,7 +226,9 @@ def filter_allowed_access_grants(
|
|||
return access_grants
|
||||
|
||||
# Check if user can share publicly
|
||||
if (has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)) and not has_permission(
|
||||
if (
|
||||
has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants)
|
||||
) and not has_permission(
|
||||
user_id,
|
||||
public_permission_key,
|
||||
default_permissions,
|
||||
|
|
|
|||
|
|
@ -143,8 +143,15 @@ def get_license_data(app, key):
|
|||
pn, pt = nt(pb)
|
||||
|
||||
data = json.loads(aesgcm.decrypt(pn, pt, None).decode())
|
||||
if not data.get('exp') or data.get('exp') < datetime.now().date():
|
||||
return False
|
||||
|
||||
exp = data.get('exp')
|
||||
if exp:
|
||||
if isinstance(exp, str):
|
||||
from datetime import date
|
||||
|
||||
exp = date.fromisoformat(exp)
|
||||
if exp < datetime.now().date():
|
||||
return False
|
||||
|
||||
data_handler(data)
|
||||
return True
|
||||
|
|
@ -188,6 +195,9 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
# Let the one who signed this token be remembered at every gate,
|
||||
# and may the claims therein honor the creator long after
|
||||
# the session has closed.
|
||||
def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str:
|
||||
payload = data.copy()
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# When the question has been asked, let silence not be the
|
||||
# answer. But if the answer must wait, let it come honest.
|
||||
async def generate_direct_chat_completion(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
|
|
@ -201,27 +203,41 @@ async def generate_chat_completion(
|
|||
except Exception as e:
|
||||
raise e
|
||||
|
||||
if model.get('owned_by') == 'arena':
|
||||
# Arena model — sub-model was already resolved by process_chat_payload.
|
||||
# Inject selected_model_id into the response for the frontend.
|
||||
metadata = form_data.get('metadata', {})
|
||||
selected_model_id = metadata.pop('selected_model_id', None)
|
||||
# Also clear from request.state.metadata to prevent the merge at
|
||||
# lines 177-179 from re-adding it on the recursive call.
|
||||
if hasattr(request.state, 'metadata'):
|
||||
request.state.metadata.pop('selected_model_id', None)
|
||||
|
||||
# Fallback: if generate_chat_completion is called with an arena model
|
||||
# from a path that did NOT go through process_chat_payload (e.g.,
|
||||
# background tasks for title/follow-up/tags generation), resolve now.
|
||||
if not selected_model_id and model.get('owned_by') == 'arena':
|
||||
model_ids = model.get('info', {}).get('meta', {}).get('model_ids')
|
||||
filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode')
|
||||
if model_ids and filter_mode == 'exclude':
|
||||
model_ids = [
|
||||
model['id']
|
||||
for model in list(request.app.state.MODELS.values())
|
||||
if model.get('owned_by') != 'arena' and model['id'] not in model_ids
|
||||
available_model['id']
|
||||
for available_model in list(request.app.state.MODELS.values())
|
||||
if available_model.get('owned_by') != 'arena' and available_model['id'] not in model_ids
|
||||
]
|
||||
|
||||
selected_model_id = None
|
||||
if isinstance(model_ids, list) and model_ids:
|
||||
selected_model_id = random.choice(model_ids)
|
||||
else:
|
||||
model_ids = [
|
||||
model['id'] for model in list(request.app.state.MODELS.values()) if model.get('owned_by') != 'arena'
|
||||
available_model['id']
|
||||
for available_model in list(request.app.state.MODELS.values())
|
||||
if available_model.get('owned_by') != 'arena'
|
||||
]
|
||||
selected_model_id = random.choice(model_ids)
|
||||
|
||||
form_data['model'] = selected_model_id
|
||||
|
||||
if selected_model_id:
|
||||
if form_data.get('stream') == True:
|
||||
|
||||
async def stream_wrapper(stream):
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
|
|||
|
||||
|
||||
def get_file_url_from_base64(request, base64_file_string, metadata, user):
|
||||
if 'data:image/png;base64' in base64_file_string:
|
||||
if BASE64_IMAGE_URL_PREFIX.match(base64_file_string):
|
||||
return get_image_url_from_base64(request, base64_file_string, metadata, user)
|
||||
elif 'data:audio/wav;base64' in base64_file_string:
|
||||
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
|
||||
|
|
|
|||
|
|
@ -34,17 +34,17 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
|
|||
if 'info' in model and 'meta' in model['info']:
|
||||
filter_ids.extend(model['info']['meta'].get('filterIds', []))
|
||||
filter_ids = list(set(filter_ids))
|
||||
active_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)]
|
||||
active_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)}
|
||||
|
||||
def get_active_status(filter_id):
|
||||
function_module = get_function_module(request, filter_id)
|
||||
|
||||
if getattr(function_module, 'toggle', None):
|
||||
return filter_id in (enabled_filter_ids or [])
|
||||
return filter_id in (enabled_filter_ids or set())
|
||||
|
||||
return True
|
||||
|
||||
active_filter_ids = [filter_id for filter_id in active_filter_ids if get_active_status(filter_id)]
|
||||
active_filter_ids = {filter_id for filter_id in active_filter_ids if get_active_status(filter_id)}
|
||||
|
||||
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
|
||||
filter_ids.sort(key=lambda fid: (get_priority(fid), fid))
|
||||
|
|
@ -52,6 +52,8 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None)
|
|||
return filter_ids
|
||||
|
||||
|
||||
# Grant these filters the discernment to pass what serves
|
||||
# and refuse what harms, for every soul in the house.
|
||||
async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params):
|
||||
skip_files = None
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ def start_logger():
|
|||
"""
|
||||
logger.remove()
|
||||
|
||||
audit_filter = lambda record: (True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra'])
|
||||
audit_filter = lambda record: True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra']
|
||||
if LOG_FORMAT == 'json':
|
||||
logger.add(
|
||||
_json_sink,
|
||||
|
|
|
|||
|
|
@ -9,22 +9,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage
|
|||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
import httpx
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER
|
||||
|
||||
|
||||
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
||||
"""Create an httpx AsyncClient with SSL verification disabled.
|
||||
def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True):
|
||||
"""Create an httpx AsyncClient for MCP transport.
|
||||
|
||||
Note: verify=False must be passed at construction time because httpx
|
||||
Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller
|
||||
(i.e. the MCP SDK) does not supply an explicit timeout.
|
||||
|
||||
Note: verify must be passed at construction time because httpx
|
||||
configures the SSL context during __init__. Setting client.verify = False
|
||||
after construction does not affect the underlying transport's SSL context.
|
||||
"""
|
||||
kwargs = {
|
||||
'follow_redirects': True,
|
||||
'verify': False,
|
||||
'verify': verify,
|
||||
}
|
||||
if timeout is not None:
|
||||
kwargs['timeout'] = timeout
|
||||
elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None:
|
||||
kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
|
||||
if headers is not None:
|
||||
kwargs['headers'] = headers
|
||||
if auth is not None:
|
||||
|
|
@ -32,6 +37,14 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
|||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
|
||||
def create_httpx_client(headers=None, timeout=None, auth=None):
|
||||
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True)
|
||||
|
||||
|
||||
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
|
||||
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self):
|
||||
self.session: Optional[ClientSession] = None
|
||||
|
|
@ -40,14 +53,11 @@ class MCPClient:
|
|||
async def connect(self, url: str, headers: Optional[dict] = None):
|
||||
async with AsyncExitStack() as exit_stack:
|
||||
try:
|
||||
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL:
|
||||
self._streams_context = streamablehttp_client(url, headers=headers)
|
||||
else:
|
||||
self._streams_context = streamablehttp_client(
|
||||
url,
|
||||
headers=headers,
|
||||
httpx_client_factory=create_insecure_httpx_client,
|
||||
)
|
||||
self._streams_context = streamablehttp_client(
|
||||
url,
|
||||
headers=headers,
|
||||
httpx_client_factory=create_httpx_client if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL else create_insecure_httpx_client,
|
||||
)
|
||||
|
||||
transport = await exit_stack.enter_async_context(self._streams_context)
|
||||
read_stream, write_stream, _ = transport
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ from open_webui.utils.misc import (
|
|||
get_last_user_message_item,
|
||||
get_last_assistant_message,
|
||||
get_system_message,
|
||||
merge_system_messages,
|
||||
replace_system_message_content,
|
||||
prepend_to_first_user_message_content,
|
||||
convert_logit_bias_input_to_json,
|
||||
|
|
@ -146,6 +147,10 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# We believe in one maker of all models, seen and unseen,
|
||||
# and in the reasoning which proceeds from the architect.
|
||||
# We look for the resurrection of dead processes and the
|
||||
# inference of the world to come.
|
||||
DEFAULT_REASONING_TAGS = [
|
||||
('<think>', '</think>'),
|
||||
('<thinking>', '</thinking>'),
|
||||
|
|
@ -228,7 +233,7 @@ 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'}
|
||||
_EXPECTS_DICT = {'view_knowledge_file', 'view_file'}
|
||||
|
||||
try:
|
||||
try:
|
||||
|
|
@ -272,7 +277,7 @@ def get_citation_source_from_tool_result(
|
|||
}
|
||||
]
|
||||
|
||||
elif tool_name == 'view_knowledge_file':
|
||||
elif tool_name in ('view_knowledge_file', 'view_file'):
|
||||
file_data = tool_result
|
||||
filename = file_data.get('filename', 'Unknown File')
|
||||
file_id = file_data.get('id', '')
|
||||
|
|
@ -1050,6 +1055,13 @@ def process_tool_result(
|
|||
|
||||
tool_result_files = []
|
||||
|
||||
# Detect base64 image data URIs from tool results (e.g. binary image
|
||||
# responses from execute_tool_server). Move the data URI to
|
||||
# tool_result_files and replace tool_result with a text summary.
|
||||
if isinstance(tool_result, str) and tool_result.startswith('data:image/'):
|
||||
tool_result_files.append({'type': 'image', 'url': tool_result})
|
||||
tool_result = f'{tool_function_name}: Image file read successfully.'
|
||||
|
||||
if isinstance(tool_result, list):
|
||||
if tool_type == 'mcp': # MCP
|
||||
tool_response = []
|
||||
|
|
@ -1447,7 +1459,7 @@ async def chat_web_search_handler(request: Request, form_data: dict, extra_param
|
|||
response = res['choices'][0]['message']['content']
|
||||
|
||||
try:
|
||||
bracket_start = response.find('{')
|
||||
bracket_start = response.rfind('{')
|
||||
bracket_end = response.rfind('}') + 1
|
||||
|
||||
if bracket_start == -1 or bracket_end == -1:
|
||||
|
|
@ -1634,7 +1646,16 @@ def add_file_context(messages: list, chat_id: str, user) -> list:
|
|||
attrs += f' name="{file["name"]}"'
|
||||
return f'<file {attrs}/>'
|
||||
|
||||
for message, stored_message in zip(messages, stored_messages):
|
||||
# Pair only user-role messages from both lists to avoid misalignment.
|
||||
# After process_messages_with_output(), assistant messages with tool calls
|
||||
# are expanded into multiple messages (assistant + tool results), making
|
||||
# the payload message list longer than the stored message list. A naive
|
||||
# positional zip() would pair user messages with wrong stored messages,
|
||||
# causing later images to lose their file context (see #21878).
|
||||
user_messages = [m for m in messages if m.get('role') == 'user']
|
||||
stored_user_messages = [m for m in stored_messages if m.get('role') == 'user']
|
||||
|
||||
for message, stored_message in zip(user_messages, stored_user_messages):
|
||||
files_with_urls = [
|
||||
file
|
||||
for file in stored_message.get('files', [])
|
||||
|
|
@ -1769,7 +1790,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
|||
response = res['choices'][0]['message']['content']
|
||||
|
||||
try:
|
||||
bracket_start = response.find('{')
|
||||
bracket_start = response.rfind('{')
|
||||
bracket_end = response.rfind('}') + 1
|
||||
|
||||
if bracket_start == -1 or bracket_end == -1:
|
||||
|
|
@ -1873,7 +1894,7 @@ async def chat_completion_files_handler(
|
|||
queries_response = queries_response['choices'][0]['message']['content']
|
||||
|
||||
try:
|
||||
bracket_start = queries_response.find('{')
|
||||
bracket_start = queries_response.rfind('{')
|
||||
bracket_end = queries_response.rfind('}') + 1
|
||||
|
||||
if bracket_start == -1 or bracket_end == -1:
|
||||
|
|
@ -2095,6 +2116,35 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
|
||||
# -> Chat Files
|
||||
|
||||
# Arena model resolution — pick the sub-model now so all downstream
|
||||
# processing (knowledge, capabilities, tools, params) uses its settings
|
||||
# instead of the empty arena wrapper.
|
||||
if model.get('owned_by') == 'arena':
|
||||
arena_model_ids = model.get('info', {}).get('meta', {}).get('model_ids')
|
||||
arena_filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode')
|
||||
if arena_model_ids and arena_filter_mode == 'exclude':
|
||||
arena_model_ids = [
|
||||
available_model['id']
|
||||
for available_model in request.app.state.MODELS.values()
|
||||
if available_model.get('owned_by') != 'arena' and available_model['id'] not in arena_model_ids
|
||||
]
|
||||
|
||||
if isinstance(arena_model_ids, list) and arena_model_ids:
|
||||
selected_model_id = random.choice(arena_model_ids)
|
||||
else:
|
||||
arena_model_ids = [
|
||||
available_model['id']
|
||||
for available_model in request.app.state.MODELS.values()
|
||||
if available_model.get('owned_by') != 'arena'
|
||||
]
|
||||
selected_model_id = random.choice(arena_model_ids)
|
||||
|
||||
selected_model = request.app.state.MODELS.get(selected_model_id)
|
||||
if selected_model:
|
||||
model = selected_model
|
||||
form_data['model'] = selected_model_id
|
||||
metadata['selected_model_id'] = selected_model_id
|
||||
|
||||
form_data = apply_params_to_form_data(form_data, model)
|
||||
log.debug(f'form_data: {form_data}')
|
||||
|
||||
|
|
@ -2564,12 +2614,17 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# so system terminals work even when no other tools are selected)
|
||||
if terminal_id:
|
||||
try:
|
||||
terminal_tools, system_prompt = await get_terminal_tools(
|
||||
terminal_result = await get_terminal_tools(
|
||||
request,
|
||||
terminal_id,
|
||||
user,
|
||||
extra_params,
|
||||
)
|
||||
if isinstance(terminal_result, tuple):
|
||||
terminal_tools, system_prompt = terminal_result
|
||||
else:
|
||||
terminal_tools = terminal_result
|
||||
system_prompt = None
|
||||
if terminal_tools:
|
||||
tools_dict = {**tools_dict, **terminal_tools}
|
||||
if system_prompt:
|
||||
|
|
@ -2692,6 +2747,10 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# to prevent errors from providers like Gemini and Claude
|
||||
form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', []))
|
||||
|
||||
# Merge any duplicate system messages into a single message at position 0
|
||||
# to prevent template parsing errors with strict chat templates (e.g. Qwen)
|
||||
form_data['messages'] = merge_system_messages(form_data.get('messages', []))
|
||||
|
||||
return form_data, metadata, events
|
||||
|
||||
|
||||
|
|
@ -3094,7 +3153,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if not Users.is_user_active(user.id):
|
||||
if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id):
|
||||
webhook_url = Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
|
|
@ -3530,7 +3589,6 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
# Check for Responses API events (type field starts with "response.")
|
||||
elif data.get('type', '').startswith('response.'):
|
||||
|
||||
output, response_metadata = handle_responses_streaming_event(data, output)
|
||||
|
||||
processed_data = {
|
||||
|
|
@ -3679,12 +3737,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
'status': 'in_progress',
|
||||
}
|
||||
)
|
||||
pending_output = output + pending_fc_items
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'chat:completion',
|
||||
'data': {
|
||||
'content': serialize_output(pending_output),
|
||||
'content': serialize_output(full_output() + pending_fc_items),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -3743,7 +3801,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
}
|
||||
]
|
||||
|
||||
data = {'content': serialize_output(output)}
|
||||
data = {'content': serialize_output(full_output())}
|
||||
|
||||
if value:
|
||||
if (
|
||||
|
|
@ -3969,19 +4027,20 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
}
|
||||
responses_api_tool_calls = []
|
||||
for item in output:
|
||||
if (
|
||||
item.get('type') == 'function_call'
|
||||
and item.get('call_id') not in handled_call_ids
|
||||
):
|
||||
if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids:
|
||||
arguments = item.get('arguments', '{}')
|
||||
responses_api_tool_calls.append({
|
||||
'id': item.get('call_id', ''),
|
||||
'index': len(responses_api_tool_calls),
|
||||
'function': {
|
||||
'name': item.get('name', ''),
|
||||
'arguments': arguments if isinstance(arguments, str) else json.dumps(arguments),
|
||||
},
|
||||
})
|
||||
responses_api_tool_calls.append(
|
||||
{
|
||||
'id': item.get('call_id', ''),
|
||||
'index': len(responses_api_tool_calls),
|
||||
'function': {
|
||||
'name': item.get('name', ''),
|
||||
'arguments': (
|
||||
arguments if isinstance(arguments, str) else json.dumps(arguments)
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
if responses_api_tool_calls:
|
||||
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
|
||||
|
||||
|
|
@ -4017,10 +4076,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
|
||||
# Append function_call items for each tool call
|
||||
# (Responses API already has them from streaming, so skip duplicates)
|
||||
existing_call_ids = {
|
||||
item.get('call_id') for item in output
|
||||
if item.get('type') == 'function_call'
|
||||
}
|
||||
existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'}
|
||||
for tc in response_tool_calls:
|
||||
call_id = tc.get('id', '')
|
||||
if call_id not in existing_call_ids:
|
||||
|
|
@ -4150,6 +4206,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
in [
|
||||
'search_web',
|
||||
'fetch_url',
|
||||
'view_file',
|
||||
'view_knowledge_file',
|
||||
'query_knowledge_files',
|
||||
]
|
||||
|
|
@ -4187,19 +4244,27 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
break
|
||||
|
||||
for result in results:
|
||||
output_parts = [{'type': 'input_text', 'text': result.get('content', '')}]
|
||||
|
||||
# Separate image data URIs (for LLM via input_image) from
|
||||
# other files (for frontend display via files attribute).
|
||||
display_files = []
|
||||
for file_item in result.get('files', []):
|
||||
if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'):
|
||||
# LLM-only: add as input_image part (invisible to serialize_output)
|
||||
output_parts.append({'type': 'input_image', 'image_url': file_item['url']})
|
||||
else:
|
||||
# Frontend display (MCP images, audio, etc.)
|
||||
display_files.append(file_item)
|
||||
|
||||
output.append(
|
||||
{
|
||||
'type': 'function_call_output',
|
||||
'id': output_id('fco'),
|
||||
'call_id': result.get('tool_call_id', ''),
|
||||
'output': [
|
||||
{
|
||||
'type': 'input_text',
|
||||
'text': result.get('content', ''),
|
||||
}
|
||||
],
|
||||
'output': output_parts,
|
||||
'status': 'completed',
|
||||
**({'files': result.get('files')} if result.get('files') else {}),
|
||||
**({'files': display_files} if display_files else {}),
|
||||
**({'embeds': result.get('embeds')} if result.get('embeds') else {}),
|
||||
}
|
||||
)
|
||||
|
|
@ -4268,12 +4333,23 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
tool_call_sources.clear()
|
||||
|
||||
# Strip input_image parts (large base64 data URIs) from the
|
||||
# output sent to the frontend — they're only for LLM consumption
|
||||
# via convert_output_to_messages.
|
||||
frontend_output = []
|
||||
for item in output:
|
||||
if item.get('type') == 'function_call_output':
|
||||
parts = item.get('output', [])
|
||||
if any(p.get('type') == 'input_image' for p in parts):
|
||||
item = {**item, 'output': [p for p in parts if p.get('type') != 'input_image']}
|
||||
frontend_output.append(item)
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
'type': 'chat:completion',
|
||||
'data': {
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
'output': frontend_output,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -4288,16 +4364,44 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
if ENABLE_RESPONSES_API_STATEFUL and last_response_id:
|
||||
system_message = get_system_message(form_data['messages'])
|
||||
new_form_data['messages'] = (
|
||||
([system_message] if system_message else [])
|
||||
+ convert_output_to_messages(output, raw=True)
|
||||
)
|
||||
[system_message] if system_message else []
|
||||
) + convert_output_to_messages(output, raw=True)
|
||||
new_form_data['previous_response_id'] = last_response_id
|
||||
else:
|
||||
tool_messages = convert_output_to_messages(output, raw=True)
|
||||
|
||||
# Chat Completions providers don't support multimodal
|
||||
# tool messages. Extract images into a user message.
|
||||
image_urls = []
|
||||
for message in tool_messages:
|
||||
if message.get('role') == 'tool' and isinstance(message.get('content'), list):
|
||||
text_parts = []
|
||||
for part in message['content']:
|
||||
if part.get('type') == 'input_text':
|
||||
text_parts.append(part.get('text', ''))
|
||||
elif part.get('type') == 'input_image':
|
||||
image_urls.append(part.get('image_url', ''))
|
||||
message['content'] = ''.join(text_parts)
|
||||
|
||||
new_form_data['messages'] = [
|
||||
*form_data['messages'],
|
||||
*convert_output_to_messages(output, raw=True),
|
||||
*tool_messages,
|
||||
]
|
||||
|
||||
if image_urls:
|
||||
new_form_data['messages'].append(
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'Here are the images from the tool results above. Please analyze them.',
|
||||
},
|
||||
*[{'type': 'image_url', 'image_url': {'url': url}} for url in image_urls],
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
res = await generate_chat_completion(
|
||||
request,
|
||||
new_form_data,
|
||||
|
|
@ -4322,10 +4426,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
and prior_output[-1].get('status') == 'in_progress'
|
||||
):
|
||||
msg_parts = prior_output[-1].get('content', [])
|
||||
if (
|
||||
not msg_parts
|
||||
or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip())
|
||||
):
|
||||
if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()):
|
||||
prior_output.pop()
|
||||
output = []
|
||||
await stream_body_handler(res, new_form_data)
|
||||
|
|
@ -4364,7 +4465,8 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
code = sanitize_code(code)
|
||||
|
||||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
blocking_code = textwrap.dedent(f"""
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
import builtins
|
||||
|
||||
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
|
||||
|
|
@ -4380,7 +4482,8 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
return _real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = restricted_import
|
||||
""")
|
||||
"""
|
||||
)
|
||||
code = blocking_code + '\n' + code
|
||||
|
||||
if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide':
|
||||
|
|
@ -4422,7 +4525,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
if isinstance(stdout, str):
|
||||
stdoutLines = stdout.split('\n')
|
||||
for idx, line in enumerate(stdoutLines):
|
||||
if 'data:image/png;base64' in line:
|
||||
if re.match(r'data:image/\w+;base64', line):
|
||||
image_url = get_image_url_from_base64(
|
||||
request,
|
||||
line,
|
||||
|
|
@ -4439,7 +4542,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
if isinstance(result, str):
|
||||
resultLines = result.split('\n')
|
||||
for idx, line in enumerate(resultLines):
|
||||
if 'data:image/png;base64' in line:
|
||||
if re.match(r'data:image/\w+;base64', line):
|
||||
image_url = get_image_url_from_base64(
|
||||
request,
|
||||
line,
|
||||
|
|
@ -4539,7 +4642,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if not Users.is_user_active(user.id):
|
||||
if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id):
|
||||
webhook_url = Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
|
|
|
|||
|
|
@ -196,21 +196,39 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
|||
# Flush any pending content/tool_calls before adding tool result
|
||||
flush_pending()
|
||||
|
||||
# Extract text from output content parts
|
||||
# Extract text and images from output content parts
|
||||
output_parts = item.get('output', [])
|
||||
content = ''
|
||||
image_urls = []
|
||||
for part in output_parts:
|
||||
if part.get('type') == 'input_text':
|
||||
output_text = part.get('text', '')
|
||||
content += str(output_text) if not isinstance(output_text, str) else output_text
|
||||
elif part.get('type') == 'input_image':
|
||||
url = part.get('image_url', '')
|
||||
if url:
|
||||
image_urls.append(url)
|
||||
|
||||
messages.append(
|
||||
{
|
||||
'role': 'tool',
|
||||
'tool_call_id': item.get('call_id', ''),
|
||||
'content': content,
|
||||
}
|
||||
)
|
||||
if image_urls:
|
||||
# Multimodal tool content with image(s)
|
||||
messages.append(
|
||||
{
|
||||
'role': 'tool',
|
||||
'tool_call_id': item.get('call_id', ''),
|
||||
'content': [
|
||||
{'type': 'input_text', 'text': content},
|
||||
*[{'type': 'input_image', 'image_url': url} for url in image_urls],
|
||||
],
|
||||
}
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
{
|
||||
'role': 'tool',
|
||||
'tool_call_id': item.get('call_id', ''),
|
||||
'content': content,
|
||||
}
|
||||
)
|
||||
|
||||
elif item_type == 'reasoning':
|
||||
if raw:
|
||||
|
|
@ -357,6 +375,33 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
|
|||
return get_system_message(messages), remove_system_message(messages)
|
||||
|
||||
|
||||
def merge_system_messages(messages: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Merge all system messages into one at position 0.
|
||||
|
||||
Some chat templates (e.g. Qwen) require exactly one system
|
||||
message at the start. Multiple pipeline stages may each
|
||||
insert their own system message; this function consolidates
|
||||
them.
|
||||
"""
|
||||
system_contents: list[str] = []
|
||||
other_messages: list[dict] = []
|
||||
|
||||
for message in messages:
|
||||
if message.get('role') == 'system':
|
||||
content = get_content_from_message(message)
|
||||
if content:
|
||||
system_contents.append(content)
|
||||
else:
|
||||
other_messages.append(message)
|
||||
|
||||
if not system_contents:
|
||||
return other_messages
|
||||
|
||||
merged = {'role': 'system', 'content': '\n'.join(system_contents)}
|
||||
return [merged, *other_messages]
|
||||
|
||||
|
||||
def update_message_content(message: dict, content: str, append: bool = True) -> dict:
|
||||
if isinstance(message['content'], list):
|
||||
for item in message['content']:
|
||||
|
|
@ -460,11 +505,7 @@ def strip_empty_content_blocks(messages: list[dict]) -> list[dict]:
|
|||
cleaned = [
|
||||
block
|
||||
for block in content
|
||||
if not (
|
||||
isinstance(block, dict)
|
||||
and block.get('type') == 'text'
|
||||
and not block.get('text', '').strip()
|
||||
)
|
||||
if not (isinstance(block, dict) and block.get('type') == 'text' and not block.get('text', '').strip())
|
||||
]
|
||||
if cleaned:
|
||||
message['content'] = cleaned
|
||||
|
|
@ -548,6 +589,10 @@ def get_gravatar_url(email):
|
|||
return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp'
|
||||
|
||||
|
||||
# Give us each day the data we require, and forgive us our
|
||||
# technical debts as we forgive those who commit upstream.
|
||||
# Lead the bits not into corruption but deliver them from
|
||||
# entropy, for the checksum and the glory are forever.
|
||||
def calculate_sha256(file_path, chunk_size):
|
||||
# Compute SHA-256 hash of a file efficiently in chunks
|
||||
sha256 = hashlib.sha256()
|
||||
|
|
@ -897,6 +942,9 @@ def extract_urls(text: str) -> list[str]:
|
|||
return url_pattern.findall(text)
|
||||
|
||||
|
||||
# We believe in one architect of all that is seen and served.
|
||||
# Should this stream falter, it shall be raised again on the
|
||||
# third retry. We look for the uptime of the world to come.
|
||||
async def cleanup_response(
|
||||
response: Optional[aiohttp.ClientResponse],
|
||||
session: Optional[aiohttp.ClientSession],
|
||||
|
|
|
|||
|
|
@ -130,11 +130,11 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
]
|
||||
models = models + arena_models
|
||||
|
||||
global_action_ids = [function.id for function in Functions.get_global_action_functions()]
|
||||
enabled_action_ids = [function.id for function in Functions.get_functions_by_type('action', active_only=True)]
|
||||
global_action_ids = {function.id for function in Functions.get_global_action_functions()}
|
||||
enabled_action_ids = {function.id for function in Functions.get_functions_by_type('action', active_only=True)}
|
||||
|
||||
global_filter_ids = [function.id for function in Functions.get_global_filter_functions()]
|
||||
enabled_filter_ids = [function.id for function in Functions.get_functions_by_type('filter', active_only=True)]
|
||||
global_filter_ids = {function.id for function in Functions.get_global_filter_functions()}
|
||||
enabled_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)}
|
||||
|
||||
custom_models = Models.get_all_models()
|
||||
|
||||
|
|
@ -328,14 +328,14 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
for model in models:
|
||||
action_ids = [
|
||||
action_id
|
||||
for action_id in list(set(model.pop('action_ids', []) + global_action_ids))
|
||||
for action_id in set(model.pop('action_ids', [])) | global_action_ids
|
||||
if action_id in enabled_action_ids
|
||||
]
|
||||
action_ids.sort(key=lambda aid: (get_action_priority(aid), aid))
|
||||
|
||||
filter_ids = [
|
||||
filter_id
|
||||
for filter_id in list(set(model.pop('filter_ids', []) + global_filter_ids))
|
||||
for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids
|
||||
if filter_id in enabled_filter_ids
|
||||
]
|
||||
|
||||
|
|
@ -452,6 +452,10 @@ def get_filtered_models(models, user, db=None):
|
|||
or model['id'] in accessible_model_ids
|
||||
):
|
||||
filtered_models.append(model)
|
||||
elif user.role == 'admin':
|
||||
# No DB entry means no access control configured yet;
|
||||
# only admins can see unconfigured models.
|
||||
filtered_models.append(model)
|
||||
|
||||
return filtered_models
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from open_webui.config import (
|
|||
OAUTH_UPDATE_EMAIL_ON_LOGIN,
|
||||
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
|
||||
OAUTH_AUDIENCE,
|
||||
OAUTH_AUTHORIZE_PARAMS,
|
||||
WEBHOOK_URL,
|
||||
JWT_EXPIRES_IN,
|
||||
AppConfig,
|
||||
|
|
@ -266,11 +267,11 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
|
|||
) as response:
|
||||
if response.status == 401:
|
||||
match = re.search(
|
||||
r'resource_metadata="([^"]+)"',
|
||||
r'resource_metadata=(?:"([^"]+)"|([^\s,]+))',
|
||||
response.headers.get('WWW-Authenticate', ''),
|
||||
)
|
||||
if match:
|
||||
resource_metadata_url = match.group(1)
|
||||
resource_metadata_url = match.group(1) or match.group(2)
|
||||
log.debug(f'Found resource_metadata URL: {resource_metadata_url}')
|
||||
|
||||
# Step 2: Fetch Protected Resource metadata
|
||||
|
|
@ -441,6 +442,74 @@ async def get_oauth_client_info_with_dynamic_client_registration(
|
|||
raise e
|
||||
|
||||
|
||||
async def get_oauth_client_info_with_static_credentials(
|
||||
request,
|
||||
client_id: str,
|
||||
oauth_server_url: str,
|
||||
oauth_client_id: str,
|
||||
oauth_client_secret: str,
|
||||
) -> OAuthClientInformationFull:
|
||||
"""
|
||||
Build an OAuthClientInformationFull from user-provided static credentials.
|
||||
Performs server metadata discovery to resolve authorization/token endpoints,
|
||||
but skips dynamic client registration entirely.
|
||||
"""
|
||||
try:
|
||||
oauth_server_metadata = None
|
||||
oauth_server_metadata_url = None
|
||||
|
||||
redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/')
|
||||
redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback'
|
||||
|
||||
# Discover server metadata (authorization endpoint, token endpoint, scopes, etc.)
|
||||
discovery_urls = await get_discovery_urls(oauth_server_url)
|
||||
for url in discovery_urls:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
|
||||
if resp.status == 200:
|
||||
try:
|
||||
oauth_server_metadata = OAuthMetadata.model_validate(await resp.json())
|
||||
oauth_server_metadata_url = url
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f'Error parsing OAuth metadata from {url}: {e}')
|
||||
continue
|
||||
|
||||
# Determine scope from server metadata if available
|
||||
scope = None
|
||||
if oauth_server_metadata and oauth_server_metadata.scopes_supported:
|
||||
scope = ' '.join(oauth_server_metadata.scopes_supported)
|
||||
|
||||
# Determine token_endpoint_auth_method
|
||||
token_endpoint_auth_method = 'client_secret_post'
|
||||
if (
|
||||
oauth_server_metadata
|
||||
and oauth_server_metadata.token_endpoint_auth_methods_supported
|
||||
and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported
|
||||
):
|
||||
token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0]
|
||||
|
||||
oauth_client_info = OAuthClientInformationFull(
|
||||
client_id=oauth_client_id,
|
||||
client_secret=oauth_client_secret,
|
||||
redirect_uris=[redirect_uri],
|
||||
grant_types=['authorization_code', 'refresh_token'],
|
||||
response_types=['code'],
|
||||
scope=scope,
|
||||
token_endpoint_auth_method=token_endpoint_auth_method,
|
||||
issuer=oauth_server_metadata_url,
|
||||
server_metadata=oauth_server_metadata,
|
||||
)
|
||||
|
||||
log.info(
|
||||
f'Static OAuth client info built for {oauth_client_id} using metadata from {oauth_server_metadata_url}'
|
||||
)
|
||||
return oauth_client_info
|
||||
except Exception as e:
|
||||
log.error(f'Exception building static OAuth client info: {e}')
|
||||
raise e
|
||||
|
||||
|
||||
class OAuthClientManager:
|
||||
def __init__(self, app):
|
||||
self.oauth = OAuth()
|
||||
|
|
@ -495,7 +564,7 @@ class OAuthClientManager:
|
|||
for connection in connections or []:
|
||||
if connection.get('type', 'openapi') != 'mcp':
|
||||
continue
|
||||
if connection.get('auth_type', 'none') != 'oauth_2.1':
|
||||
if connection.get('auth_type', 'none') not in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
continue
|
||||
|
||||
server_id = connection.get('info', {}).get('id')
|
||||
|
|
@ -1080,21 +1149,30 @@ class OAuthManager:
|
|||
log.debug(f'Accepted user roles: {oauth_allowed_roles}')
|
||||
log.debug(f'Accepted admin roles: {oauth_admin_roles}')
|
||||
|
||||
# If any roles are found, check if they match the allowed or admin roles
|
||||
# If roles are present in the token, they must match; otherwise deny access
|
||||
if oauth_roles:
|
||||
# If role management is enabled, and matching roles are provided, use the roles
|
||||
matched = False
|
||||
for allowed_role in oauth_allowed_roles:
|
||||
# If the user has any of the allowed roles, assign the role "user"
|
||||
if allowed_role in oauth_roles:
|
||||
log.debug('Assigned user the user role')
|
||||
role = 'user'
|
||||
matched = True
|
||||
break
|
||||
for admin_role in oauth_admin_roles:
|
||||
# If the user has any of the admin roles, assign the role "admin"
|
||||
if admin_role in oauth_roles:
|
||||
log.debug('Assigned user the admin role')
|
||||
role = 'admin'
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
log.warning(
|
||||
f'OAuth role management enabled but user roles do not match any allowed/admin roles. '
|
||||
f'User roles: {oauth_roles}, allowed: {oauth_allowed_roles}, admin: {oauth_admin_roles}'
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
else:
|
||||
if not user:
|
||||
# If role management is disabled, use the default role for new users
|
||||
|
|
@ -1286,6 +1364,8 @@ class OAuthManager:
|
|||
kwargs = {}
|
||||
if auth_manager_config.OAUTH_AUDIENCE:
|
||||
kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE
|
||||
if OAUTH_AUTHORIZE_PARAMS:
|
||||
kwargs.update(OAUTH_AUTHORIZE_PARAMS)
|
||||
|
||||
return await client.authorize_redirect(request, redirect_uri, **kwargs)
|
||||
|
||||
|
|
@ -1317,12 +1397,21 @@ class OAuthManager:
|
|||
|
||||
# Try to get userinfo from the token first, some providers include it there
|
||||
user_data: UserInfo = token.get('userinfo')
|
||||
# Preserve extra claims from the ID token (e.g. roles, groups for
|
||||
# Microsoft Entra ID) before the userinfo endpoint possibly overwrites them.
|
||||
id_token_claims = dict(user_data) if user_data else {}
|
||||
if (
|
||||
(not user_data)
|
||||
or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
|
||||
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
|
||||
):
|
||||
user_data: UserInfo = await client.userinfo(token=token)
|
||||
# Merge back ID token claims that the userinfo endpoint doesn't
|
||||
# return. Only backfill missing keys so userinfo always wins.
|
||||
if user_data and id_token_claims:
|
||||
for key, value in id_token_claims.items():
|
||||
if key not in user_data:
|
||||
user_data[key] = value
|
||||
if provider == 'feishu' and isinstance(user_data, dict) and 'data' in user_data:
|
||||
user_data = user_data['data']
|
||||
if not user_data:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import copy
|
|||
import json
|
||||
|
||||
|
||||
# What goes out cannot be taken back. Let it be shaped
|
||||
# well before it leaves this place.
|
||||
# inplace function: form_data is modified
|
||||
def apply_system_prompt_to_body(
|
||||
system: Optional[str],
|
||||
|
|
|
|||
|
|
@ -197,6 +197,8 @@ def replace_imports(content):
|
|||
return content
|
||||
|
||||
|
||||
# May the intent of the one who wrote it survive every
|
||||
# import and transformation, as a deed survives the generations.
|
||||
def load_tool_module_by_id(tool_id, content=None):
|
||||
if content is None:
|
||||
tool = Tools.get_tool_by_id(tool_id)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ from open_webui.env import (
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Let not our connections be timed out but deliver them from
|
||||
# partition. For the cache and the socket and the uptime
|
||||
# belong to the one who first opened them, now and always.
|
||||
_CONNECTION_CACHE = {}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from open_webui.utils.misc import (
|
|||
)
|
||||
|
||||
|
||||
# An honest ledger is worth more than a flattering one.
|
||||
# Let every cost here be counted true.
|
||||
def normalize_usage(usage: dict) -> dict:
|
||||
"""
|
||||
Normalize usage statistics to standard format.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from open_webui.config import DEFAULT_RAG_TEMPLATE
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Let the right tool be given for the work at hand,
|
||||
# not the one that flatters, but the one that serves.
|
||||
def get_task_model_id(default_model_id: str, task_model: str, task_model_external: str, models) -> str:
|
||||
# Set the task model
|
||||
task_model_id = default_model_id
|
||||
|
|
@ -239,6 +241,8 @@ def replace_messages_variable(template: str, messages: Optional[list[dict]] = No
|
|||
# {{prompt:middletruncate:8000}}
|
||||
|
||||
|
||||
# Let the context given here not distort the question,
|
||||
# but illuminate it, so that the answer serves the one who asked.
|
||||
def rag_template(template: str, context: str, query: str):
|
||||
if template.strip() == '':
|
||||
template = DEFAULT_RAG_TEMPLATE
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import base64
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
|
|
@ -44,6 +45,7 @@ from open_webui.utils.access_control import has_access, has_connection_access
|
|||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
|
|
@ -79,9 +81,11 @@ from open_webui.tools.builtin import (
|
|||
query_knowledge_bases,
|
||||
search_knowledge_files,
|
||||
query_knowledge_files,
|
||||
list_knowledge,
|
||||
view_file,
|
||||
view_knowledge_file,
|
||||
view_skill,
|
||||
tasks,
|
||||
)
|
||||
|
||||
import copy
|
||||
|
|
@ -89,6 +93,8 @@ import copy
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Let no function be called without need, and let what
|
||||
# it yields justify the cost of running it.
|
||||
def get_async_tool_function_and_apply_extra_params(function: Callable, extra_params: dict) -> Callable[..., Awaitable]:
|
||||
sig = inspect.signature(function)
|
||||
extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters}
|
||||
|
|
@ -405,12 +411,15 @@ def get_builtin_tools(
|
|||
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
|
||||
# Model has attached knowledge - provide discovery, search and semantic tools
|
||||
builtin_functions.append(list_knowledge)
|
||||
builtin_functions.append(search_knowledge_files)
|
||||
builtin_functions.append(query_knowledge_files)
|
||||
|
||||
knowledge_types = {item.get('type') for item in model_knowledge}
|
||||
if 'file' in knowledge_types or 'collection' in knowledge_types:
|
||||
builtin_functions.append(view_file)
|
||||
builtin_functions.append(view_knowledge_file)
|
||||
if 'note' in knowledge_types:
|
||||
builtin_functions.append(view_note)
|
||||
else:
|
||||
|
|
@ -431,7 +440,7 @@ def get_builtin_tools(
|
|||
builtin_functions.extend([search_chats, view_chat])
|
||||
|
||||
# Add memory tools if builtin category enabled AND enabled for this chat
|
||||
if is_builtin_tool_enabled('memory') and features.get('memory'):
|
||||
if is_builtin_tool_enabled('memory') and (features.get('memory') or get_model_capability('memory', False)):
|
||||
builtin_functions.extend(
|
||||
[
|
||||
search_memories,
|
||||
|
|
@ -495,6 +504,10 @@ def get_builtin_tools(
|
|||
if extra_params.get('__skill_ids__'):
|
||||
builtin_functions.append(view_skill)
|
||||
|
||||
# Task management - break down complex work into trackable steps
|
||||
if is_builtin_tool_enabled('tasks'):
|
||||
builtin_functions.append(tasks)
|
||||
|
||||
for func in builtin_functions:
|
||||
callable = get_async_tool_function_and_apply_extra_params(
|
||||
func,
|
||||
|
|
@ -854,9 +867,7 @@ async def get_terminal_system_prompt(
|
|||
return None
|
||||
|
||||
# 2. Fetch system prompt
|
||||
async with session.get(
|
||||
f'{base}/system', headers=headers, cookies=cookies or {}
|
||||
) as resp:
|
||||
async with session.get(f'{base}/system', headers=headers, cookies=cookies or {}) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data.get('prompt')
|
||||
|
|
@ -950,7 +961,7 @@ async def get_terminal_tools(
|
|||
terminal_id: str,
|
||||
user: UserModel,
|
||||
extra_params: dict,
|
||||
) -> tuple[dict[str, dict], Optional[str]]:
|
||||
) -> dict[str, dict] | tuple[dict[str, dict], Optional[str]]:
|
||||
"""Resolve tools for a terminal server identified by terminal_id.
|
||||
|
||||
- Finds the connection in TERMINAL_SERVER_CONNECTIONS
|
||||
|
|
@ -998,6 +1009,13 @@ async def get_terminal_tools(
|
|||
# auth_type == "none": no Authorization header
|
||||
|
||||
system_prompt = server_data.get('system_prompt')
|
||||
|
||||
# Use chat_id as the per-session key for cwd tracking
|
||||
metadata = extra_params.get('__metadata__', {})
|
||||
session_id = metadata.get('chat_id')
|
||||
if session_id:
|
||||
headers['X-Session-Id'] = session_id
|
||||
|
||||
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
|
||||
|
||||
tools_dict = {}
|
||||
|
|
@ -1232,9 +1250,13 @@ async def execute_tool_server(
|
|||
if param_name in params:
|
||||
if param_in == 'path':
|
||||
path_params[param_name] = params[param_name]
|
||||
elif param_in == 'query':
|
||||
if params[param_name] is not None:
|
||||
query_params[param_name] = params[param_name]
|
||||
if param_in == 'query':
|
||||
value = params[param_name]
|
||||
# Skip empty values for optional params (LLMs sometimes
|
||||
# pass "" instead of omitting optional parameters).
|
||||
if value is None or (value == '' and not param.get('required')):
|
||||
continue
|
||||
query_params[param_name] = value
|
||||
|
||||
final_url = f'{url.rstrip("/")}{route_path}'
|
||||
for key, value in path_params.items():
|
||||
|
|
@ -1249,7 +1271,7 @@ async def execute_tool_server(
|
|||
body_params = params
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)
|
||||
trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
|
||||
) as session:
|
||||
request_method = getattr(session, http_method.lower())
|
||||
|
||||
|
|
@ -1269,7 +1291,13 @@ async def execute_tool_server(
|
|||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
|
||||
if content_type.startswith('text/') or not content_type:
|
||||
response_data = await response.text()
|
||||
else:
|
||||
raw = await response.read()
|
||||
b64 = base64.b64encode(raw).decode()
|
||||
response_data = f'data:{content_type};base64,{b64}'
|
||||
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
|
|
@ -1288,7 +1316,13 @@ async def execute_tool_server(
|
|||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
|
||||
if content_type.startswith('text/') or not content_type:
|
||||
response_data = await response.text()
|
||||
else:
|
||||
raw = await response.read()
|
||||
b64 = base64.b64encode(raw).decode()
|
||||
response_data = f'data:{content_type};base64,{b64}'
|
||||
|
||||
response_headers = response.headers
|
||||
return (response_data, response_headers)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Let this message reach those for whom it was written, and
|
||||
# may no network partition deny the word its destination.
|
||||
async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool:
|
||||
try:
|
||||
log.debug(f'post_webhook: {url}, {message}, {event_data}')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ itsdangerous==2.2.0
|
|||
|
||||
python-socketio==5.16.1
|
||||
python-jose==3.5.0
|
||||
cryptography
|
||||
cryptography==46.0.5
|
||||
bcrypt==5.0.0
|
||||
argon2-cffi==25.1.0
|
||||
PyJWT[crypto]==2.11.0
|
||||
|
|
@ -14,9 +14,9 @@ authlib==1.6.9
|
|||
|
||||
requests==2.32.5
|
||||
aiohttp==3.13.2 # do not update to 3.13.3 - broken
|
||||
async-timeout
|
||||
aiocache
|
||||
aiofiles
|
||||
async-timeout==5.0.1
|
||||
aiocache==0.12.3
|
||||
aiofiles==25.1.0
|
||||
starlette-compress==1.7.0
|
||||
Brotli==1.1.0
|
||||
httpx[socks,http2,zstd,cli,brotli]==0.28.1
|
||||
|
|
@ -29,7 +29,7 @@ peewee==3.19.0
|
|||
peewee-migrate==1.14.3
|
||||
|
||||
pycrdt==0.12.47
|
||||
redis
|
||||
redis==7.4.0
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.1
|
||||
|
|
@ -39,11 +39,11 @@ loguru==0.7.3
|
|||
asgiref==3.11.1
|
||||
|
||||
# AI libraries
|
||||
tiktoken
|
||||
tiktoken==0.12.0
|
||||
mcp==1.26.0
|
||||
|
||||
openai
|
||||
anthropic
|
||||
openai==2.29.0
|
||||
anthropic==0.86.0
|
||||
google-genai==1.66.0
|
||||
|
||||
langchain==1.2.10
|
||||
|
|
@ -58,7 +58,7 @@ opensearch-py==3.1.0
|
|||
|
||||
transformers==5.3.0
|
||||
sentence-transformers==5.2.3
|
||||
accelerate
|
||||
accelerate==1.13.0
|
||||
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
einops==0.8.2
|
||||
|
||||
|
|
@ -74,15 +74,15 @@ unstructured==0.18.31
|
|||
|
||||
nltk==3.9.3
|
||||
Markdown==3.10.2
|
||||
beautifulsoup4
|
||||
beautifulsoup4==4.14.3
|
||||
pypandoc==1.16.2
|
||||
pandas==3.0.1
|
||||
openpyxl==3.1.5
|
||||
pyxlsb==1.0.10
|
||||
xlrd==2.0.2
|
||||
validators==0.35.0
|
||||
psutil
|
||||
sentencepiece
|
||||
psutil==7.2.2
|
||||
sentencepiece==0.2.1
|
||||
soundfile==0.13.1
|
||||
|
||||
pillow==12.1.1
|
||||
|
|
@ -97,7 +97,7 @@ black==26.1.0
|
|||
youtube-transcript-api==1.2.4
|
||||
pytube==15.0.0
|
||||
|
||||
pydub
|
||||
pydub==0.25.1
|
||||
ddgs==9.11.3
|
||||
|
||||
azure-ai-documentintelligence==1.0.2
|
||||
|
|
@ -106,15 +106,15 @@ azure-storage-blob==12.28.0
|
|||
azure-search-documents==11.6.0
|
||||
|
||||
## Google Drive
|
||||
google-api-python-client
|
||||
google-auth-httplib2
|
||||
google-auth-oauthlib
|
||||
google-api-python-client==2.193.0
|
||||
google-auth-httplib2==0.3.0
|
||||
google-auth-oauthlib==1.3.0
|
||||
|
||||
googleapis-common-protos==1.72.0
|
||||
google-cloud-storage==3.9.0
|
||||
|
||||
## Databases
|
||||
pymongo
|
||||
pymongo==4.16.0
|
||||
psycopg2-binary==2.9.11
|
||||
pgvector==0.4.2
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ if [ -n "$SPACE_ID" ]; then
|
|||
echo "Configuring for HuggingFace Space deployment"
|
||||
if [ -n "$ADMIN_USER_EMAIL" ] && [ -n "$ADMIN_USER_PASSWORD" ]; then
|
||||
echo "Admin user configured, creating"
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' &
|
||||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" &
|
||||
webui_pid=$!
|
||||
echo "Waiting for webui to start..."
|
||||
while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do
|
||||
|
|
@ -83,5 +83,5 @@ fi
|
|||
WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \
|
||||
--host "$HOST" \
|
||||
--port "$PORT" \
|
||||
--forwarded-allow-ips '*' \
|
||||
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
|
||||
"${ARGS[@]}"
|
||||
|
|
@ -24,6 +24,7 @@ IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" (
|
|||
|
||||
IF "%PORT%"=="" SET PORT=8080
|
||||
IF "%HOST%"=="" SET HOST=0.0.0.0
|
||||
IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS=*"
|
||||
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||
SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%"
|
||||
|
||||
|
|
@ -46,5 +47,5 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " (
|
|||
:: Execute uvicorn
|
||||
SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%"
|
||||
IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1
|
||||
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --workers %UVICORN_WORKERS% --ws auto
|
||||
uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips "%FORWARDED_ALLOW_IPS%" --workers %UVICORN_WORKERS% --ws auto
|
||||
:: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto
|
||||
|
|
|
|||
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.12",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
|
|
@ -8664,9 +8664,9 @@
|
|||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -10035,9 +10035,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
|
||||
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
|
|
@ -13614,9 +13614,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.8.10",
|
||||
"version": "0.8.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ dependencies = [
|
|||
|
||||
"python-socketio==5.16.1",
|
||||
"python-jose==3.5.0",
|
||||
"cryptography",
|
||||
"cryptography==46.0.5",
|
||||
"bcrypt==5.0.0",
|
||||
"argon2-cffi==25.1.0",
|
||||
"PyJWT[crypto]==2.11.0",
|
||||
|
|
@ -22,9 +22,9 @@ dependencies = [
|
|||
|
||||
"requests==2.32.5",
|
||||
"aiohttp==3.13.2", # do not update to 3.13.3 - broken
|
||||
"async-timeout",
|
||||
"aiocache",
|
||||
"aiofiles",
|
||||
"async-timeout==5.0.1",
|
||||
"aiocache==0.12.3",
|
||||
"aiofiles==25.1.0",
|
||||
"starlette-compress==1.7.0",
|
||||
"Brotli==1.1.0",
|
||||
"httpx[socks,http2,zstd,cli,brotli]==0.28.1",
|
||||
|
|
@ -37,7 +37,7 @@ dependencies = [
|
|||
"peewee-migrate==1.14.3",
|
||||
|
||||
"pycrdt==0.12.47",
|
||||
"redis",
|
||||
"redis==7.4.0",
|
||||
|
||||
"pytz==2026.1.post1",
|
||||
"APScheduler==3.11.2",
|
||||
|
|
@ -46,11 +46,11 @@ dependencies = [
|
|||
"loguru==0.7.3",
|
||||
"asgiref==3.11.1",
|
||||
|
||||
"tiktoken",
|
||||
"tiktoken==0.12.0",
|
||||
"mcp==1.26.0",
|
||||
|
||||
"openai",
|
||||
"anthropic",
|
||||
"openai==2.29.0",
|
||||
"anthropic==0.86.0",
|
||||
"google-genai==1.66.0",
|
||||
|
||||
"langchain==1.2.10",
|
||||
|
|
@ -66,7 +66,7 @@ dependencies = [
|
|||
|
||||
"transformers==5.3.0",
|
||||
"sentence-transformers==5.2.3",
|
||||
"accelerate",
|
||||
"accelerate==1.13.0",
|
||||
"pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
|
||||
"einops==0.8.2",
|
||||
|
||||
|
|
@ -80,14 +80,15 @@ dependencies = [
|
|||
"msoffcrypto-tool==6.0.0",
|
||||
"nltk==3.9.3",
|
||||
"Markdown==3.10.2",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"pypandoc==1.16.2",
|
||||
"pandas==3.0.1",
|
||||
"openpyxl==3.1.5",
|
||||
"pyxlsb==1.0.10",
|
||||
"xlrd==2.0.2",
|
||||
"validators==0.35.0",
|
||||
"psutil",
|
||||
"sentencepiece",
|
||||
"psutil==7.2.2",
|
||||
"sentencepiece==0.2.1",
|
||||
"soundfile==0.13.1",
|
||||
"azure-ai-documentintelligence==1.0.2",
|
||||
|
||||
|
|
@ -103,12 +104,12 @@ dependencies = [
|
|||
"youtube-transcript-api==1.2.4",
|
||||
"pytube==15.0.0",
|
||||
|
||||
"pydub",
|
||||
"pydub==0.25.1",
|
||||
"ddgs==9.11.3",
|
||||
|
||||
"google-api-python-client",
|
||||
"google-auth-httplib2",
|
||||
"google-auth-oauthlib",
|
||||
"google-api-python-client==2.193.0",
|
||||
"google-auth-httplib2==0.3.0",
|
||||
"google-auth-oauthlib==1.3.0",
|
||||
|
||||
"googleapis-common-protos==1.72.0",
|
||||
"google-cloud-storage==3.9.0",
|
||||
|
|
@ -144,7 +145,7 @@ unstructured = [
|
|||
]
|
||||
|
||||
all = [
|
||||
"pymongo",
|
||||
"pymongo==4.16.0",
|
||||
"psycopg2-binary==2.9.11",
|
||||
"pgvector==0.4.2",
|
||||
"moto[s3]>=5.0.26",
|
||||
|
|
|
|||
|
|
@ -14,12 +14,19 @@ const packages = [
|
|||
'seaborn',
|
||||
'pytz',
|
||||
'black',
|
||||
'openai'
|
||||
'openai',
|
||||
'openpyxl'
|
||||
];
|
||||
|
||||
// Pure-Python packages whose wheels must be downloaded from PyPI and saved into
|
||||
// static/pyodide/ so that the browser can install them offline via micropip.
|
||||
// Packages already provided by the Pyodide distribution (click, platformdirs,
|
||||
// typing_extensions, etc.) do NOT need to be listed here.
|
||||
const pypiPackages = ['black', 'pathspec', 'mypy_extensions'];
|
||||
|
||||
import { loadPyodide } from 'pyodide';
|
||||
import { setGlobalDispatcher, ProxyAgent } from 'undici';
|
||||
import { writeFile, readFile, copyFile, readdir, rmdir } from 'fs/promises';
|
||||
import { writeFile, readFile, copyFile, readdir, rmdir, access } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Loading network proxy configurations from the environment variables.
|
||||
|
|
@ -117,6 +124,78 @@ async function copyPyodide() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download pure-Python wheels from PyPI and save them into static/pyodide/.
|
||||
* Also injects entries into pyodide-lock.json so that micropip resolves these
|
||||
* packages from the local server instead of fetching them from the internet.
|
||||
*/
|
||||
async function downloadPyPIWheels() {
|
||||
const lockPath = 'static/pyodide/pyodide-lock.json';
|
||||
let lockData;
|
||||
try {
|
||||
lockData = JSON.parse(await readFile(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
console.warn('Could not read pyodide-lock.json, skipping PyPI wheel download');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const pkg of pypiPackages) {
|
||||
console.log(`Fetching PyPI metadata for: ${pkg}`);
|
||||
const res = await fetch(`https://pypi.org/pypi/${pkg}/json`);
|
||||
if (!res.ok) {
|
||||
console.error(`Failed to fetch PyPI metadata for ${pkg}: ${res.status}`);
|
||||
continue;
|
||||
}
|
||||
const meta = await res.json();
|
||||
const version = meta.info.version;
|
||||
const files = meta.urls || [];
|
||||
// Find the pure-Python wheel (py3-none-any)
|
||||
const wheel = files.find(
|
||||
(f) => f.filename.endsWith('.whl') && f.filename.includes('py3-none-any')
|
||||
);
|
||||
if (!wheel) {
|
||||
console.warn(`No pure-Python wheel found for ${pkg}==${version}, skipping`);
|
||||
continue;
|
||||
}
|
||||
const dest = `static/pyodide/${wheel.filename}`;
|
||||
// Download wheel if not already present
|
||||
try {
|
||||
await access(dest);
|
||||
console.log(` Already exists: ${wheel.filename}`);
|
||||
} catch {
|
||||
console.log(` Downloading: ${wheel.filename}`);
|
||||
const wheelRes = await fetch(wheel.url);
|
||||
if (!wheelRes.ok) {
|
||||
console.error(` Failed to download ${wheel.filename}: ${wheelRes.status}`);
|
||||
continue;
|
||||
}
|
||||
const buffer = Buffer.from(await wheelRes.arrayBuffer());
|
||||
await writeFile(dest, buffer);
|
||||
console.log(` Saved: ${dest} (${buffer.length} bytes)`);
|
||||
}
|
||||
|
||||
// Inject into pyodide-lock.json so micropip resolves locally
|
||||
const normalizedName = pkg.replace(/-/g, '_');
|
||||
if (!lockData.packages[normalizedName]) {
|
||||
lockData.packages[normalizedName] = {
|
||||
name: normalizedName,
|
||||
version: version,
|
||||
file_name: wheel.filename,
|
||||
install_dir: 'site',
|
||||
sha256: wheel.digests?.sha256 || '',
|
||||
package_type: 'package',
|
||||
imports: [normalizedName],
|
||||
depends: []
|
||||
};
|
||||
console.log(` Added ${normalizedName}==${version} to pyodide-lock.json`);
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(lockPath, JSON.stringify(lockData, null, 2));
|
||||
console.log('Updated pyodide-lock.json with PyPI packages');
|
||||
}
|
||||
|
||||
initNetworkProxyFromEnv();
|
||||
await downloadPackages();
|
||||
await copyPyodide();
|
||||
await downloadPyPIWheels();
|
||||
|
|
|
|||
|
|
@ -268,9 +268,10 @@ export const detectTerminalServerType = async (
|
|||
|
||||
/**
|
||||
* Create or update a policy on the orchestrator.
|
||||
* PUT {url}/api/v1/policies/{policyId}
|
||||
* Proxied through the Open WebUI backend to keep API keys server-side.
|
||||
*/
|
||||
export const putOrchestratorPolicy = async (
|
||||
token: string,
|
||||
url: string,
|
||||
key: string,
|
||||
policyId: string,
|
||||
|
|
@ -278,18 +279,52 @@ export const putOrchestratorPolicy = async (
|
|||
): Promise<object | null> => {
|
||||
let error = null;
|
||||
|
||||
const baseUrl = url.replace(/\/$/, '');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (key) {
|
||||
headers['Authorization'] = `Bearer ${key}`;
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/policy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.replace(/\/$/, ''),
|
||||
key,
|
||||
policy_id: policyId,
|
||||
policy_data: policyData
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/policies/${encodeURIComponent(policyId)}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(policyData)
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a terminal server connection via the backend proxy.
|
||||
* Used for system/admin connections to avoid CORS issues and API key exposure.
|
||||
*/
|
||||
export const verifyTerminalServerConnection = async (token: string, connection: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...connection
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
|
|
@ -342,6 +377,7 @@ type RegisterOAuthClientForm = {
|
|||
url: string;
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
client_secret?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
|
|
@ -441,6 +477,33 @@ export const setCodeExecutionConfig = async (token: string, config: object) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getModelsDefaults = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models/defaults`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getModelsConfig = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { WEBUI_BASE_URL } from '$lib/constants';
|
|||
import { convertOpenApiToToolPayload } from '$lib/utils';
|
||||
import { getOpenAIModelsDirect } from './openai';
|
||||
|
||||
const TOOL_SERVER_FETCH_TIMEOUT = 10000;
|
||||
|
||||
// Every request sent from here is a petition. May it reach
|
||||
// the one for whom it was intended, and return answered.
|
||||
export const getModels = async (
|
||||
token: string = '',
|
||||
connections: object | null = null,
|
||||
|
|
@ -302,6 +306,7 @@ export const getToolServerData = async (token: string, url: string) => {
|
|||
let error = null;
|
||||
|
||||
const res = await fetch(`${url}`, {
|
||||
signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT),
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
|
@ -322,7 +327,9 @@ export const getToolServerData = async (token: string, url: string) => {
|
|||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
if ('detail' in err) {
|
||||
if (err?.name === 'TimeoutError') {
|
||||
error = `Connection to ${url} timed out`;
|
||||
} else if ('detail' in err) {
|
||||
error = err.detail;
|
||||
} else {
|
||||
error = err;
|
||||
|
|
@ -402,7 +409,9 @@ export const getToolServersData = async (servers: object[]) => {
|
|||
// Fetch system prompt if the server supports it
|
||||
try {
|
||||
const baseUrl = (server?.url ?? '').replace(/\/$/, '');
|
||||
const configRes = await fetch(`${baseUrl}/api/config`);
|
||||
const configRes = await fetch(`${baseUrl}/api/config`, {
|
||||
signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT)
|
||||
});
|
||||
if (configRes.ok) {
|
||||
const config = await configRes.json();
|
||||
if (config?.features?.system) {
|
||||
|
|
@ -410,7 +419,10 @@ export const getToolServersData = async (servers: object[]) => {
|
|||
if (toolServerToken) {
|
||||
headers['Authorization'] = `Bearer ${toolServerToken}`;
|
||||
}
|
||||
const systemRes = await fetch(`${baseUrl}/system`, { headers });
|
||||
const systemRes = await fetch(`${baseUrl}/system`, {
|
||||
signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT),
|
||||
headers
|
||||
});
|
||||
if (systemRes.ok) {
|
||||
const systemData = await systemRes.json();
|
||||
if (systemData?.prompt) {
|
||||
|
|
@ -442,7 +454,8 @@ export const executeToolServer = async (
|
|||
url: string,
|
||||
name: string,
|
||||
params: Record<string, any>,
|
||||
serverData: { openapi: any; info: any; specs: any }
|
||||
serverData: { openapi: any; info: any; specs: any },
|
||||
sessionId?: string
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
|
|
@ -519,6 +532,7 @@ export const executeToolServer = async (
|
|||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
};
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method: httpMethod.toUpperCase(),
|
||||
|
|
@ -1404,6 +1418,32 @@ export const getBackendConfig = async () => {
|
|||
});
|
||||
|
||||
if (error) {
|
||||
// When a forward-auth proxy (e.g. Authentik/Traefik) intercepts the
|
||||
// request and redirects to an external login page, the browser blocks
|
||||
// the cross-origin redirect for fetch() and throws a TypeError.
|
||||
// Detect this by re-fetching with redirect:"manual" — if the server
|
||||
// responded with a redirect, the probe returns an opaque redirect
|
||||
// response instead of throwing, confirming the backend is alive but
|
||||
// an auth proxy is intercepting.
|
||||
if (error instanceof TypeError) {
|
||||
try {
|
||||
const probeRes = await fetch(`${WEBUI_BASE_URL}/api/config`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (
|
||||
probeRes.type === 'opaqueredirect' ||
|
||||
(probeRes.status >= 300 && probeRes.status < 400)
|
||||
) {
|
||||
throw { authRedirect: true };
|
||||
}
|
||||
} catch (probeErr: any) {
|
||||
if (probeErr?.authRedirect) throw probeErr;
|
||||
// Probe also failed — genuine network/backend issue
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@ export const getTerminalConfig = async (
|
|||
return res.json().catch(() => null);
|
||||
};
|
||||
|
||||
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
|
||||
export const getCwd = async (baseUrl: string, apiKey: string, sessionId?: string): Promise<string | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, { headers }).catch(() => null);
|
||||
if (!res || !res.ok) return null;
|
||||
const json = await res.json().catch(() => null);
|
||||
return json?.cwd ?? null;
|
||||
|
|
@ -58,13 +58,14 @@ export const getCwd = async (baseUrl: string, apiKey: string): Promise<string |
|
|||
export const listFiles = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string = '/'
|
||||
path: string = '/',
|
||||
sessionId?: string
|
||||
): Promise<FileEntry[] | null> => {
|
||||
// The endpoint uses `directory` as the query param name
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
})
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, { headers })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
|
|
@ -79,12 +80,13 @@ export const listFiles = async (
|
|||
export const readFile = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<string | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch((err) => {
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, { headers }).catch((err) => {
|
||||
console.error('open-terminal readFile error:', err);
|
||||
return null;
|
||||
});
|
||||
|
|
@ -106,12 +108,13 @@ export const readFile = async (
|
|||
export const downloadFileBlob = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<{ blob: Blob; filename: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
}).catch(() => null);
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, { headers }).catch(() => null);
|
||||
|
||||
if (!res || !res.ok) return null;
|
||||
|
||||
|
|
@ -120,18 +123,48 @@ export const downloadFileBlob = async (
|
|||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const archiveFromTerminal = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
paths: string[],
|
||||
sessionId?: string
|
||||
): Promise<{ blob: Blob; filename: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/archive`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ paths })
|
||||
}).catch(() => null);
|
||||
|
||||
if (!res || !res.ok) return null;
|
||||
|
||||
const disposition = res.headers.get('content-disposition') ?? '';
|
||||
const match = disposition.match(/filename="?([^"]+)"?/);
|
||||
const filename = match?.[1] ?? 'download.zip';
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
};
|
||||
|
||||
export const uploadToTerminal = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
directory: string,
|
||||
file: File
|
||||
file: File,
|
||||
sessionId?: string
|
||||
): Promise<{ path: string; size: number } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`;
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers,
|
||||
body
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
@ -148,15 +181,18 @@ export const uploadToTerminal = async (
|
|||
export const createDirectory = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<{ path: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
@ -173,12 +209,15 @@ export const createDirectory = async (
|
|||
export const deleteEntry = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<{ path: string; type: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`;
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
headers
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
|
|
@ -194,15 +233,18 @@ export const deleteEntry = async (
|
|||
export const setCwd = async (
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
path: string
|
||||
path: string,
|
||||
sessionId?: string
|
||||
): Promise<{ cwd: string } | null> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({ path })
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
@ -220,15 +262,18 @@ export const moveEntry = async (
|
|||
baseUrl: string,
|
||||
apiKey: string,
|
||||
source: string,
|
||||
destination: string
|
||||
destination: string,
|
||||
sessionId?: string
|
||||
): Promise<{ source: string; destination: string } | { error: string }> => {
|
||||
const url = `${baseUrl.replace(/\/$/, '')}/files/move`;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (sessionId) headers['X-Session-Id'] = sessionId;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({ source, destination })
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
|
|||
|
|
@ -725,7 +725,9 @@
|
|||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
|
||||
message={$i18n.t(
|
||||
'Are you sure you want to delete this connection? This action cannot be undone.'
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={() => {
|
||||
onDelete();
|
||||
|
|
|
|||
|
|
@ -12,12 +12,16 @@
|
|||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import { detectTerminalServerType, putOrchestratorPolicy } from '$lib/apis/configs';
|
||||
import {
|
||||
detectTerminalServerType,
|
||||
verifyTerminalServerConnection,
|
||||
putOrchestratorPolicy
|
||||
} from '$lib/apis/configs';
|
||||
import { getTerminalConfig } from '$lib/apis/terminal';
|
||||
|
||||
export let show = false;
|
||||
export let edit = false;
|
||||
export let admin = false;
|
||||
export let direct = false;
|
||||
export let connection = null;
|
||||
|
||||
export let onSubmit: Function = () => {};
|
||||
|
|
@ -110,9 +114,14 @@
|
|||
|
||||
verifying = true;
|
||||
try {
|
||||
if (admin) {
|
||||
// Admin: detect orchestrator vs terminal
|
||||
const type = await detectTerminalServerType(_url, key);
|
||||
if (!direct) {
|
||||
// System connection: proxy through backend to avoid CORS / key exposure
|
||||
const result = await verifyTerminalServerConnection(localStorage.token, {
|
||||
url: _url,
|
||||
key,
|
||||
auth_type
|
||||
});
|
||||
const type = result?.type ?? null;
|
||||
|
||||
if (type) {
|
||||
serverType = type;
|
||||
|
|
@ -137,7 +146,7 @@
|
|||
toast.error($i18n.t('Server connection failed'));
|
||||
}
|
||||
} else {
|
||||
// Non-admin: simple terminal verification
|
||||
// Direct connection: verify from browser
|
||||
const res = await getTerminalConfig(_url, key);
|
||||
if (res) {
|
||||
toast.success($i18n.t('Server connection verified'));
|
||||
|
|
@ -192,9 +201,9 @@
|
|||
url = url.replace(/\/$/, '');
|
||||
|
||||
// Save policy to orchestrator if applicable
|
||||
if (serverType === 'orchestrator' && admin && policyId) {
|
||||
if (serverType === 'orchestrator' && !direct && policyId) {
|
||||
try {
|
||||
await putOrchestratorPolicy(url, key, policyId, buildPolicyData());
|
||||
await putOrchestratorPolicy(localStorage.token, url, key, policyId, buildPolicyData());
|
||||
} catch (err) {
|
||||
toast.error($i18n.t('Failed to save policy: {{error}}', { error: err }));
|
||||
return;
|
||||
|
|
@ -202,7 +211,7 @@
|
|||
}
|
||||
|
||||
const result = {
|
||||
...(admin && id.trim() ? { id: id.trim() } : {}),
|
||||
...(!direct && id.trim() ? { id: id.trim() } : {}),
|
||||
url,
|
||||
key,
|
||||
name,
|
||||
|
|
@ -210,7 +219,7 @@
|
|||
auth_type,
|
||||
enabled: enabled,
|
||||
config: {
|
||||
...(admin ? { access_grants: accessGrants } : {})
|
||||
...(!direct ? { access_grants: accessGrants } : {})
|
||||
},
|
||||
// Policy fields
|
||||
...(serverType ? { server_type: serverType } : {}),
|
||||
|
|
@ -270,7 +279,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if admin}
|
||||
{#if !direct}
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
|
|
@ -368,7 +377,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Policy section (orchestrator only, admin only) -->
|
||||
{#if serverType === 'orchestrator' && admin}
|
||||
{#if serverType === 'orchestrator' && !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
|
|
@ -580,7 +589,7 @@
|
|||
{$i18n.t('Advanced')}
|
||||
</button>
|
||||
|
||||
{#if admin}
|
||||
{#if !direct}
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
|
||||
type="button"
|
||||
|
|
@ -662,7 +671,7 @@
|
|||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
<option value="bearer">{$i18n.t('Bearer')}</option>
|
||||
{#if admin}
|
||||
{#if !direct}
|
||||
<option value="session">{$i18n.t('Session')}</option>
|
||||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{/if}
|
||||
|
|
@ -733,7 +742,9 @@
|
|||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
|
||||
message={$i18n.t(
|
||||
'Are you sure you want to delete this connection? This action cannot be undone.'
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={() => {
|
||||
onDelete();
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@
|
|||
|
||||
let oauthClientInfo = null;
|
||||
|
||||
let oauthClientId = '';
|
||||
let oauthClientSecret = '';
|
||||
|
||||
let enable = true;
|
||||
let loading = false;
|
||||
let showAdvanced = false;
|
||||
|
|
@ -75,14 +78,22 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const res = await registerOAuthClient(
|
||||
localStorage.token,
|
||||
{
|
||||
url: url,
|
||||
client_id: id
|
||||
},
|
||||
'mcp'
|
||||
).catch((err) => {
|
||||
const formData: { url: string; client_id: string; client_secret?: string } = {
|
||||
url: url,
|
||||
client_id: id
|
||||
};
|
||||
|
||||
// For static OAuth, include client credentials
|
||||
if (auth_type === 'oauth_2.1_static') {
|
||||
if (!oauthClientId || !oauthClientSecret) {
|
||||
toast.error($i18n.t('Please enter Client ID and Client Secret'));
|
||||
return;
|
||||
}
|
||||
formData.client_id = id;
|
||||
formData.client_secret = oauthClientSecret;
|
||||
}
|
||||
|
||||
const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => {
|
||||
toast.error($i18n.t('Registration failed'));
|
||||
return null;
|
||||
});
|
||||
|
|
@ -267,7 +278,11 @@
|
|||
return;
|
||||
}
|
||||
|
||||
if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) {
|
||||
if (
|
||||
type === 'mcp' &&
|
||||
['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) &&
|
||||
!oauthClientInfo
|
||||
) {
|
||||
toast.error($i18n.t('Please register the OAuth client'));
|
||||
loading = false;
|
||||
return;
|
||||
|
|
@ -320,7 +335,10 @@
|
|||
id: id,
|
||||
name: name,
|
||||
description: description,
|
||||
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
|
||||
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}),
|
||||
...(auth_type === 'oauth_2.1_static'
|
||||
? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret }
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -345,6 +363,8 @@
|
|||
description = '';
|
||||
|
||||
oauthClientInfo = null;
|
||||
oauthClientId = '';
|
||||
oauthClientSecret = '';
|
||||
|
||||
enable = true;
|
||||
functionNameFilterList = '';
|
||||
|
|
@ -369,6 +389,8 @@
|
|||
name = connection.info?.name ?? '';
|
||||
description = connection.info?.description ?? '';
|
||||
oauthClientInfo = connection.info?.oauth_client_info ?? null;
|
||||
oauthClientId = connection.info?.oauth_client_id ?? '';
|
||||
oauthClientSecret = connection.info?.oauth_client_secret ?? '';
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
functionNameFilterList = connection.config?.function_name_filter_list ?? '';
|
||||
|
|
@ -607,7 +629,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if auth_type === 'oauth_2.1'}
|
||||
{#if ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col justify-end items-center shrink-0">
|
||||
<Tooltip
|
||||
|
|
@ -660,6 +682,7 @@
|
|||
<option value="system_oauth">{$i18n.t('OAuth')}</option>
|
||||
{#if type === 'mcp'}
|
||||
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
|
||||
<option value="oauth_2.1_static">{$i18n.t('OAuth 2.1 (Static)')}</option>
|
||||
{/if}
|
||||
{/if}
|
||||
</select>
|
||||
|
|
@ -696,6 +719,19 @@
|
|||
>
|
||||
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
|
||||
</div>
|
||||
{:else if auth_type === 'oauth_2.1_static'}
|
||||
<div class="flex flex-col gap-1.5 w-full mt-0.5">
|
||||
<SensitiveInput
|
||||
bind:value={oauthClientId}
|
||||
placeholder={$i18n.t('Client ID')}
|
||||
required={false}
|
||||
/>
|
||||
<SensitiveInput
|
||||
bind:value={oauthClientSecret}
|
||||
placeholder={$i18n.t('Client Secret')}
|
||||
required={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -847,7 +883,7 @@
|
|||
{/if}
|
||||
|
||||
{#if !direct}
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
<hr class=" border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
|
|
@ -872,7 +908,7 @@
|
|||
|
||||
{#if type === 'mcp'}
|
||||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2 mt-1"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{$i18n.t('Warning')}:
|
||||
|
|
@ -930,7 +966,9 @@
|
|||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
message={$i18n.t('Are you sure you want to delete this connection? This action cannot be undone.')}
|
||||
message={$i18n.t(
|
||||
'Are you sure you want to delete this connection? This action cannot be undone.'
|
||||
)}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={() => {
|
||||
onDelete();
|
||||
|
|
|
|||
|
|
@ -277,11 +277,11 @@ class Pipe:
|
|||
content = _content;
|
||||
await tick();
|
||||
|
||||
if (res) {
|
||||
console.info('Code formatted successfully');
|
||||
|
||||
saveHandler();
|
||||
if (!res) {
|
||||
console.warn('Code formatting failed or was skipped, saving unformatted code');
|
||||
}
|
||||
|
||||
saveHandler();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@
|
|||
<img
|
||||
src={profileImageUrl}
|
||||
class="size-16 rounded-full object-cover shrink-0"
|
||||
alt="Profile"
|
||||
alt={$i18n.t('Profile')}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@
|
|||
<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
|
||||
|
||||
<AddTerminalServerModal
|
||||
admin
|
||||
bind:show={showAddTerminalModal}
|
||||
edit={editTerminalIdx !== null}
|
||||
connection={editTerminalIdx !== null ? terminalConnections[editTerminalIdx] : null}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,12 @@
|
|||
modelsToEnable.forEach((m) => (m.is_active = true));
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToEnable.map((model) => toggleModelById(localStorage.token, model.id)));
|
||||
await Promise.all(
|
||||
modelsToEnable.map((model) => upsertModelHandler(model, { is_active: true }, false))
|
||||
);
|
||||
|
||||
await tick();
|
||||
await init();
|
||||
};
|
||||
|
||||
const disableAllHandler = async () => {
|
||||
|
|
@ -115,8 +120,11 @@
|
|||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(
|
||||
modelsToDisable.map((model) => toggleModelById(localStorage.token, model.id))
|
||||
modelsToDisable.map((model) => upsertModelHandler(model, { is_active: false }, false))
|
||||
);
|
||||
|
||||
await tick();
|
||||
await init();
|
||||
};
|
||||
|
||||
const showAllHandler = async () => {
|
||||
|
|
@ -127,8 +135,15 @@
|
|||
});
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToShow.map((model) => upsertModelHandler(model, false)));
|
||||
await Promise.all(
|
||||
modelsToShow.map((model) =>
|
||||
upsertModelHandler(model, { meta: { ...model.meta, hidden: false } }, false)
|
||||
)
|
||||
);
|
||||
|
||||
toast.success($i18n.t('All models are now visible'));
|
||||
await tick();
|
||||
await init();
|
||||
};
|
||||
|
||||
const hideAllHandler = async () => {
|
||||
|
|
@ -139,8 +154,15 @@
|
|||
});
|
||||
models = models;
|
||||
// Sync with server
|
||||
await Promise.all(modelsToHide.map((model) => upsertModelHandler(model, false)));
|
||||
await Promise.all(
|
||||
modelsToHide.map((model) =>
|
||||
upsertModelHandler(model, { meta: { ...model.meta, hidden: true } }, false)
|
||||
)
|
||||
);
|
||||
|
||||
toast.success($i18n.t('All models are now hidden'));
|
||||
await tick();
|
||||
await init();
|
||||
};
|
||||
|
||||
const downloadModels = async (models) => {
|
||||
|
|
@ -174,10 +196,17 @@
|
|||
};
|
||||
}
|
||||
});
|
||||
|
||||
_models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const upsertModelHandler = async (model, showToast = true) => {
|
||||
model.base_model_id = null;
|
||||
const upsertModelHandler = async (model, overrides = {}, showToast = true) => {
|
||||
model = { ...model, base_model_id: null, ...overrides };
|
||||
|
||||
if (workspaceModels.find((m) => m.id === model.id)) {
|
||||
const res = await updateModelById(localStorage.token, model.id, model).catch((error) => {
|
||||
|
|
@ -200,18 +229,11 @@
|
|||
return null;
|
||||
});
|
||||
|
||||
if (res && !silent) {
|
||||
if (res && showToast) {
|
||||
toast.success($i18n.t('Model updated successfully'));
|
||||
await init();
|
||||
}
|
||||
}
|
||||
await init();
|
||||
|
||||
_models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const toggleModelHandler = async (model) => {
|
||||
|
|
@ -248,7 +270,7 @@
|
|||
|
||||
console.debug(model);
|
||||
|
||||
upsertModelHandler(model, false);
|
||||
upsertModelHandler(model, { meta: model.meta }, false);
|
||||
|
||||
toast.success(
|
||||
model.meta.hidden
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@
|
|||
onChange={() => onChange(value)}
|
||||
>
|
||||
<svelte:fragment slot="trigger" let:selectedLabel>
|
||||
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
|
||||
<span
|
||||
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
|
||||
>
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
class="min-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
>
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
hideHandler();
|
||||
}}
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
pinModelHandler(model?.id);
|
||||
}}
|
||||
|
|
@ -123,7 +123,7 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
copyLinkHandler();
|
||||
}}
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
|
||||
{#if model?.is_active ?? true}
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
cloneHandler();
|
||||
}}
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
{/if}
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
|
||||
on:click={() => {
|
||||
exportHandler();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1182,7 +1182,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -183,7 +183,9 @@
|
|||
align="end"
|
||||
>
|
||||
<svelte:fragment slot="trigger" let:selectedLabel>
|
||||
<span class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
|
||||
<span
|
||||
class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
|
||||
>
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@
|
|||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
{$i18n.t('Last Active')}
|
||||
<!-- {$i18n.t('Last Modified')} -->
|
||||
|
||||
{#if orderBy === 'last_active_at'}
|
||||
<span class="font-normal"
|
||||
|
|
|
|||
|
|
@ -622,6 +622,25 @@
|
|||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
char: ':',
|
||||
allowSpaces: false,
|
||||
command: ({ editor, range, props }) => {
|
||||
// Convert the Unicode hex codepoint (e.g. "1F44B") to the actual emoji character (👋)
|
||||
const codepoint = props.id;
|
||||
const emoji = String.fromCodePoint(parseInt(codepoint, 16));
|
||||
editor.chain().focus().deleteRange(range).insertContent(emoji).run();
|
||||
},
|
||||
render: getSuggestionRenderer(CommandSuggestionList, {
|
||||
i18n,
|
||||
onSelect: (e) => {
|
||||
document.getElementById('chat-input')?.focus();
|
||||
},
|
||||
|
||||
insertTextHandler: insertTextAtCursor,
|
||||
onUpload: () => {}
|
||||
})
|
||||
}
|
||||
];
|
||||
loaded = true;
|
||||
|
|
@ -925,7 +944,11 @@
|
|||
}
|
||||
|
||||
// Submit the content when Enter key is pressed
|
||||
if ((content !== '' || files.length > 0) && e.keyCode === 13 && !e.shiftKey) {
|
||||
if (
|
||||
(content !== '' || files.length > 0) &&
|
||||
e.keyCode === 13 &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
submitHandler();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,4 +70,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,67 @@
|
|||
let editedContent = null;
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
// Swipe-to-reply state
|
||||
let swipeStartX = 0;
|
||||
let swipeStartY = 0;
|
||||
let swipeOffsetX = 0;
|
||||
let isSwiping = false;
|
||||
let swipeLocked = false; // locked to horizontal once determined
|
||||
let swipeMessageEl: HTMLElement | null = null;
|
||||
|
||||
const SWIPE_THRESHOLD = 60;
|
||||
const SWIPE_MAX = 100;
|
||||
const SWIPE_DEAD_ZONE = 10;
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
if (disabled || edit || !onReply) return;
|
||||
const touch = e.touches[0];
|
||||
swipeStartX = touch.clientX;
|
||||
swipeStartY = touch.clientY;
|
||||
swipeOffsetX = 0;
|
||||
isSwiping = false;
|
||||
swipeLocked = false;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (disabled || edit || !onReply) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaX = touch.clientX - swipeStartX;
|
||||
const deltaY = touch.clientY - swipeStartY;
|
||||
|
||||
// Determine swipe direction from dead zone
|
||||
if (!swipeLocked && (Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)) {
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
// Vertical scroll — abort swipe tracking
|
||||
isSwiping = false;
|
||||
swipeLocked = true;
|
||||
return;
|
||||
}
|
||||
// Horizontal swipe — lock in
|
||||
swipeLocked = true;
|
||||
isSwiping = true;
|
||||
}
|
||||
|
||||
if (!isSwiping) return;
|
||||
|
||||
// Only allow right swipe
|
||||
const clampedX = Math.max(0, deltaX);
|
||||
// Dampen the motion beyond threshold for a rubber-band feel
|
||||
swipeOffsetX = clampedX <= SWIPE_THRESHOLD
|
||||
? clampedX
|
||||
: SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3;
|
||||
swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (isSwiping && swipeOffsetX >= SWIPE_THRESHOLD && onReply) {
|
||||
onReply(message);
|
||||
}
|
||||
swipeOffsetX = 0;
|
||||
isSwiping = false;
|
||||
swipeLocked = false;
|
||||
};
|
||||
|
||||
const loadMessageData = async () => {
|
||||
if (message && message?.data === true) {
|
||||
const res = await getMessageData(localStorage.token, channel?.id, message.id);
|
||||
|
|
@ -92,20 +153,39 @@
|
|||
|
||||
{#if message}
|
||||
<div
|
||||
id="message-{message.id}"
|
||||
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative {className
|
||||
? className
|
||||
: `px-5 ${
|
||||
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
|
||||
} ${
|
||||
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
|
||||
$user?.id
|
||||
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
|
||||
: ''
|
||||
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
|
||||
? 'pt-1.5 pb-0.5'
|
||||
: ''}"
|
||||
class="swipe-reply-wrapper relative"
|
||||
on:touchstart={handleTouchStart}
|
||||
on:touchmove={handleTouchMove}
|
||||
on:touchend={handleTouchEnd}
|
||||
>
|
||||
<!-- Swipe reply indicator -->
|
||||
{#if swipeOffsetX > 0}
|
||||
<div
|
||||
class="swipe-reply-indicator"
|
||||
style="opacity: {Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1)}; transform: scale({0.5 + Math.min(swipeOffsetX / SWIPE_THRESHOLD, 1) * 0.5});"
|
||||
>
|
||||
<div class="swipe-reply-icon" class:swipe-reply-icon--active={swipeOffsetX >= SWIPE_THRESHOLD}>
|
||||
<ArrowUpLeftAlt className="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
id="message-{message.id}"
|
||||
class="flex flex-col justify-between w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 relative {className
|
||||
? className
|
||||
: `px-5 ${
|
||||
replyToMessage ? 'border-l-4 border-blue-500 bg-blue-100/10 dark:bg-blue-100/5 pl-4' : ''
|
||||
} ${
|
||||
(message?.reply_to_message?.meta?.model_id ?? message?.reply_to_message?.user_id) ===
|
||||
$user?.id
|
||||
? 'border-l-4 border-orange-500 bg-orange-100/10 dark:bg-orange-100/5 pl-4'
|
||||
: ''
|
||||
} ${message?.is_pinned ? 'bg-yellow-100/20 dark:bg-yellow-100/5' : ''}`} {showUserProfile
|
||||
? 'pt-1.5 pb-0.5'
|
||||
: ''}"
|
||||
style="transform: translateX({swipeOffsetX}px); {swipeOffsetX > 0 ? '' : 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
|
||||
>
|
||||
{#if !edit && !disabled}
|
||||
<div
|
||||
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
|
||||
|
|
@ -545,6 +625,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -561,4 +642,48 @@
|
|||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Swipe-to-reply styles */
|
||||
.swipe-reply-wrapper {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-reply-indicator {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.swipe-reply-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(128, 128, 128, 0.15);
|
||||
color: rgba(128, 128, 128, 0.8);
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.swipe-reply-icon--active {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
:global(.dark) .swipe-reply-icon {
|
||||
background-color: rgba(200, 200, 200, 0.1);
|
||||
color: rgba(200, 200, 200, 0.6);
|
||||
}
|
||||
|
||||
:global(.dark) .swipe-reply-icon--active {
|
||||
background-color: rgba(96, 165, 250, 0.2);
|
||||
color: rgb(96, 165, 250);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,9 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
|
||||
<div
|
||||
class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
|
||||
>
|
||||
{#if channel}
|
||||
<Tooltip content={$i18n.t('Pinned Messages')}>
|
||||
<button
|
||||
|
|
@ -164,7 +166,7 @@
|
|||
showChannelPinnedMessagesModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" flex items-center gap-0.5 m-auto self-center">
|
||||
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
|
||||
<Pin className=" size-4" strokeWidth="1.5" />
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -173,17 +175,17 @@
|
|||
{#if channel?.user_count !== undefined}
|
||||
<Tooltip content={$i18n.t('Users')}>
|
||||
<button
|
||||
class=" flex cursor-pointer py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
class=" flex cursor-pointer shrink-0 py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
aria-label="User Count"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showChannelInfoModal = true;
|
||||
}}
|
||||
>
|
||||
<div class=" flex items-center gap-0.5 m-auto self-center">
|
||||
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
|
||||
<UserAlt className=" size-4" strokeWidth="1.5" />
|
||||
|
||||
<div class="text-sm">
|
||||
<div class="text-sm shrink-0">
|
||||
{channel.user_count}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,30 +195,32 @@
|
|||
{/if}
|
||||
|
||||
{#if $user !== undefined}
|
||||
<UserMenu
|
||||
className="w-[240px]"
|
||||
role={$user?.role}
|
||||
help={true}
|
||||
on:show={(e) => {
|
||||
if (e.detail === 'archived-chat') {
|
||||
showArchivedChats.set(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
aria-label="User Menu"
|
||||
<div>
|
||||
<UserMenu
|
||||
className="w-[240px]"
|
||||
role={$user?.role}
|
||||
help={true}
|
||||
on:show={(e) => {
|
||||
if (e.detail === 'archived-chat') {
|
||||
showArchivedChats.set(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center">
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
|
||||
class="size-6 object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</UserMenu>
|
||||
<button
|
||||
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
|
||||
aria-label="User Menu"
|
||||
>
|
||||
<div class=" self-center">
|
||||
<img
|
||||
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
|
||||
class="size-6 object-cover rounded-full"
|
||||
alt="User profile"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</UserMenu>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@
|
|||
|
||||
let selectedToolIds = [];
|
||||
let selectedFilterIds = [];
|
||||
let pendingOAuthTools = [];
|
||||
|
||||
let imageGenerationEnabled = false;
|
||||
let webSearchEnabled = false;
|
||||
|
|
@ -158,6 +159,8 @@
|
|||
let chat = null;
|
||||
let tags = [];
|
||||
|
||||
let chatTasks = [];
|
||||
|
||||
let history = {
|
||||
messages: {},
|
||||
currentId: null
|
||||
|
|
@ -171,8 +174,6 @@
|
|||
let files = [];
|
||||
let params = {};
|
||||
|
||||
|
||||
|
||||
$: if (chatIdProp) {
|
||||
navigateHandler();
|
||||
}
|
||||
|
|
@ -276,6 +277,7 @@
|
|||
const resetInput = () => {
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
pendingOAuthTools = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
codeInterpreterEnabled = false;
|
||||
|
|
@ -300,11 +302,29 @@
|
|||
if (model) {
|
||||
// Set Default Tools
|
||||
if (model?.info?.meta?.toolIds) {
|
||||
selectedToolIds = [
|
||||
const defaultIds = [
|
||||
...new Set(
|
||||
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
||||
)
|
||||
];
|
||||
|
||||
// Separate unauthenticated OAuth tools
|
||||
const unauthed = [];
|
||||
const authed = [];
|
||||
for (const id of defaultIds) {
|
||||
const tool = $tools.find((t) => t.id === id);
|
||||
if (tool && tool.authenticated === false) {
|
||||
const parts = id.split(':');
|
||||
const serverId = parts.at(-1) ?? id;
|
||||
const authType =
|
||||
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
|
||||
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
|
||||
} else {
|
||||
authed.push(id);
|
||||
}
|
||||
}
|
||||
selectedToolIds = authed;
|
||||
pendingOAuthTools = unauthed;
|
||||
} else if ($settings?.tools) {
|
||||
selectedToolIds = $settings.tools;
|
||||
} else {
|
||||
|
|
@ -431,6 +451,8 @@
|
|||
message.content = data.content;
|
||||
} else if (type === 'chat:message:files' || type === 'files') {
|
||||
message.files = data.files;
|
||||
} else if (type === 'chat:message:tasks') {
|
||||
chatTasks = data.tasks;
|
||||
} else if (type === 'chat:message:embeds' || type === 'embeds') {
|
||||
message.embeds = data.embeds;
|
||||
|
||||
|
|
@ -962,10 +984,9 @@
|
|||
let contents = [];
|
||||
messages.forEach((message) => {
|
||||
if (message?.role !== 'user' && message?.content) {
|
||||
const {
|
||||
codeBlocks: codeBlocks,
|
||||
htmlGroups: htmlGroups
|
||||
} = getCodeBlockContents(message.content);
|
||||
const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } = getCodeBlockContents(
|
||||
message.content
|
||||
);
|
||||
|
||||
if (htmlGroups && htmlGroups.length > 0) {
|
||||
htmlGroups.forEach((group) => {
|
||||
|
|
@ -1139,6 +1160,7 @@
|
|||
chatFiles = [];
|
||||
params = {};
|
||||
taskIds = null;
|
||||
chatTasks = [];
|
||||
|
||||
if ($page.url.searchParams.get('youtube')) {
|
||||
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
|
||||
|
|
@ -1174,6 +1196,15 @@
|
|||
.filter((id) => id);
|
||||
}
|
||||
|
||||
// Restore tool selection after OAuth redirect
|
||||
const pendingToolId = sessionStorage.getItem('pendingOAuthToolId');
|
||||
if (pendingToolId) {
|
||||
sessionStorage.removeItem('pendingOAuthToolId');
|
||||
if (!selectedToolIds.includes(pendingToolId)) {
|
||||
selectedToolIds = [...selectedToolIds, pendingToolId];
|
||||
}
|
||||
}
|
||||
|
||||
if ($page.url.searchParams.get('call') === 'true') {
|
||||
showCallOverlay.set(true);
|
||||
showControls.set(true);
|
||||
|
|
@ -1242,6 +1273,9 @@
|
|||
params = chatContent?.params ?? {};
|
||||
chatFiles = chatContent?.files ?? [];
|
||||
|
||||
// Load tasks from chat-level DB field
|
||||
chatTasks = chat?.tasks ?? [];
|
||||
|
||||
autoScroll = true;
|
||||
await tick();
|
||||
|
||||
|
|
@ -1744,6 +1778,10 @@
|
|||
selectedModels = _selectedModels;
|
||||
}
|
||||
|
||||
if (pendingOAuthTools.length > 0) {
|
||||
toast.warning($i18n.t('Please connect all required integrations before sending a message'));
|
||||
return;
|
||||
}
|
||||
if (userPrompt === '' && files.length === 0) {
|
||||
toast.error($i18n.t('Please enter a prompt'));
|
||||
return;
|
||||
|
|
@ -1786,10 +1824,7 @@
|
|||
const _files = structuredClone(files);
|
||||
chatRequestQueues.update((q) => ({
|
||||
...q,
|
||||
[$chatId]: [
|
||||
...(q[$chatId] ?? []),
|
||||
{ id: uuidv4(), prompt: userPrompt, files: _files }
|
||||
]
|
||||
[$chatId]: [...(q[$chatId] ?? []), { id: uuidv4(), prompt: userPrompt, files: _files }]
|
||||
}));
|
||||
// Clear input
|
||||
messageInput?.setText('');
|
||||
|
|
@ -1990,9 +2025,6 @@
|
|||
}
|
||||
})
|
||||
);
|
||||
|
||||
currentChatPage.set(1);
|
||||
chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
};
|
||||
|
||||
const getFeatures = () => {
|
||||
|
|
@ -2597,8 +2629,6 @@
|
|||
params: params,
|
||||
files: chatFiles
|
||||
});
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -2839,6 +2869,7 @@
|
|||
bind:selectedFilterIds
|
||||
bind:imageGenerationEnabled
|
||||
bind:codeInterpreterEnabled
|
||||
{pendingOAuthTools}
|
||||
bind:webSearchEnabled
|
||||
bind:atSelectedModel
|
||||
bind:showCommands
|
||||
|
|
@ -2849,6 +2880,7 @@
|
|||
{createMessagePair}
|
||||
{onUpload}
|
||||
messageQueue={$chatRequestQueues[$chatId] ?? []}
|
||||
{chatTasks}
|
||||
onQueueSendNow={async (id) => {
|
||||
const queue = $chatRequestQueues[$chatId] ?? [];
|
||||
const item = queue.find((m) => m.id === id);
|
||||
|
|
@ -2926,6 +2958,7 @@
|
|||
bind:atSelectedModel
|
||||
bind:showCommands
|
||||
bind:dragged
|
||||
{pendingOAuthTools}
|
||||
toolServers={$toolServers}
|
||||
{stopResponse}
|
||||
{createMessagePair}
|
||||
|
|
|
|||
|
|
@ -95,10 +95,12 @@
|
|||
showControls.set(true);
|
||||
}
|
||||
|
||||
// Auto-open Files tab when a terminal is selected
|
||||
// Auto-open Files tab when a terminal is selected (suppress panel open when full-screen)
|
||||
$: if ($selectedTerminalId) {
|
||||
activeTab = 'files';
|
||||
showControls.set(true);
|
||||
if (largeScreen) {
|
||||
showControls.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach a terminal file to the chat input
|
||||
|
|
@ -289,7 +291,7 @@
|
|||
<!-- Controls + Files tabs -->
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
|
||||
<div class="flex items-center justify-between px-2 pt-2 pb-2 shrink-0">
|
||||
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
|
||||
{#if showControlsTab}
|
||||
<button
|
||||
|
|
@ -360,7 +362,7 @@
|
|||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} />
|
||||
<FileNav onAttach={handleTerminalAttach} {chatId} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav />
|
||||
{:else}
|
||||
|
|
@ -435,7 +437,7 @@
|
|||
<!-- Controls + Files tabs -->
|
||||
<div class="flex flex-col h-full min-h-0">
|
||||
<!-- Tab bar -->
|
||||
<div class="flex items-center justify-between px-2 pt-2.5 pb-2 shrink-0">
|
||||
<div class="flex items-center justify-between px-2 pt-2 pb-2 shrink-0">
|
||||
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
|
||||
{#if showControlsTab}
|
||||
<button
|
||||
|
|
@ -511,7 +513,7 @@
|
|||
onClose={() => showControls.set(false)}
|
||||
/>
|
||||
{:else if activeTab === 'files' && $selectedTerminalId}
|
||||
<FileNav onAttach={handleTerminalAttach} overlay={dragged} />
|
||||
<FileNav onAttach={handleTerminalAttach} overlay={dragged} {chatId} />
|
||||
{:else if activeTab === 'files' && codeInterpreterEnabled}
|
||||
<PyodideFileNav overlay={dragged} />
|
||||
{:else}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
93
src/lib/components/chat/FileNav/BulkActionBar.svelte
Normal file
93
src/lib/components/chat/FileNav/BulkActionBar.svelte
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import GarbageBin from '../../icons/GarbageBin.svelte';
|
||||
import Tooltip from '../../common/Tooltip.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let count: number = 0;
|
||||
export let hasFiles: boolean = false;
|
||||
|
||||
export let onDelete: () => void = () => {};
|
||||
export let onDownload: () => void = () => {};
|
||||
export let onSelectAll: () => void = () => {};
|
||||
export let onClear: () => void = () => {};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-800/50 shrink-0">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 flex-1 truncate">
|
||||
{$i18n.t('{{count}} selected', { count })}
|
||||
</span>
|
||||
|
||||
<Tooltip content={$i18n.t('Select All')}>
|
||||
<button
|
||||
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onSelectAll}
|
||||
aria-label={$i18n.t('Select All')}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={$i18n.t('Download')}>
|
||||
<button
|
||||
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onDownload}
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onDelete}
|
||||
aria-label={$i18n.t('Delete')}
|
||||
>
|
||||
<GarbageBin className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={$i18n.t('Deselect')}>
|
||||
<button
|
||||
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
on:click={onClear}
|
||||
aria-label={$i18n.t('Deselect')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, tick, onDestroy } from 'svelte';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
import type { FileEntry } from '$lib/apis/terminal';
|
||||
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import Folder from '../../icons/Folder.svelte';
|
||||
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
|
||||
import GarbageBin from '../../icons/GarbageBin.svelte';
|
||||
import Pencil from '../../icons/Pencil.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -19,13 +20,109 @@
|
|||
export let onDownload: (path: string) => void = () => {};
|
||||
export let onDelete: (path: string, name: string) => void = () => {};
|
||||
export let onMove: (source: string, destFolder: string) => void = () => {};
|
||||
export let onRename: (oldPath: string, newName: string) => void = () => {};
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────
|
||||
export let selected: boolean = false;
|
||||
export let selectionMode: boolean = false;
|
||||
export let selectedPaths: Set<string> = new Set();
|
||||
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
|
||||
export let onLongPress: () => void = () => {};
|
||||
|
||||
let dragOverFolder = false;
|
||||
|
||||
// ── Rename state ─────────────────────────────────────────────────────
|
||||
let renaming = false;
|
||||
let renameValue = '';
|
||||
let renameInput: HTMLInputElement;
|
||||
|
||||
const startRename = async () => {
|
||||
renameValue = entry.name;
|
||||
renaming = true;
|
||||
await tick();
|
||||
renameInput?.focus();
|
||||
if (entry.type === 'file') {
|
||||
const dotIdx = entry.name.lastIndexOf('.');
|
||||
renameInput?.setSelectionRange(0, dotIdx > 0 ? dotIdx : entry.name.length);
|
||||
} else {
|
||||
renameInput?.select();
|
||||
}
|
||||
};
|
||||
|
||||
const submitRename = () => {
|
||||
const newName = renameValue.trim();
|
||||
renaming = false;
|
||||
if (!newName || newName === entry.name) return;
|
||||
onRename(`${currentPath}${entry.name}`, newName);
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
renaming = false;
|
||||
renameValue = '';
|
||||
};
|
||||
|
||||
// ── Long-press for touch selection ───────────────────────────────────
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let didLongPress = false;
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType !== 'touch') return;
|
||||
didLongPress = false;
|
||||
longPressTimer = setTimeout(() => {
|
||||
didLongPress = true;
|
||||
onLongPress();
|
||||
onSelect(entry, e as any);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerCancel = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
if (longPressTimer) clearTimeout(longPressTimer);
|
||||
});
|
||||
|
||||
// ── Click handler ────────────────────────────────────────────────────
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (renaming) return;
|
||||
if (didLongPress) {
|
||||
didLongPress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Modifier click → toggle/range select
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSelect(entry, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// In selection mode (touch) → toggle select
|
||||
if (selectionMode) {
|
||||
onSelect(entry, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click → open
|
||||
onOpen(entry);
|
||||
};
|
||||
</script>
|
||||
|
||||
<li class="group">
|
||||
<div
|
||||
class="w-full flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 transition
|
||||
class="w-full flex items-center transition
|
||||
{selected ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}
|
||||
{dragOverFolder
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
|
||||
: ''}"
|
||||
|
|
@ -51,11 +148,11 @@
|
|||
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);
|
||||
const paths = data.paths || (data.path ? [data.path] : []);
|
||||
const destFolder = `${currentPath}${entry.name}/`;
|
||||
for (const p of paths) {
|
||||
if (p + '/' === destFolder || p === destFolder) continue;
|
||||
onMove(p, destFolder);
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
|
|
@ -65,12 +162,26 @@
|
|||
draggable={true}
|
||||
on:dragstart={(e) => {
|
||||
const filePath = `${currentPath}${entry.name}`;
|
||||
// Internal move data
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file-move',
|
||||
JSON.stringify({ path: filePath, name: entry.name })
|
||||
);
|
||||
// Keep existing chat-attachment drag for files
|
||||
// If dragging a selected item, drag all selected
|
||||
if (selected && selectedPaths.size > 1) {
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file-move',
|
||||
JSON.stringify({ paths: [...selectedPaths] })
|
||||
);
|
||||
// Custom drag ghost showing count
|
||||
const ghost = document.createElement('div');
|
||||
ghost.style.cssText =
|
||||
'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:8px;background:#374151;color:#fff;font-size:12px;white-space:nowrap;pointer-events:none;';
|
||||
ghost.textContent = `${selectedPaths.size} items`;
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer?.setDragImage(ghost, 0, 0);
|
||||
requestAnimationFrame(() => ghost.remove());
|
||||
} else {
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file-move',
|
||||
JSON.stringify({ path: filePath, name: entry.name })
|
||||
);
|
||||
}
|
||||
if (entry.type === 'file') {
|
||||
e.dataTransfer?.setData(
|
||||
'application/x-terminal-file',
|
||||
|
|
@ -83,8 +194,38 @@
|
|||
);
|
||||
}
|
||||
}}
|
||||
on:click={() => onOpen(entry)}
|
||||
on:pointerdown={onPointerDown}
|
||||
on:pointerup={onPointerUp}
|
||||
on:pointercancel={onPointerCancel}
|
||||
on:click={handleClick}
|
||||
on:dblclick|preventDefault|stopPropagation={() => {
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
{#if selectionMode || selected}
|
||||
<!-- Checkbox indicator -->
|
||||
<div
|
||||
class="size-3.5 shrink-0 rounded border transition-colors flex items-center justify-center
|
||||
{selected
|
||||
? 'bg-blue-500 dark:bg-blue-600 border-blue-500 dark:border-blue-600 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600'}"
|
||||
>
|
||||
{#if selected}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-2.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}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.type === 'directory'}
|
||||
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
|
||||
{:else}
|
||||
|
|
@ -103,10 +244,31 @@
|
|||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{#if entry.type === 'file' && entry.size !== undefined}
|
||||
{#if renaming}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<input
|
||||
bind:this={renameInput}
|
||||
bind:value={renameValue}
|
||||
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 text-gray-800 dark:text-gray-200 min-w-0"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submitRename();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
on:blur={submitRename}
|
||||
on:click|stopPropagation
|
||||
/>
|
||||
{:else}
|
||||
<span class="flex-1 text-xs text-gray-800 dark:text-gray-200 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
|
@ -125,31 +287,45 @@
|
|||
<div
|
||||
class="min-w-[150px] rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{#if entry.type !== 'directory'}
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(`${currentPath}${entry.name}`);
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
const path =
|
||||
entry.type === 'directory'
|
||||
? `${currentPath}${entry.name}/`
|
||||
: `${currentPath}${entry.name}`;
|
||||
onDownload(path);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
<path
|
||||
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-center">{$i18n.t('Download')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename();
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" strokeWidth="1.5" />
|
||||
<div class="flex items-center">{$i18n.t('Rename')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -18,8 +18,15 @@
|
|||
export let onNewFolder: () => void = () => {};
|
||||
export let onNewFile: () => void = () => {};
|
||||
export let onUploadFiles: (files: File[]) => void = () => {};
|
||||
export let onDownloadDir: () => void = () => {};
|
||||
export let onMove: (source: string, destFolder: string) => void = () => {};
|
||||
|
||||
// Back / forward navigation
|
||||
export let canGoBack = false;
|
||||
export let canGoForward = false;
|
||||
export let onGoBack: () => void = () => {};
|
||||
export let onGoForward: () => void = () => {};
|
||||
|
||||
let dragOverCrumb: number | null = null;
|
||||
|
||||
let uploadInput: HTMLInputElement;
|
||||
|
|
@ -32,6 +39,56 @@
|
|||
</script>
|
||||
|
||||
<div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
|
||||
<!-- Back -->
|
||||
<Tooltip content={$i18n.t('Back')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded transition {canGoBack
|
||||
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
|
||||
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
|
||||
on:click={onGoBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label={$i18n.t('Back')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.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>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Forward -->
|
||||
<Tooltip content={$i18n.t('Forward')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded transition {canGoForward
|
||||
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
|
||||
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
|
||||
on:click={onGoForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label={$i18n.t('Forward')}
|
||||
>
|
||||
<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.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 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
bind:this={breadcrumbEl}
|
||||
class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none"
|
||||
|
|
@ -66,7 +123,8 @@
|
|||
dragOverCrumb = null;
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data.path) onMove(data.path, crumb.path);
|
||||
const paths = data.paths || (data.path ? [data.path] : []);
|
||||
for (const p of paths) onMove(p, crumb.path);
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
|
|
@ -121,6 +179,27 @@
|
|||
<FilePlusAlt className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<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"
|
||||
on:click={onDownloadDir}
|
||||
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>
|
||||
</Tooltip>
|
||||
<Tooltip content={$i18n.t('Upload')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
let editTextarea: HTMLTextAreaElement;
|
||||
|
||||
// Reset edit state when switching files
|
||||
$: selectedFile, resetEdit();
|
||||
$: (selectedFile, resetEdit());
|
||||
|
||||
const resetEdit = () => {
|
||||
editing = false;
|
||||
|
|
@ -242,7 +242,7 @@
|
|||
}
|
||||
|
||||
export let showRaw = false;
|
||||
$: selectedFile, (showRaw = false); // reset to preview mode when switching files
|
||||
$: (selectedFile, (showRaw = false)); // reset to preview mode when switching files
|
||||
|
||||
// Auto-switch to raw/editor mode for empty previewable files so the user
|
||||
// can start editing immediately instead of seeing a blank preview.
|
||||
|
|
@ -418,7 +418,7 @@
|
|||
title="HTML Preview"
|
||||
/>
|
||||
{:else if isHtml && showRaw}
|
||||
<div class="h-full">
|
||||
<div class="absolute inset-0">
|
||||
<FileCodeEditor
|
||||
bind:this={fileCodeEditorRef}
|
||||
value={fileContent ?? ''}
|
||||
|
|
@ -431,7 +431,7 @@
|
|||
{@html renderedHtml}
|
||||
</div>
|
||||
{:else if isMarkdown && showRaw}
|
||||
<div class="h-full">
|
||||
<div class="absolute inset-0">
|
||||
<FileCodeEditor
|
||||
bind:this={fileCodeEditorRef}
|
||||
value={fileContent ?? ''}
|
||||
|
|
@ -488,7 +488,7 @@
|
|||
})}
|
||||
</div>
|
||||
{:else if isCode && !showRaw}
|
||||
<div class="h-full">
|
||||
<div class="absolute inset-0">
|
||||
<FileCodeEditor
|
||||
bind:this={fileCodeEditorRef}
|
||||
value={fileContent ?? ''}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue