Merge branch 'open-webui:main' into fix-milvus-limit-error

This commit is contained in:
Classic298 2025-09-26 10:42:53 +02:00 committed by GitHub
commit b550d78905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 7583 additions and 2576 deletions

View file

@ -5,6 +5,60 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.31] - 2025-09-25
### Added
- 🔌 MCP (streamable HTTP) server support was added alongside existing OpenAPI server integration, allowing users to connect both server types through an improved server configuration interface. [#15932](https://github.com/open-webui/open-webui/issues/15932) [#16651](https://github.com/open-webui/open-webui/pull/16651), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406), [Commit](https://github.com/open-webui/open-webui/commit/777e81f7a8aca957a359d51df8388e5af4721a68), [Commit](https://github.com/open-webui/open-webui/commit/de7f7b3d855641450f8e5aac34fbae0665e0b80e), [Commit](https://github.com/open-webui/open-webui/commit/f1bbf3a91e4713039364b790e886e59b401572d0), [Commit](https://github.com/open-webui/open-webui/commit/c55afc42559c32a6f0c8beb0f1bb18e9360ab8af), [Commit](https://github.com/open-webui/open-webui/commit/61f20acf61f4fe30c0e5b0180949f6e1a8cf6524)
- 🔐 To enable MCP server authentication, OAuth 2.1 dynamic client registration was implemented with secure automatic client registration, encrypted session management, and seamless authentication flows. [Commit](https://github.com/open-webui/open-webui/commit/972be4eda5a394c111e849075f94099c9c0dd9aa), [Commit](https://github.com/open-webui/open-webui/commit/77e971dd9fbeee806e2864e686df5ec75e82104b), [Commit](https://github.com/open-webui/open-webui/commit/879abd7feea3692a2f157da4a458d30f27217508), [Commit](https://github.com/open-webui/open-webui/commit/422d38fd114b1ebd8a7dbb114d64e14791e67d7a), [Docs:#709](https://github.com/open-webui/docs/pull/709)
- 🛠️ External & Built-In Tools can now support rich UI element embedding ([Docs](https://docs.openwebui.com/features/plugin/tools/development)), allowing tools to return HTML content and interactive iframes that display directly within chat conversations with configurable security settings. [Commit](https://github.com/open-webui/open-webui/commit/07c5b25bc8b63173f406feb3ba183d375fedee6a), [Commit](https://github.com/open-webui/open-webui/commit/a5d8882bba7933a2c2c31c0a1405aba507c370bb), [Commit](https://github.com/open-webui/open-webui/commit/7be5b7f50f498de97359003609fc5993a172f084), [Commit](https://github.com/open-webui/open-webui/commit/a89ffccd7e96705a4a40e845289f4fcf9c4ae596)
- 📝 Note editor now supports drag-and-drop reordering of list items with visual drag handles, making list organization more intuitive and efficient. [Commit](https://github.com/open-webui/open-webui/commit/e4e97e727e9b4971f1c363b1280ca3a101599d88), [Commit](https://github.com/open-webui/open-webui/commit/aeb5288a3c7a6e9e0a47b807cc52f870c1b7dbe6)
- 🔍 Search modal was enhanced with quick action buttons for starting new conversations and creating notes, with intelligent content pre-population from search queries. [Commit](https://github.com/open-webui/open-webui/commit/aa6f63a335e172fec1dc94b2056541f52c1167a6), [Commit](https://github.com/open-webui/open-webui/commit/612a52d7bb7dbe9fa0bbbc8ac0a552d2b9801146), [Commit](https://github.com/open-webui/open-webui/commit/b03529b006f3148e895b1094584e1ab129ecac5b)
- 🛠️ Tool user valve configuration interface was added to the integrations menu, displaying clickable gear icon buttons with tooltips for tools that support user-specific settings, making personal tool configurations easily accessible. [Commit](https://github.com/open-webui/open-webui/commit/27d61307cdce97ed11a05ec13fc300249d6022cd)
- 👥 Channel access control was enhanced to require write permissions for posting, editing, and deleting messages, while read-only users can view content but cannot contribute. [#17543](https://github.com/open-webui/open-webui/pull/17543)
- 💬 Channel models now support image processing, allowing AI assistants to view and analyze images shared in conversation threads. [Commit](https://github.com/open-webui/open-webui/commit/9f0010e234a6f40782a66021435d3c02b9c23639)
- 🌐 Attach Webpage button was added to the message input menu, providing a user-friendly modal interface for attaching web content and YouTube videos as an alternative to the existing URL syntax. [#17534](https://github.com/open-webui/open-webui/pull/17534)
- 🔐 Redis session storage support was added for OAuth redirects, providing better state handling in multi-pod Kubernetes deployments and resolving CSRF mismatch errors. [#17223](https://github.com/open-webui/open-webui/pull/17223), [#15373](https://github.com/open-webui/open-webui/issues/15373)
- 🔍 Ollama Cloud web search integration was added as a new search engine option, providing access to web search functionality through Ollama's cloud infrastructure. [Commit](https://github.com/open-webui/open-webui/commit/e06489d92baca095b8f376fbef223298c7772579), [Commit](https://github.com/open-webui/open-webui/commit/4b6d34438bcfc45463dc7a9cb984794b32c1f0a1), [Commit](https://github.com/open-webui/open-webui/commit/05c46008da85357dc6890b846789dfaa59f4a520), [Commit](https://github.com/open-webui/open-webui/commit/fe65fe0b97ec5a8fff71592ff04a25c8e123d108), [Docs:#708](https://github.com/open-webui/docs/pull/708)
- 🔍 Perplexity Websearch API integration was added as a new search engine option, providing access to the new websearch functionality provided by Perplexity. [#17756](https://github.com/open-webui/open-webui/issues/17756), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/7f411dd5cc1c29733216f79e99eeeed0406a2afe)
- ☁️ OneDrive integration was improved to support separate client IDs for personal and business authentication, enabling both integrations to work simultaneously. [#17619](https://github.com/open-webui/open-webui/pull/17619), [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
- 📝 Pending user overlay content now supports markdown formatting, enabling rich text display for custom messages similar to banner functionality. [#17681](https://github.com/open-webui/open-webui/pull/17681)
- 🎨 Image generation model selection was centralized to enable dynamic model override in function calls, allowing pipes and tools to specify different models than the global default while maintaining backward compatibility. [#17689](https://github.com/open-webui/open-webui/pull/17689)
- 🎨 Interface design was modernized with updated visual styling, improved spacing, and refined component layouts across modals, sidebar, settings, and navigation elements. [Commit](https://github.com/open-webui/open-webui/commit/27a91cc80a24bda0a3a188bc3120a8ab57b00881), [Commit](https://github.com/open-webui/open-webui/commit/4ad743098615f9c58daa9df392f31109aeceeb16), [Commit](https://github.com/open-webui/open-webui/commit/fd7385c3921eb59af76a26f4c475aedb38ce2406)
- 📊 Notes query performance was optimized through database-level filtering and separated access control logic, reducing memory usage and eliminating N+1 query problems for better scalability. [#17607](https://github.com/open-webui/open-webui/pull/17607) [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/da661756fa7eec754270e6dd8c67cbf74a28a17f)
- ⚡ Page loading performance was optimized by deferring API requests until components are actually opened, including ChangelogModal, ModelSelector, RecursiveFolder, ArchivedChatsModal, and SearchModal. [#17542](https://github.com/open-webui/open-webui/pull/17542), [#17555](https://github.com/open-webui/open-webui/pull/17555), [#17557](https://github.com/open-webui/open-webui/pull/17557), [#17541](https://github.com/open-webui/open-webui/pull/17541), [#17640](https://github.com/open-webui/open-webui/pull/17640)
- ⚡ Bundle size was reduced by 1.58MB through optimized highlight.js language support, improving page loading speed and reducing bandwidth usage. [#17645](https://github.com/open-webui/open-webui/pull/17645)
- ⚡ Editor collaboration functionality was refactored to reduce package size by 390KB and minimize compilation errors, improving build performance and reliability. [#17593](https://github.com/open-webui/open-webui/pull/17593)
- ♿ Enhanced user interface accessibility through the addition of unique element IDs, improving targeting for testing, styling, and assistive technologies while providing better semantic markup for screen readers and accessibility tools. [#17746](https://github.com/open-webui/open-webui/pull/17746)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Portuguese (Brazil), Chinese (Simplified and Traditional), Korean, Irish, Spanish, Finnish, French, Kabyle, Russian, and Catalan were enhanced and improved.
### Fixed
- 🛡️ SVG content security was enhanced by implementing DOMPurify sanitization to prevent XSS attacks through malicious SVG elements, ensuring safe rendering of user-generated SVG content. [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/750a659a9fee7687e667d9d755e17b8a0c77d557)
- ☁️ OneDrive attachment menu rendering issues were resolved by restructuring the submenu interface from dropdown to tabbed navigation, preventing menu items from being hidden or clipped due to overflow constraints. [#17554](https://github.com/open-webui/open-webui/issues/17554), [Commit](https://github.com/open-webui/open-webui/pull/17747/commits/90e4b49b881b644465831cc3028bb44f0f7a2196)
- 💬 Attached conversation references now persist throughout the entire chat session, ensuring models can continue querying referenced conversations after multiple conversation turns. [#17750](https://github.com/open-webui/open-webui/issues/17750)
- 🔍 Search modal text box focus issues after pinning or unpinning chats were resolved, allowing users to properly exit the search interface by clicking outside the text box. [#17743](https://github.com/open-webui/open-webui/issues/17743)
- 🔍 Search function chat list is now properly updated in real-time when chats are created or deleted, eliminating stale search results and preview loading failures. [#17741](https://github.com/open-webui/open-webui/issues/17741)
- 💬 Chat jitter and delayed code block expansion in multi-model sessions were resolved by reverting dynamic CodeEditor loading, restoring stable rendering behavior. [#17715](https://github.com/open-webui/open-webui/pull/17715), [#17684](https://github.com/open-webui/open-webui/issues/17684)
- 📎 File upload handling was improved to properly recognize uploaded files even when no accompanying text message is provided, resolving issues where attachments were ignored in custom prompts. [#17492](https://github.com/open-webui/open-webui/issues/17492)
- 💬 Chat conversation referencing within projects was restored by including foldered chats in the reference menu, allowing users to properly quote conversations from within their project scope. [#17530](https://github.com/open-webui/open-webui/issues/17530)
- 🔍 RAG query generation is now skipped when all attached files are set to full context mode, preventing unnecessary retrieval operations and improving system efficiency. [#17744](https://github.com/open-webui/open-webui/pull/17744)
- 💾 Memory leaks in file handling and HTTP connections are prevented through proper resource cleanup, ensuring stable memory usage during large file downloads and processing operations. [#17608](https://github.com/open-webui/open-webui/pull/17608)
- 🔐 OAuth access token refresh errors are resolved by properly implementing async/await patterns, preventing "coroutine object has no attribute get" failures during token expiry. [#17585](https://github.com/open-webui/open-webui/issues/17585), [#17678](https://github.com/open-webui/open-webui/issues/17678)
- ⚙️ Valve behavior was improved to properly handle default values and array types, ensuring only explicitly set values are persisted while maintaining consistent distinction between custom and default valve states. [#17664](https://github.com/open-webui/open-webui/pull/17664)
- 🔍 Hybrid search functionality was enhanced to handle inconsistent parameter types and prevent failures when collection results are None, empty, or in unexpected formats. [#17617](https://github.com/open-webui/open-webui/pull/17617)
- 📁 Empty folder deletion is now allowed regardless of chat deletion permission restrictions, resolving cases where users couldn't remove folders after deleting all contained chats. [#17683](https://github.com/open-webui/open-webui/pull/17683)
- 📝 Rich text editor console errors were resolved by adding proper error handling when the TipTap editor view is not available or not yet mounted. [#17697](https://github.com/open-webui/open-webui/issues/17697)
- 🗒️ Hidden models are now properly excluded from the notes section dropdown and default model selection, preventing users from accessing models they shouldn't see. [#17722](https://github.com/open-webui/open-webui/pull/17722)
- 🖼️ AI-generated image download filenames now use a clean, translatable "Generated Image" format instead of potentially problematic response text, improving file management and compatibility. [#17721](https://github.com/open-webui/open-webui/pull/17721)
- 🎨 Toggle switch display issues in the Integrations interface are fixed, preventing background highlighting and obscuring on hover. [#17564](https://github.com/open-webui/open-webui/issues/17564)
### Changed
- 👥 Channel permissions now require write access for message posting, editing, and deletion, with existing user groups defaulting to read-only access requiring manual admin migration to write permissions for full participation.
- ☁️ OneDrive environment variable configuration was updated to use separate ONEDRIVE_CLIENT_ID_PERSONAL and ONEDRIVE_CLIENT_ID_BUSINESS variables for better client ID separation, while maintaining backward compatibility with the legacy ONEDRIVE_CLIENT_ID variable. [Docs](https://docs.openwebui.com/tutorials/integrations/onedrive-sharepoint), [Docs](https://docs.openwebui.com/getting-started/env-configuration/#onedrive)
## [0.6.30] - 2025-09-17 ## [0.6.30] - 2025-09-17
### Added ### Added

11
LICENSE_NOTICE Normal file
View file

@ -0,0 +1,11 @@
# Open WebUI Multi-License Notice
This repository contains code governed by multiple licenses based on the date and origin of contribution:
1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
For details on which commits are covered by which license, refer to LICENSE_HISTORY.

View file

@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
## License 📜 ## License 📜
This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄 This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
## Support 💬 ## Support 💬

View file

@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
class AppConfig: class AppConfig:
_state: dict[str, PersistentConfig]
_redis: Union[redis.Redis, redis.cluster.RedisCluster] = None _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
_redis_key_prefix: str _redis_key_prefix: str
_state: dict[str, PersistentConfig]
def __init__( def __init__(
self, self,
redis_url: Optional[str] = None, redis_url: Optional[str] = None,
@ -233,9 +234,8 @@ class AppConfig:
redis_cluster: Optional[bool] = False, redis_cluster: Optional[bool] = False,
redis_key_prefix: str = "open-webui", redis_key_prefix: str = "open-webui",
): ):
super().__setattr__("_state", {})
super().__setattr__("_redis_key_prefix", redis_key_prefix)
if redis_url: if redis_url:
super().__setattr__("_redis_key_prefix", redis_key_prefix)
super().__setattr__( super().__setattr__(
"_redis", "_redis",
get_redis_connection( get_redis_connection(
@ -246,6 +246,8 @@ class AppConfig:
), ),
) )
super().__setattr__("_state", {})
def __setattr__(self, key, value): def __setattr__(self, key, value):
if isinstance(value, PersistentConfig): if isinstance(value, PersistentConfig):
self._state[key] = value self._state[key] = value
@ -2168,6 +2170,8 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
"onedrive.enable", "onedrive.enable",
os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true", os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
) )
ENABLE_ONEDRIVE_PERSONAL = ( ENABLE_ONEDRIVE_PERSONAL = (
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true" os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
) )
@ -2175,10 +2179,12 @@ ENABLE_ONEDRIVE_BUSINESS = (
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true" os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
) )
ONEDRIVE_CLIENT_ID = PersistentConfig( ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
"ONEDRIVE_CLIENT_ID", ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
"onedrive.client_id", "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
os.environ.get("ONEDRIVE_CLIENT_ID", ""), )
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
"ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
) )
ONEDRIVE_SHAREPOINT_URL = PersistentConfig( ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
@ -2761,6 +2767,12 @@ WEB_SEARCH_TRUST_ENV = PersistentConfig(
) )
OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig(
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY",
"rag.web.search.ollama_cloud_api_key",
os.getenv("OLLAMA_CLOUD_API_KEY", ""),
)
SEARXNG_QUERY_URL = PersistentConfig( SEARXNG_QUERY_URL = PersistentConfig(
"SEARXNG_QUERY_URL", "SEARXNG_QUERY_URL",
"rag.web.search.searxng_query_url", "rag.web.search.searxng_query_url",

View file

@ -474,6 +474,10 @@ ENABLE_OAUTH_ID_TOKEN_COOKIE = (
os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true" os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
) )
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get(
"OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY
)
OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get( OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
"OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
) )
@ -547,16 +551,16 @@ else:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get( CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
"CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10" "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
) )
if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "": if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10 CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
else: else:
try: try:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES) CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
except Exception: except Exception:
CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10 CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
#################################### ####################################

View file

@ -239,7 +239,7 @@ async def generate_function_chat_completion(
oauth_token = None oauth_token = None
try: try:
if request.cookies.get("oauth_session_id", None): if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token( oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id, user.id,
request.cookies.get("oauth_session_id", None), request.cookies.get("oauth_session_id", None),
) )

View file

@ -50,6 +50,11 @@ from starlette.middleware.sessions import SessionMiddleware
from starlette.responses import Response, StreamingResponse from starlette.responses import Response, StreamingResponse
from starlette.datastructures import Headers from starlette.datastructures import Headers
from starsessions import (
SessionMiddleware as StarSessionsMiddleware,
SessionAutoloadMiddleware,
)
from starsessions.stores.redis import RedisStore
from open_webui.utils import logger from open_webui.utils import logger
from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware from open_webui.utils.audit import AuditLevel, AuditLoggingMiddleware
@ -269,6 +274,7 @@ from open_webui.config import (
WEB_SEARCH_CONCURRENT_REQUESTS, WEB_SEARCH_CONCURRENT_REQUESTS,
WEB_SEARCH_TRUST_ENV, WEB_SEARCH_TRUST_ENV,
WEB_SEARCH_DOMAIN_FILTER_LIST, WEB_SEARCH_DOMAIN_FILTER_LIST,
OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
JINA_API_KEY, JINA_API_KEY,
SEARCHAPI_API_KEY, SEARCHAPI_API_KEY,
SEARCHAPI_ENGINE, SEARCHAPI_ENGINE,
@ -301,7 +307,8 @@ from open_webui.config import (
GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY, GOOGLE_DRIVE_API_KEY,
ENABLE_ONEDRIVE_INTEGRATION, ENABLE_ONEDRIVE_INTEGRATION,
ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_ID_PERSONAL,
ONEDRIVE_CLIENT_ID_BUSINESS,
ONEDRIVE_SHAREPOINT_URL, ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID, ONEDRIVE_SHAREPOINT_TENANT_ID,
ENABLE_ONEDRIVE_PERSONAL, ENABLE_ONEDRIVE_PERSONAL,
@ -466,7 +473,12 @@ from open_webui.utils.auth import (
get_verified_user, get_verified_user,
) )
from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.oauth import OAuthManager from open_webui.utils.oauth import (
OAuthManager,
OAuthClientManager,
decrypt_data,
OAuthClientInformationFull,
)
from open_webui.utils.security_headers import SecurityHeadersMiddleware from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.redis import get_redis_connection from open_webui.utils.redis import get_redis_connection
@ -596,9 +608,14 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# For Open WebUI OIDC/OAuth2
oauth_manager = OAuthManager(app) oauth_manager = OAuthManager(app)
app.state.oauth_manager = oauth_manager app.state.oauth_manager = oauth_manager
# For Integrations
oauth_client_manager = OAuthClientManager(app)
app.state.oauth_client_manager = oauth_client_manager
app.state.instance_id = None app.state.instance_id = None
app.state.config = AppConfig( app.state.config = AppConfig(
redis_url=REDIS_URL, redis_url=REDIS_URL,
@ -882,6 +899,8 @@ app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER
app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = OLLAMA_CLOUD_WEB_SEARCH_API_KEY
app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
app.state.config.YACY_QUERY_URL = YACY_QUERY_URL app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
app.state.config.YACY_USERNAME = YACY_USERNAME app.state.config.YACY_USERNAME = YACY_USERNAME
@ -1530,6 +1549,14 @@ async def chat_completion(
except: except:
pass pass
finally:
try:
if mcp_clients := metadata.get("mcp_clients"):
for client in mcp_clients:
await client.disconnect()
except Exception as e:
log.debug(f"Error cleaning up: {e}")
pass
if ( if (
metadata.get("session_id") metadata.get("session_id")
@ -1743,7 +1770,8 @@ async def get_app_config(request: Request):
"api_key": GOOGLE_DRIVE_API_KEY.value, "api_key": GOOGLE_DRIVE_API_KEY.value,
}, },
"onedrive": { "onedrive": {
"client_id": ONEDRIVE_CLIENT_ID.value, "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
"client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
"sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value, "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
"sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value, "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
}, },
@ -1863,14 +1891,78 @@ async def get_current_usage(user=Depends(get_verified_user)):
# OAuth Login & Callback # OAuth Login & Callback
############################ ############################
# Initialize OAuth client manager with any MCP tool servers using OAuth 2.1
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS:
if tool_server_connection.get("type", "openapi") == "mcp":
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":
oauth_client_info = tool_server_connection.get("info", {}).get(
"oauth_client_info", ""
)
oauth_client_info = decrypt_data(oauth_client_info)
app.state.oauth_client_manager.add_client(
f"mcp:{server_id}", OAuthClientInformationFull(**oauth_client_info)
)
# SessionMiddleware is used by authlib for oauth # SessionMiddleware is used by authlib for oauth
if len(OAUTH_PROVIDERS) > 0: if len(OAUTH_PROVIDERS) > 0:
app.add_middleware( try:
SessionMiddleware, if REDIS_URL:
secret_key=WEBUI_SECRET_KEY, redis_session_store = RedisStore(
session_cookie="oui-session", url=REDIS_URL,
same_site=WEBUI_SESSION_COOKIE_SAME_SITE, prefix=(
https_only=WEBUI_SESSION_COOKIE_SECURE, f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"
),
)
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(
StarSessionsMiddleware,
store=redis_session_store,
cookie_name="oui-session",
cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
cookie_https_only=WEBUI_SESSION_COOKIE_SECURE,
)
log.info("Using Redis for session")
else:
raise ValueError("No Redis URL provided")
except Exception as e:
app.add_middleware(
SessionMiddleware,
secret_key=WEBUI_SECRET_KEY,
session_cookie="oui-session",
same_site=WEBUI_SESSION_COOKIE_SAME_SITE,
https_only=WEBUI_SESSION_COOKIE_SECURE,
)
@app.get("/oauth/clients/{client_id}/authorize")
async def oauth_client_authorize(
client_id: str,
request: Request,
response: Response,
user=Depends(get_verified_user),
):
return await oauth_client_manager.handle_authorize(request, client_id=client_id)
@app.get("/oauth/clients/{client_id}/callback")
async def oauth_client_callback(
client_id: str,
request: Request,
response: Response,
user=Depends(get_verified_user),
):
return await oauth_client_manager.handle_callback(
request,
client_id=client_id,
user_id=user.id if user else None,
response=response,
) )
@ -1885,8 +1977,9 @@ async def oauth_login(provider: str, request: Request):
# - This is considered insecure in general, as OAuth providers do not always verify email addresses # - This is considered insecure in general, as OAuth providers do not always verify email addresses
# 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user # 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user
# - Email addresses are considered unique, so we fail registration if the email address is already taken # - Email addresses are considered unique, so we fail registration if the email address is already taken
@app.get("/oauth/{provider}/callback") @app.get("/oauth/{provider}/login/callback")
async def oauth_callback(provider: str, request: Request, response: Response): @app.get("/oauth/{provider}/callback") # Legacy endpoint
async def oauth_login_callback(provider: str, request: Request, response: Response):
return await oauth_manager.handle_callback(request, provider, response) return await oauth_manager.handle_callback(request, provider, response)

View file

@ -57,6 +57,10 @@ class ChannelModel(BaseModel):
#################### ####################
class ChannelResponse(ChannelModel):
write_access: bool = False
class ChannelForm(BaseModel): class ChannelForm(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None

View file

@ -492,11 +492,16 @@ class ChatTable:
self, self,
user_id: str, user_id: str,
include_archived: bool = False, include_archived: bool = False,
include_folders: bool = False,
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> list[ChatTitleIdResponse]: ) -> list[ChatTitleIdResponse]:
with get_db() as db: with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None) query = db.query(Chat).filter_by(user_id=user_id)
if not include_folders:
query = query.filter_by(folder_id=None)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
if not include_archived: if not include_archived:
@ -943,6 +948,16 @@ class ChatTable:
return count return count
def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str) -> int:
with get_db() as db:
query = db.query(Chat).filter_by(user_id=user_id)
query = query.filter_by(folder_id=folder_id)
count = query.count()
log.info(f"Count of chats for folder '{folder_id}': {count}")
return count
def delete_tag_by_id_and_user_id_and_tag_name( def delete_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str self, id: str, user_id: str, tag_name: str
) -> bool: ) -> bool:

View file

@ -130,6 +130,17 @@ class FilesTable:
except Exception: except Exception:
return None return None
def get_file_by_id_and_user_id(self, id: str, user_id: str) -> Optional[FileModel]:
with get_db() as db:
try:
file = db.query(File).filter_by(id=id, user_id=user_id).first()
if file:
return FileModel.model_validate(file)
else:
return None
except Exception:
return None
def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]: def get_file_metadata_by_id(self, id: str) -> Optional[FileMetadataResponse]:
with get_db() as db: with get_db() as db:
try: try:

View file

@ -2,6 +2,7 @@ import json
import time import time
import uuid import uuid
from typing import Optional from typing import Optional
from functools import lru_cache
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
@ -110,20 +111,72 @@ class NoteTable:
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_user_id( def get_notes_by_user_id(
self,
user_id: str,
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id)
query = query.order_by(Note.updated_at.desc())
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
notes = query.all()
return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_permission(
self, self,
user_id: str, user_id: str,
permission: str = "write", permission: str = "write",
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> list[NoteModel]: ) -> list[NoteModel]:
notes = self.get_notes(skip=skip, limit=limit) with get_db() as db:
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)} user_groups = Groups.get_groups_by_member_id(user_id)
return [ user_group_ids = {group.id for group in user_groups}
note
for note in notes # Order newest-first. We stream to keep memory usage low.
if note.user_id == user_id query = (
or has_access(user_id, permission, note.access_control, user_group_ids) db.query(Note)
] .order_by(Note.updated_at.desc())
.execution_options(stream_results=True)
.yield_per(256)
)
results: list[NoteModel] = []
n_skipped = 0
for note in query:
# Fast-pass #1: owner
if note.user_id == user_id:
permitted = True
# Fast-pass #2: public/open
elif note.access_control is None:
# Technically this should mean public access for both read and write, but we'll only do read for now
# We might want to change this behavior later
permitted = permission == "read"
else:
permitted = has_access(
user_id, permission, note.access_control, user_group_ids
)
if not permitted:
continue
# Apply skip AFTER permission filtering so it counts only accessible notes
if skip and n_skipped < skip:
n_skipped += 1
continue
results.append(NoteModel.model_validate(note))
if limit is not None and len(results) >= limit:
break
return results
def get_note_by_id(self, id: str) -> Optional[NoteModel]: def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db: with get_db() as db:

View file

@ -176,6 +176,26 @@ class OAuthSessionTable:
log.error(f"Error getting OAuth session by ID: {e}") log.error(f"Error getting OAuth session by ID: {e}")
return None return None
def get_session_by_provider_and_user_id(
self, provider: str, user_id: str
) -> Optional[OAuthSessionModel]:
"""Get OAuth session by provider and user ID"""
try:
with get_db() as db:
session = (
db.query(OAuthSession)
.filter_by(provider=provider, user_id=user_id)
.first()
)
if session:
session.token = self._decrypt_token(session.token)
return OAuthSessionModel.model_validate(session)
return None
except Exception as e:
log.error(f"Error getting OAuth session by provider and user ID: {e}")
return None
def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]: def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
"""Get all OAuth sessions for a user""" """Get all OAuth sessions for a user"""
try: try:

View file

@ -95,6 +95,8 @@ class ToolResponse(BaseModel):
class ToolUserResponse(ToolResponse): class ToolUserResponse(ToolResponse):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
model_config = ConfigDict(extra="allow")
class ToolForm(BaseModel): class ToolForm(BaseModel):
id: str id: str

View file

@ -127,7 +127,13 @@ def query_doc_with_hybrid_search(
hybrid_bm25_weight: float, hybrid_bm25_weight: float,
) -> dict: ) -> dict:
try: try:
if not collection_result.documents[0]: if (
not collection_result
or not hasattr(collection_result, "documents")
or not collection_result.documents
or len(collection_result.documents) == 0
or not collection_result.documents[0]
):
log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
return {"documents": [], "metadatas": [], "distances": []} return {"documents": [], "metadatas": [], "distances": []}

View file

@ -0,0 +1,51 @@
import logging
from dataclasses import dataclass
from typing import Optional
import requests
from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.web.main import SearchResult
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_ollama_cloud(
url: str,
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Ollama Search API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Ollama Search API key
query (str): The query to search for
count (int): Number of results to return
filter_list (Optional[list[str]]): List of domains to filter results by
"""
log.info(f"Searching with Ollama for query: {query}")
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"query": query, "max_results": count}
try:
response = requests.post(f"{url}/api/web_search", headers=headers, json=payload)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
log.info(f"Found {len(results)} results")
return [
SearchResult(
link=result.get("url", ""),
title=result.get("title", ""),
snippet=result.get("content", ""),
)
for result in results
]
except Exception as e:
log.error(f"Error searching Ollama: {e}")
return []

View file

@ -0,0 +1,64 @@
import logging
from typing import Optional, Literal
import requests
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
from open_webui.env import SRC_LOG_LEVELS
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["RAG"])
def search_perplexity_search(
api_key: str,
query: str,
count: int,
filter_list: Optional[list[str]] = None,
) -> list[SearchResult]:
"""Search using Perplexity API and return the results as a list of SearchResult objects.
Args:
api_key (str): A Perplexity API key
query (str): The query to search for
count (int): Maximum number of results to return
filter_list (Optional[list[str]]): List of domains to filter results
"""
# Handle PersistentConfig object
if hasattr(api_key, "__str__"):
api_key = str(api_key)
try:
url = "https://api.perplexity.ai/search"
# Create payload for the API call
payload = {
"query": query,
"max_results": count,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Make the API request
response = requests.request("POST", url, json=payload, headers=headers)
# Parse the JSON response
json_response = response.json()
# Extract citations from the response
results = json_response.get("results", [])
return [
SearchResult(
link=result["url"], title=result["title"], snippet=result["snippet"]
)
for result in results
]
except Exception as e:
log.error(f"Error searching with Perplexity Search API: {e}")
return []

View file

@ -10,7 +10,13 @@ from pydantic import BaseModel
from open_webui.socket.main import sio, get_user_ids_from_room from open_webui.socket.main import sio, get_user_ids_from_room
from open_webui.models.users import Users, UserNameResponse from open_webui.models.users import Users, UserNameResponse
from open_webui.models.channels import Channels, ChannelModel, ChannelForm from open_webui.models.groups import Groups
from open_webui.models.channels import (
Channels,
ChannelModel,
ChannelForm,
ChannelResponse,
)
from open_webui.models.messages import ( from open_webui.models.messages import (
Messages, Messages,
MessageModel, MessageModel,
@ -80,7 +86,7 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user
############################ ############################
@router.get("/{id}", response_model=Optional[ChannelModel]) @router.get("/{id}", response_model=Optional[ChannelResponse])
async def get_channel_by_id(id: str, user=Depends(get_verified_user)): async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
channel = Channels.get_channel_by_id(id) channel = Channels.get_channel_by_id(id)
if not channel: if not channel:
@ -95,7 +101,16 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
) )
return ChannelModel(**channel.model_dump()) write_access = has_access(
user.id, type="write", access_control=channel.access_control, strict=False
)
return ChannelResponse(
**{
**channel.model_dump(),
"write_access": write_access or user.role == "admin",
}
)
############################ ############################
@ -275,6 +290,7 @@ async def model_response_handler(request, channel, message, user):
) )
thread_history = [] thread_history = []
images = []
message_users = {} message_users = {}
for thread_message in thread_messages: for thread_message in thread_messages:
@ -303,6 +319,11 @@ async def model_response_handler(request, channel, message, user):
f"{username}: {replace_mentions(thread_message.content)}" f"{username}: {replace_mentions(thread_message.content)}"
) )
thread_message_files = thread_message.data.get("files", [])
for file in thread_message_files:
if file.get("type", "") == "image":
images.append(file.get("url", ""))
system_message = { system_message = {
"role": "system", "role": "system",
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational." "content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
@ -313,14 +334,29 @@ async def model_response_handler(request, channel, message, user):
), ),
} }
content = f"{user.name if user else 'User'}: {message_content}"
if images:
content = [
{
"type": "text",
"text": content,
},
*[
{
"type": "image_url",
"image_url": {
"url": image,
},
}
for image in images
],
]
form_data = { form_data = {
"model": model_id, "model": model_id,
"messages": [ "messages": [
system_message, system_message,
{ {"role": "user", "content": content},
"role": "user",
"content": f"{user.name if user else 'User'}: {message_content}",
},
], ],
"stream": False, "stream": False,
} }
@ -362,7 +398,7 @@ async def new_message_handler(
) )
if user.role != "admin" and not has_access( if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control user.id, type="write", access_control=channel.access_control, strict=False
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -658,7 +694,7 @@ async def add_reaction_to_message(
) )
if user.role != "admin" and not has_access( if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control user.id, type="write", access_control=channel.access_control, strict=False
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -724,7 +760,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
) )
if user.role != "admin" and not has_access( if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control user.id, type="write", access_control=channel.access_control, strict=False
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
@ -806,7 +842,9 @@ async def delete_message_by_id(
if ( if (
user.role != "admin" user.role != "admin"
and message.user_id != user.id and message.user_id != user.id
and not has_access(user.id, type="read", access_control=channel.access_control) and not has_access(
user.id, type="write", access_control=channel.access_control, strict=False
)
): ):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()

View file

@ -37,7 +37,9 @@ router = APIRouter()
@router.get("/", response_model=list[ChatTitleIdResponse]) @router.get("/", response_model=list[ChatTitleIdResponse])
@router.get("/list", response_model=list[ChatTitleIdResponse]) @router.get("/list", response_model=list[ChatTitleIdResponse])
def get_session_user_chat_list( def get_session_user_chat_list(
user=Depends(get_verified_user), page: Optional[int] = None user=Depends(get_verified_user),
page: Optional[int] = None,
include_folders: Optional[bool] = False,
): ):
try: try:
if page is not None: if page is not None:
@ -45,10 +47,12 @@ def get_session_user_chat_list(
skip = (page - 1) * limit skip = (page - 1) * limit
return Chats.get_chat_title_id_list_by_user_id( return Chats.get_chat_title_id_list_by_user_id(
user.id, skip=skip, limit=limit user.id, include_folders=include_folders, skip=skip, limit=limit
) )
else: else:
return Chats.get_chat_title_id_list_by_user_id(user.id) return Chats.get_chat_title_id_list_by_user_id(
user.id, include_folders=include_folders
)
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
raise HTTPException( raise HTTPException(

View file

@ -1,5 +1,7 @@
import logging
from fastapi import APIRouter, Depends, Request, HTTPException from fastapi import APIRouter, Depends, Request, HTTPException
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
import aiohttp
from typing import Optional from typing import Optional
@ -12,10 +14,24 @@ from open_webui.utils.tools import (
get_tool_server_url, get_tool_server_url,
set_tool_servers, set_tool_servers,
) )
from open_webui.utils.mcp.client import MCPClient
from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.oauth import (
get_discovery_urls,
get_oauth_client_info_with_dynamic_client_registration,
encrypt_data,
decrypt_data,
OAuthClientInformationFull,
)
from mcp.shared.auth import OAuthMetadata
router = APIRouter() router = APIRouter()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
############################ ############################
# ImportConfig # ImportConfig
@ -79,6 +95,43 @@ async def set_connections_config(
} }
class OAuthClientRegistrationForm(BaseModel):
url: str
client_id: str
client_name: Optional[str] = None
@router.post("/oauth/clients/register")
async def register_oauth_client(
request: Request,
form_data: OAuthClientRegistrationForm,
type: Optional[str] = None,
user=Depends(get_admin_user),
):
try:
oauth_client_id = form_data.client_id
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
)
)
return {
"status": True,
"oauth_client_info": encrypt_data(
oauth_client_info.model_dump(mode="json")
),
}
except Exception as e:
log.debug(f"Failed to register OAuth client: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to register OAuth client",
)
############################ ############################
# ToolServers Config # ToolServers Config
############################ ############################
@ -87,6 +140,7 @@ async def set_connections_config(
class ToolServerConnection(BaseModel): class ToolServerConnection(BaseModel):
url: str url: str
path: str path: str
type: Optional[str] = "openapi" # openapi, mcp
auth_type: Optional[str] auth_type: Optional[str]
key: Optional[str] key: Optional[str]
config: Optional[dict] config: Optional[dict]
@ -114,8 +168,29 @@ async def set_tool_servers_config(
request.app.state.config.TOOL_SERVER_CONNECTIONS = [ request.app.state.config.TOOL_SERVER_CONNECTIONS = [
connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS connection.model_dump() for connection in form_data.TOOL_SERVER_CONNECTIONS
] ]
await set_tool_servers(request) await set_tool_servers(request)
for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS:
server_type = connection.get("type", "openapi")
if server_type == "mcp":
server_id = connection.get("info", {}).get("id")
auth_type = connection.get("auth_type", "none")
if auth_type == "oauth_2.1" and server_id:
try:
oauth_client_info = connection.get("info", {}).get(
"oauth_client_info", ""
)
oauth_client_info = decrypt_data(oauth_client_info)
await request.app.state.oauth_client_manager.add_client(
f"{server_type}:{server_id}",
OAuthClientInformationFull(**oauth_client_info),
)
except Exception as e:
log.debug(f"Failed to add OAuth client for MCP tool server: {e}")
continue
return { return {
"TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS,
} }
@ -129,19 +204,105 @@ async def verify_tool_servers_config(
Verify the connection to the tool server. Verify the connection to the tool server.
""" """
try: try:
if form_data.type == "mcp":
if form_data.auth_type == "oauth_2.1":
discovery_urls = get_discovery_urls(form_data.url)
async with aiohttp.ClientSession() as session:
async with session.get(
discovery_urls[0]
) as oauth_server_metadata_response:
if oauth_server_metadata_response.status != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
)
token = None try:
if form_data.auth_type == "bearer": oauth_server_metadata = OAuthMetadata.model_validate(
token = form_data.key await oauth_server_metadata_response.json()
elif form_data.auth_type == "session": )
token = request.state.token.credentials return {
"status": True,
"oauth_server_metadata": oauth_server_metadata.model_dump(
mode="json"
),
}
except Exception as e:
log.info(
f"Failed to parse OAuth 2.1 discovery document: {e}"
)
raise HTTPException(
status_code=400,
detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_urls[0]}",
)
url = get_tool_server_url(form_data.url, form_data.path) raise HTTPException(
return await get_tool_server_data(token, url) status_code=400,
detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls[0]}",
)
else:
try:
client = MCPClient()
headers = None
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
elif form_data.auth_type == "system_oauth":
try:
if request.cookies.get("oauth_session_id", None):
token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
pass
if token:
headers = {"Authorization": f"Bearer {token}"}
await client.connect(form_data.url, headers=headers)
specs = await client.list_tool_specs()
return {
"status": True,
"specs": specs,
}
except Exception as e:
log.debug(f"Failed to create MCP client: {e}")
raise HTTPException(
status_code=400,
detail=f"Failed to create MCP client",
)
finally:
if client:
await client.disconnect()
else: # openapi
token = None
if form_data.auth_type == "bearer":
token = form_data.key
elif form_data.auth_type == "session":
token = request.state.token.credentials
elif form_data.auth_type == "system_oauth":
try:
if request.cookies.get("oauth_session_id", None):
token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get("oauth_session_id", None),
)
except Exception as e:
pass
url = get_tool_server_url(form_data.url, form_data.path)
return await get_tool_server_data(token, url)
except HTTPException as e:
raise e
except Exception as e: except Exception as e:
log.debug(f"Failed to connect to the tool server: {e}")
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Failed to connect to the tool server: {str(e)}", detail=f"Failed to connect to the tool server",
) )

View file

@ -262,15 +262,15 @@ async def update_folder_is_expanded_by_id(
async def delete_folder_by_id( async def delete_folder_by_id(
request: Request, id: str, user=Depends(get_verified_user) request: Request, id: str, user=Depends(get_verified_user)
): ):
chat_delete_permission = has_permission( if Chats.count_chats_by_folder_id_and_user_id(id, user.id):
user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS chat_delete_permission = has_permission(
) user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
) )
if user.role != "admin" and not chat_delete_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
folder = Folders.get_folder_by_id_and_user_id(id, user.id) folder = Folders.get_folder_by_id_and_user_id(id, user.id)
if folder: if folder:

View file

@ -431,8 +431,10 @@ async def update_function_valves_by_id(
try: try:
form_data = {k: v for k, v in form_data.items() if v is not None} form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data) valves = Valves(**form_data)
Functions.update_function_valves_by_id(id, valves.model_dump())
return valves.model_dump() valves_dict = valves.model_dump(exclude_unset=True)
Functions.update_function_valves_by_id(id, valves_dict)
return valves_dict
except Exception as e: except Exception as e:
log.exception(f"Error updating function values by id {id}: {e}") log.exception(f"Error updating function values by id {id}: {e}")
raise HTTPException( raise HTTPException(
@ -514,10 +516,11 @@ async def update_function_user_valves_by_id(
try: try:
form_data = {k: v for k, v in form_data.items() if v is not None} form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data) user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Functions.update_user_valves_by_id_and_user_id( Functions.update_user_valves_by_id_and_user_id(
id, user.id, user_valves.model_dump() id, user.id, user_valves_dict
) )
return user_valves.model_dump() return user_valves_dict
except Exception as e: except Exception as e:
log.exception(f"Error updating function user valves by id {id}: {e}") log.exception(f"Error updating function user valves by id {id}: {e}")
raise HTTPException( raise HTTPException(

View file

@ -514,6 +514,7 @@ async def image_generations(
size = form_data.size size = form_data.size
width, height = tuple(map(int, size.split("x"))) width, height = tuple(map(int, size.split("x")))
model = get_image_model(request)
r = None r = None
try: try:
@ -531,11 +532,7 @@ async def image_generations(
headers["X-OpenWebUI-User-Role"] = user.role headers["X-OpenWebUI-User-Role"] = user.role
data = { data = {
"model": ( "model": model,
request.app.state.config.IMAGE_GENERATION_MODEL
if request.app.state.config.IMAGE_GENERATION_MODEL != ""
else "dall-e-2"
),
"prompt": form_data.prompt, "prompt": form_data.prompt,
"n": form_data.n, "n": form_data.n,
"size": ( "size": (
@ -584,7 +581,6 @@ async def image_generations(
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY headers["x-goog-api-key"] = request.app.state.config.IMAGES_GEMINI_API_KEY
model = get_image_model(request)
data = { data = {
"instances": {"prompt": form_data.prompt}, "instances": {"prompt": form_data.prompt},
"parameters": { "parameters": {
@ -640,7 +636,7 @@ async def image_generations(
} }
) )
res = await comfyui_generate_image( res = await comfyui_generate_image(
request.app.state.config.IMAGE_GENERATION_MODEL, model,
form_data, form_data,
user.id, user.id,
request.app.state.config.COMFYUI_BASE_URL, request.app.state.config.COMFYUI_BASE_URL,

View file

@ -48,7 +48,7 @@ async def get_notes(request: Request, user=Depends(get_verified_user)):
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()), "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
} }
) )
for note in Notes.get_notes_by_user_id(user.id, "write") for note in Notes.get_notes_by_permission(user.id, "write")
] ]
return notes return notes
@ -81,7 +81,9 @@ async def get_note_list(
notes = [ notes = [
NoteTitleIdResponse(**note.model_dump()) NoteTitleIdResponse(**note.model_dump())
for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit) for note in Notes.get_notes_by_permission(
user.id, "write", skip=skip, limit=limit
)
] ]
return notes return notes

View file

@ -1694,25 +1694,27 @@ async def download_file_stream(
yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n' yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
if done: if done:
file.seek(0) file.close()
chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
file.seek(0)
url = f"{ollama_url}/api/blobs/sha256:{hashed}" with open(file_path, "rb") as file:
response = requests.post(url, data=file) chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size)
if response.ok: url = f"{ollama_url}/api/blobs/sha256:{hashed}"
res = { with requests.Session() as session:
"done": done, response = session.post(url, data=file, timeout=30)
"blob": f"sha256:{hashed}",
"name": file_name,
}
os.remove(file_path)
yield f"data: {json.dumps(res)}\n\n" if response.ok:
else: res = {
raise "Ollama: Could not create blob, Please try again." "done": done,
"blob": f"sha256:{hashed}",
"name": file_name,
}
os.remove(file_path)
yield f"data: {json.dumps(res)}\n\n"
else:
raise "Ollama: Could not create blob, Please try again."
# url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf" # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"

View file

@ -121,7 +121,7 @@ def openai_reasoning_model_handler(payload):
return payload return payload
def get_headers_and_cookies( async def get_headers_and_cookies(
request: Request, request: Request,
url, url,
key=None, key=None,
@ -174,7 +174,7 @@ def get_headers_and_cookies(
oauth_token = None oauth_token = None
try: try:
if request.cookies.get("oauth_session_id", None): if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token( oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id, user.id,
request.cookies.get("oauth_session_id", None), request.cookies.get("oauth_session_id", None),
) )
@ -305,7 +305,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support
) )
headers, cookies = get_headers_and_cookies( headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user request, url, key, api_config, user=user
) )
@ -570,7 +570,7 @@ async def get_models(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session: ) as session:
try: try:
headers, cookies = get_headers_and_cookies( headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user request, url, key, api_config, user=user
) )
@ -656,7 +656,7 @@ async def verify_connection(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
) as session: ) as session:
try: try:
headers, cookies = get_headers_and_cookies( headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user request, url, key, api_config, user=user
) )
@ -901,7 +901,7 @@ async def generate_chat_completion(
convert_logit_bias_input_to_json(payload["logit_bias"]) convert_logit_bias_input_to_json(payload["logit_bias"])
) )
headers, cookies = get_headers_and_cookies( headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, metadata, user=user request, url, key, api_config, metadata, user=user
) )
@ -1010,7 +1010,9 @@ async def embeddings(request: Request, form_data: dict, user):
session = None session = None
streaming = False streaming = False
headers, cookies = get_headers_and_cookies(request, url, key, api_config, user=user) headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user
)
try: try:
session = aiohttp.ClientSession(trust_env=True) session = aiohttp.ClientSession(trust_env=True)
r = await session.request( r = await session.request(
@ -1080,7 +1082,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
streaming = False streaming = False
try: try:
headers, cookies = get_headers_and_cookies( headers, cookies = await get_headers_and_cookies(
request, url, key, api_config, user=user request, url, key, api_config, user=user
) )

View file

@ -45,6 +45,8 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
# Web search engines # Web search engines
from open_webui.retrieval.web.main import SearchResult from open_webui.retrieval.web.main import SearchResult
from open_webui.retrieval.web.utils import get_web_loader from open_webui.retrieval.web.utils import get_web_loader
from open_webui.retrieval.web.ollama import search_ollama_cloud
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
from open_webui.retrieval.web.brave import search_brave from open_webui.retrieval.web.brave import search_brave
from open_webui.retrieval.web.kagi import search_kagi from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.mojeek import search_mojeek from open_webui.retrieval.web.mojeek import search_mojeek
@ -469,6 +471,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
@ -525,6 +528,7 @@ class WebConfig(BaseModel):
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
SEARXNG_QUERY_URL: Optional[str] = None SEARXNG_QUERY_URL: Optional[str] = None
YACY_QUERY_URL: Optional[str] = None YACY_QUERY_URL: Optional[str] = None
YACY_USERNAME: Optional[str] = None YACY_USERNAME: Optional[str] = None
@ -988,6 +992,9 @@ async def update_rag_config(
request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = ( request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = (
form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER
) )
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = (
form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY
)
request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
@ -1139,6 +1146,7 @@ async def update_rag_config(
"WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
"BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
"BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER,
"OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
"SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
"YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
"YACY_USERNAME": request.app.state.config.YACY_USERNAME, "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
@ -1407,59 +1415,35 @@ def process_file(
form_data: ProcessFileForm, form_data: ProcessFileForm,
user=Depends(get_verified_user), user=Depends(get_verified_user),
): ):
try: if user.role == "admin":
file = Files.get_file_by_id(form_data.file_id) file = Files.get_file_by_id(form_data.file_id)
else:
file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id)
collection_name = form_data.collection_name if file:
try:
if collection_name is None: collection_name = form_data.collection_name
collection_name = f"file-{file.id}"
if form_data.content: if collection_name is None:
# Update the content in the file collection_name = f"file-{file.id}"
# Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
try: if form_data.content:
# /files/{file_id}/data/content/update # Update the content in the file
VECTOR_DB_CLIENT.delete_collection(collection_name=f"file-{file.id}") # Usage: /files/{file_id}/data/content/update, /files/ (audio file upload pipeline)
except:
# Audio file upload pipeline
pass
docs = [ try:
Document( # /files/{file_id}/data/content/update
page_content=form_data.content.replace("<br/>", "\n"), VECTOR_DB_CLIENT.delete_collection(
metadata={ collection_name=f"file-{file.id}"
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
text_content = form_data.content
elif form_data.collection_name:
# Check if the file has already been processed and save the content
# Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
result = VECTOR_DB_CLIENT.query(
collection_name=f"file-{file.id}", filter={"file_id": file.id}
)
if result is not None and len(result.ids[0]) > 0:
docs = [
Document(
page_content=result.documents[0][idx],
metadata=result.metadatas[0][idx],
) )
for idx, id in enumerate(result.ids[0]) except:
] # Audio file upload pipeline
else: pass
docs = [ docs = [
Document( Document(
page_content=file.data.get("content", ""), page_content=form_data.content.replace("<br/>", "\n"),
metadata={ metadata={
**file.meta, **file.meta,
"name": file.filename, "name": file.filename,
@ -1470,149 +1454,190 @@ def process_file(
) )
] ]
text_content = file.data.get("content", "") text_content = form_data.content
else: elif form_data.collection_name:
# Process the file and save the content # Check if the file has already been processed and save the content
# Usage: /files/ # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update
file_path = file.path
if file_path: result = VECTOR_DB_CLIENT.query(
file_path = Storage.get_file(file_path) collection_name=f"file-{file.id}", filter={"file_id": file.id}
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
},
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
)
docs = loader.load(
file.filename, file.meta.get("content_type"), file_path
) )
docs = [ if result is not None and len(result.ids[0]) > 0:
Document( docs = [
page_content=doc.page_content, Document(
metadata={ page_content=result.documents[0][idx],
**doc.metadata, metadata=result.metadatas[0][idx],
"name": file.filename, )
"created_by": file.user_id, for idx, id in enumerate(result.ids[0])
"file_id": file.id, ]
"source": file.filename,
},
)
for doc in docs
]
else:
docs = [
Document(
page_content=file.data.get("content", ""),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
text_content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {text_content}")
Files.update_file_data_by_id(
file.id,
{"content": text_content},
)
hash = calculate_sha256_string(text_content)
Files.update_file_hash_by_id(file.id, hash)
if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"})
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
else:
try:
result = save_docs_to_vector_db(
request,
docs=docs,
collection_name=collection_name,
metadata={
"file_id": file.id,
"name": file.filename,
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
log.info(f"added {len(docs)} items to collection {collection_name}")
if result:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
else: else:
raise Exception("Error saving document to vector database") docs = [
except Exception as e: Document(
raise e page_content=file.data.get("content", ""),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
except Exception as e: text_content = file.data.get("content", "")
log.exception(e) else:
if "No pandoc was found" in str(e): # Process the file and save the content
raise HTTPException( # Usage: /files/
status_code=status.HTTP_400_BAD_REQUEST, file_path = file.path
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED, if file_path:
file_path = Storage.get_file(file_path)
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=request.app.state.config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=request.app.state.config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=request.app.state.config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=request.app.state.config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=request.app.state.config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=request.app.state.config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=request.app.state.config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
DOCLING_PARAMS={
"do_ocr": request.app.state.config.DOCLING_DO_OCR,
"force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
"ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
"ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
"pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
"table_mode": request.app.state.config.DOCLING_TABLE_MODE,
"pipeline": request.app.state.config.DOCLING_PIPELINE,
"do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
"picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
"picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
"picture_description_api": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_API,
},
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
MISTRAL_OCR_API_KEY=request.app.state.config.MISTRAL_OCR_API_KEY,
)
docs = loader.load(
file.filename, file.meta.get("content_type"), file_path
)
docs = [
Document(
page_content=doc.page_content,
metadata={
**doc.metadata,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
for doc in docs
]
else:
docs = [
Document(
page_content=file.data.get("content", ""),
metadata={
**file.meta,
"name": file.filename,
"created_by": file.user_id,
"file_id": file.id,
"source": file.filename,
},
)
]
text_content = " ".join([doc.page_content for doc in docs])
log.debug(f"text_content: {text_content}")
Files.update_file_data_by_id(
file.id,
{"content": text_content},
) )
else: hash = calculate_sha256_string(text_content)
raise HTTPException( Files.update_file_hash_by_id(file.id, hash)
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
Files.update_file_data_by_id(file.id, {"status": "completed"})
return {
"status": True,
"collection_name": None,
"filename": file.filename,
"content": text_content,
}
else:
try:
result = save_docs_to_vector_db(
request,
docs=docs,
collection_name=collection_name,
metadata={
"file_id": file.id,
"name": file.filename,
"hash": hash,
},
add=(True if form_data.collection_name else False),
user=user,
)
log.info(f"added {len(docs)} items to collection {collection_name}")
if result:
Files.update_file_metadata_by_id(
file.id,
{
"collection_name": collection_name,
},
)
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
)
return {
"status": True,
"collection_name": collection_name,
"filename": file.filename,
"content": text_content,
}
else:
raise Exception("Error saving document to vector database")
except Exception as e:
raise e
except Exception as e:
log.exception(e)
Files.update_file_data_by_id(
file.id,
{"status": "failed"},
) )
if "No pandoc was found" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED,
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
class ProcessTextForm(BaseModel): class ProcessTextForm(BaseModel):
name: str name: str
@ -1769,7 +1794,25 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
""" """
# TODO: add playwright to search the web # TODO: add playwright to search the web
if engine == "searxng": if engine == "ollama_cloud":
return search_ollama_cloud(
"https://ollama.com",
request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
elif engine == "perplexity_search":
if request.app.state.config.PERPLEXITY_API_KEY:
return search_perplexity_search(
request.app.state.config.PERPLEXITY_API_KEY,
query,
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
)
else:
raise Exception("No PERPLEXITY_API_KEY found in environment variables")
elif engine == "searxng":
if request.app.state.config.SEARXNG_QUERY_URL: if request.app.state.config.SEARXNG_QUERY_URL:
return search_searxng( return search_searxng(
request.app.state.config.SEARXNG_QUERY_URL, request.app.state.config.SEARXNG_QUERY_URL,

View file

@ -9,6 +9,7 @@ from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.tools import ( from open_webui.models.tools import (
ToolForm, ToolForm,
ToolModel, ToolModel,
@ -41,8 +42,17 @@ router = APIRouter()
@router.get("/", response_model=list[ToolUserResponse]) @router.get("/", response_model=list[ToolUserResponse])
async def get_tools(request: Request, user=Depends(get_verified_user)): async def get_tools(request: Request, user=Depends(get_verified_user)):
tools = Tools.get_tools() tools = [
ToolUserResponse(
**{
**tool.model_dump(),
"has_user_valves": "class UserValves(BaseModel):" in tool.content,
}
)
for tool in Tools.get_tools()
]
# OpenAPI Tool Servers
for server in await get_tool_servers(request): for server in await get_tool_servers(request):
tools.append( tools.append(
ToolUserResponse( ToolUserResponse(
@ -68,6 +78,50 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
) )
) )
# MCP Tool Servers
for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if server.get("type", "openapi") == "mcp":
server_id = server.get("info", {}).get("id")
auth_type = server.get("auth_type", "none")
session_token = None
if auth_type == "oauth_2.1":
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
session_token = (
await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
)
)
tools.append(
ToolUserResponse(
**{
"id": f"server:mcp:{server.get('info', {}).get('id')}",
"user_id": f"server:mcp:{server.get('info', {}).get('id')}",
"name": server.get("info", {}).get("name", "MCP Tool Server"),
"meta": {
"description": server.get("info", {}).get(
"description", ""
),
},
"access_control": server.get("config", {}).get(
"access_control", None
),
"updated_at": int(time.time()),
"created_at": int(time.time()),
**(
{
"authenticated": session_token is not None,
}
if auth_type == "oauth_2.1"
else {}
),
}
)
)
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
# Admin can see all tools # Admin can see all tools
return tools return tools
@ -462,8 +516,9 @@ async def update_tools_valves_by_id(
try: try:
form_data = {k: v for k, v in form_data.items() if v is not None} form_data = {k: v for k, v in form_data.items() if v is not None}
valves = Valves(**form_data) valves = Valves(**form_data)
Tools.update_tool_valves_by_id(id, valves.model_dump()) valves_dict = valves.model_dump(exclude_unset=True)
return valves.model_dump() Tools.update_tool_valves_by_id(id, valves_dict)
return valves_dict
except Exception as e: except Exception as e:
log.exception(f"Failed to update tool valves by id {id}: {e}") log.exception(f"Failed to update tool valves by id {id}: {e}")
raise HTTPException( raise HTTPException(
@ -538,10 +593,11 @@ async def update_tools_user_valves_by_id(
try: try:
form_data = {k: v for k, v in form_data.items() if v is not None} form_data = {k: v for k, v in form_data.items() if v is not None}
user_valves = UserValves(**form_data) user_valves = UserValves(**form_data)
user_valves_dict = user_valves.model_dump(exclude_unset=True)
Tools.update_user_valves_by_id_and_user_id( Tools.update_user_valves_by_id_and_user_id(
id, user.id, user_valves.model_dump() id, user.id, user_valves_dict
) )
return user_valves.model_dump() return user_valves_dict
except Exception as e: except Exception as e:
log.exception(f"Failed to update user valves by id {id}: {e}") log.exception(f"Failed to update user valves by id {id}: {e}")
raise HTTPException( raise HTTPException(

View file

@ -110,9 +110,13 @@ def has_access(
type: str = "write", type: str = "write",
access_control: Optional[dict] = None, access_control: Optional[dict] = None,
user_group_ids: Optional[Set[str]] = None, user_group_ids: Optional[Set[str]] = None,
strict: bool = True,
) -> bool: ) -> bool:
if access_control is None: if access_control is None:
return type == "read" if strict:
return type == "read"
else:
return True
if user_group_ids is None: if user_group_ids is None:
user_groups = Groups.get_groups_by_member_id(user_id) user_groups = Groups.get_groups_by_member_id(user_id)

View file

@ -0,0 +1,97 @@
from open_webui.routers.images import (
load_b64_image_data,
upload_image,
)
from fastapi import (
APIRouter,
Depends,
HTTPException,
Request,
UploadFile,
)
from open_webui.routers.files import upload_file_handler
import mimetypes
import base64
import io
def get_image_url_from_base64(request, base64_image_string, metadata, user):
if "data:image/png;base64" in base64_image_string:
image_url = ""
# Extract base64 image data from the line
image_data, content_type = load_b64_image_data(base64_image_string)
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
)
return image_url
return None
def load_b64_audio_data(b64_str):
try:
if "," in b64_str:
header, b64_data = b64_str.split(",", 1)
else:
b64_data = b64_str
header = "data:audio/wav;base64"
audio_data = base64.b64decode(b64_data)
content_type = (
header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
)
return audio_data, content_type
except Exception as e:
print(f"Error decoding base64 audio data: {e}")
return None, None
def upload_audio(request, audio_data, content_type, metadata, user):
audio_format = mimetypes.guess_extension(content_type)
file = UploadFile(
file=io.BytesIO(audio_data),
filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file
headers={
"content-type": content_type,
},
)
file_item = upload_file_handler(
request,
file=file,
metadata=metadata,
process=False,
user=user,
)
url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
return url
def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
if "data:audio/wav;base64" in base64_audio_string:
audio_url = ""
# Extract base64 audio data from the line
audio_data, content_type = load_b64_audio_data(base64_audio_string)
if audio_data is not None:
audio_url = upload_audio(
request,
audio_data,
content_type,
metadata,
user,
)
return audio_url
return None
def get_file_url_from_base64(request, base64_file_string, metadata, user):
if "data:image/png;base64" in 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)
return None

View file

@ -0,0 +1,110 @@
import asyncio
from typing import Optional
from contextlib import AsyncExitStack
from mcp import ClientSession
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
class MCPClient:
def __init__(self):
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
async def connect(self, url: str, headers: Optional[dict] = None):
try:
self._streams_context = streamablehttp_client(url, headers=headers)
transport = await self.exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
self._session_context = ClientSession(
read_stream, write_stream
) # pylint: disable=W0201
self.session = await self.exit_stack.enter_async_context(
self._session_context
)
await self.session.initialize()
except Exception as e:
await self.disconnect()
raise e
async def list_tool_specs(self) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.list_tools()
tools = result.tools
tool_specs = []
for tool in tools:
name = tool.name
description = tool.description
inputSchema = tool.inputSchema
# TODO: handle outputSchema if needed
outputSchema = getattr(tool, "outputSchema", None)
tool_specs.append(
{"name": name, "description": description, "parameters": inputSchema}
)
return tool_specs
async def call_tool(
self, function_name: str, function_args: dict
) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.call_tool(function_name, function_args)
if not result:
raise Exception("No result returned from MCP tool call.")
result_dict = result.model_dump(mode="json")
result_content = result_dict.get("content", {})
if result.isError:
raise Exception(result_content)
else:
return result_content
async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.list_resources(cursor=cursor)
if not result:
raise Exception("No result returned from MCP list_resources call.")
result_dict = result.model_dump()
resources = result_dict.get("resources", [])
return resources
async def read_resource(self, uri: str) -> Optional[dict]:
if not self.session:
raise RuntimeError("MCP client is not connected.")
result = await self.session.read_resource(uri)
if not result:
raise Exception("No result returned from MCP read_resource call.")
result_dict = result.model_dump()
return result_dict
async def disconnect(self):
# Clean up and close the session
await self.exit_stack.aclose()
async def __aenter__(self):
await self.exit_stack.__aenter__()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
await self.disconnect()

View file

@ -20,9 +20,11 @@ from concurrent.futures import ThreadPoolExecutor
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
from fastapi.responses import HTMLResponse
from starlette.responses import Response, StreamingResponse, JSONResponse from starlette.responses import Response, StreamingResponse, JSONResponse
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.folders import Folders from open_webui.models.folders import Folders
from open_webui.models.users import Users from open_webui.models.users import Users
@ -52,6 +54,11 @@ from open_webui.routers.pipelines import (
from open_webui.routers.memories import query_memory, QueryMemoryForm from open_webui.routers.memories import query_memory, QueryMemoryForm
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
from open_webui.utils.files import (
get_audio_url_from_base64,
get_file_url_from_base64,
get_image_url_from_base64,
)
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
@ -86,6 +93,7 @@ from open_webui.utils.filter import (
) )
from open_webui.utils.code_interpreter import execute_code_jupyter from open_webui.utils.code_interpreter import execute_code_jupyter
from open_webui.utils.payload import apply_system_prompt_to_body from open_webui.utils.payload import apply_system_prompt_to_body
from open_webui.utils.mcp.client import MCPClient
from open_webui.config import ( from open_webui.config import (
@ -144,12 +152,14 @@ async def chat_completion_tools_handler(
def get_tools_function_calling_payload(messages, task_model_id, content): def get_tools_function_calling_payload(messages, task_model_id, content):
user_message = get_last_user_message(messages) user_message = get_last_user_message(messages)
history = "\n".join(
recent_messages = messages[-4:] if len(messages) > 4 else messages
chat_history = "\n".join(
f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\"" f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\""
for message in messages[::-1][:4] for message in recent_messages
) )
prompt = f"History:\n{history}\nQuery: {user_message}" prompt = f"History:\n{chat_history}\nQuery: {user_message}"
return { return {
"model": task_model_id, "model": task_model_id,
@ -631,48 +641,53 @@ async def chat_completion_files_handler(
sources = [] sources = []
if files := body.get("metadata", {}).get("files", None): if files := body.get("metadata", {}).get("files", None):
# Check if all files are in full context mode
all_full_context = all(item.get("context") == "full" for item in files)
queries = [] queries = []
try: if not all_full_context:
queries_response = await generate_queries(
request,
{
"model": body["model"],
"messages": body["messages"],
"type": "retrieval",
},
user,
)
queries_response = queries_response["choices"][0]["message"]["content"]
try: try:
bracket_start = queries_response.find("{") queries_response = await generate_queries(
bracket_end = queries_response.rfind("}") + 1 request,
{
"model": body["model"],
"messages": body["messages"],
"type": "retrieval",
},
user,
)
queries_response = queries_response["choices"][0]["message"]["content"]
if bracket_start == -1 or bracket_end == -1: try:
raise Exception("No JSON object found in the response") bracket_start = queries_response.find("{")
bracket_end = queries_response.rfind("}") + 1
queries_response = queries_response[bracket_start:bracket_end] if bracket_start == -1 or bracket_end == -1:
queries_response = json.loads(queries_response) raise Exception("No JSON object found in the response")
except Exception as e:
queries_response = {"queries": [queries_response]}
queries = queries_response.get("queries", []) queries_response = queries_response[bracket_start:bracket_end]
except: queries_response = json.loads(queries_response)
pass except Exception as e:
queries_response = {"queries": [queries_response]}
queries = queries_response.get("queries", [])
except:
pass
if len(queries) == 0: if len(queries) == 0:
queries = [get_last_user_message(body["messages"])] queries = [get_last_user_message(body["messages"])]
await __event_emitter__( if not all_full_context:
{ await __event_emitter__(
"type": "status", {
"data": { "type": "status",
"action": "queries_generated", "data": {
"queries": queries, "action": "queries_generated",
"done": False, "queries": queries,
}, "done": False,
} },
) }
)
try: try:
# Offload get_sources_from_items to a separate thread # Offload get_sources_from_items to a separate thread
@ -701,7 +716,8 @@ async def chat_completion_files_handler(
r=request.app.state.config.RELEVANCE_THRESHOLD, r=request.app.state.config.RELEVANCE_THRESHOLD,
hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
full_context=request.app.state.config.RAG_FULL_CONTEXT, full_context=all_full_context
or request.app.state.config.RAG_FULL_CONTEXT,
user=user, user=user,
), ),
) )
@ -818,7 +834,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
oauth_token = None oauth_token = None
try: try:
if request.cookies.get("oauth_session_id", None): if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token( oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id, user.id,
request.cookies.get("oauth_session_id", None), request.cookies.get("oauth_session_id", None),
) )
@ -987,14 +1003,107 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Server side tools # Server side tools
tool_ids = metadata.get("tool_ids", None) tool_ids = metadata.get("tool_ids", None)
# Client side tools # Client side tools
tool_servers = metadata.get("tool_servers", None) direct_tool_servers = metadata.get("tool_servers", None)
log.debug(f"{tool_ids=}") log.debug(f"{tool_ids=}")
log.debug(f"{tool_servers=}") log.debug(f"{direct_tool_servers=}")
tools_dict = {} tools_dict = {}
mcp_clients = []
mcp_tools_dict = {}
if tool_ids: if tool_ids:
for tool_id in tool_ids:
if tool_id.startswith("server:mcp:"):
try:
server_id = tool_id[len("server:mcp:") :]
mcp_server_connection = None
for (
server_connection
) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
if (
server_connection.get("type", "") == "mcp"
and server_connection.get("info", {}).get("id") == server_id
):
mcp_server_connection = server_connection
break
if not mcp_server_connection:
log.error(f"MCP server with id {server_id} not found")
continue
auth_type = mcp_server_connection.get("auth_type", "")
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {mcp_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
elif auth_type == "oauth_2.1":
try:
splits = server_id.split(":")
server_id = splits[-1] if len(splits) > 1 else server_id
oauth_token = await request.app.state.oauth_client_manager.get_oauth_token(
user.id, f"mcp:{server_id}"
)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
except Exception as e:
log.error(f"Error getting OAuth token: {e}")
oauth_token = None
mcp_client = MCPClient()
await mcp_client.connect(
url=mcp_server_connection.get("url", ""),
headers=headers if headers else None,
)
tool_specs = await mcp_client.list_tool_specs()
for tool_spec in tool_specs:
def make_tool_function(function_name):
async def tool_function(**kwargs):
return await mcp_client.call_tool(
function_name,
function_args=kwargs,
)
return tool_function
tool_function = make_tool_function(tool_spec["name"])
mcp_tools_dict[tool_spec["name"]] = {
"spec": tool_spec,
"callable": tool_function,
"type": "mcp",
"client": mcp_client,
"direct": False,
}
mcp_clients.append(mcp_client)
except Exception as e:
log.debug(e)
continue
tools_dict = await get_tools( tools_dict = await get_tools(
request, request,
tool_ids, tool_ids,
@ -1006,9 +1115,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"__files__": metadata.get("files", []), "__files__": metadata.get("files", []),
}, },
) )
if mcp_tools_dict:
tools_dict = {**tools_dict, **mcp_tools_dict}
if tool_servers: if direct_tool_servers:
for tool_server in tool_servers: for tool_server in direct_tool_servers:
tool_specs = tool_server.pop("specs", []) tool_specs = tool_server.pop("specs", [])
for tool in tool_specs: for tool in tool_specs:
@ -1018,6 +1129,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
"server": tool_server, "server": tool_server,
} }
if mcp_clients:
metadata["mcp_clients"] = mcp_clients
if tools_dict: if tools_dict:
if metadata.get("params", {}).get("function_calling") == "native": if metadata.get("params", {}).get("function_calling") == "native":
# If the function calling is native, then call the tools function calling handler # If the function calling is native, then call the tools function calling handler
@ -1026,6 +1140,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
{"type": "function", "function": tool.get("spec", {})} {"type": "function", "function": tool.get("spec", {})}
for tool in tools_dict.values() for tool in tools_dict.values()
] ]
else: else:
# If the function calling is not native, then call the tools function calling handler # If the function calling is not native, then call the tools function calling handler
try: try:
@ -1079,26 +1194,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
raise Exception("No user message found") raise Exception("No user message found")
if context_string != "": if context_string != "":
# Workaround for Ollama 2.0+ system prompt issue form_data["messages"] = add_or_update_user_message(
# TODO: replace with add_or_update_system_message rag_template(
if model.get("owned_by") == "ollama": request.app.state.config.RAG_TEMPLATE,
form_data["messages"] = prepend_to_first_user_message_content( context_string,
rag_template( prompt,
request.app.state.config.RAG_TEMPLATE, ),
context_string, form_data["messages"],
prompt, append=False,
), )
form_data["messages"],
)
else:
form_data["messages"] = add_or_update_system_message(
rag_template(
request.app.state.config.RAG_TEMPLATE,
context_string,
prompt,
),
form_data["messages"],
)
# If there are citations, add them to the data_items # If there are citations, add them to the data_items
sources = [ sources = [
@ -1498,7 +1602,7 @@ async def process_chat_response(
oauth_token = None oauth_token = None
try: try:
if request.cookies.get("oauth_session_id", None): if request.cookies.get("oauth_session_id", None):
oauth_token = request.app.state.oauth_manager.get_oauth_token( oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id, user.id,
request.cookies.get("oauth_session_id", None), request.cookies.get("oauth_session_id", None),
) )
@ -1581,7 +1685,8 @@ async def process_chat_response(
break break
if tool_result is not None: if tool_result is not None:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n' tool_result_embeds = result.get("embeds", "")
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(json.dumps(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
else: else:
tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n' tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
@ -2328,6 +2433,8 @@ async def process_chat_response(
results = [] results = []
for tool_call in response_tool_calls: for tool_call in response_tool_calls:
print("tool_call", tool_call)
tool_call_id = tool_call.get("id", "") tool_call_id = tool_call.get("id", "")
tool_name = tool_call.get("function", {}).get("name", "") tool_name = tool_call.get("function", {}).get("name", "")
tool_args = tool_call.get("function", {}).get("arguments", "{}") tool_args = tool_call.get("function", {}).get("arguments", "{}")
@ -2402,14 +2509,145 @@ async def process_chat_response(
except Exception as e: except Exception as e:
tool_result = str(e) tool_result = str(e)
tool_result_embeds = []
if isinstance(tool_result, HTMLResponse):
content_disposition = tool_result.headers.get(
"Content-Disposition", ""
)
if "inline" in content_disposition:
content = tool_result.body.decode("utf-8")
tool_result_embeds.append(content)
if 200 <= tool_result.status_code < 300:
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
elif 400 <= tool_result.status_code < 500:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Client error {tool_result.status_code} from embedded UI result.",
}
elif 500 <= tool_result.status_code < 600:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Server error {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = {
"status": "error",
"code": "ui_component",
"message": f"Unexpected status code {tool_result.status_code} from embedded UI result.",
}
else:
tool_result = tool_result.body.decode("utf-8")
elif (
tool.get("type") == "external"
and isinstance(tool_result, tuple)
) or (
tool.get("direct", True)
and isinstance(tool_result, list)
and len(tool_result) == 2
):
tool_result, tool_response_headers = tool_result
if tool_response_headers:
content_disposition = tool_response_headers.get(
"Content-Disposition",
tool_response_headers.get(
"content-disposition", ""
),
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type",
tool_response_headers.get("content-type", ""),
)
location = tool_response_headers.get(
"Location",
tool_response_headers.get("location", ""),
)
if "text/html" in content_type:
# Display as iframe embed
tool_result_embeds.append(tool_result)
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
elif location:
tool_result_embeds.append(location)
tool_result = {
"status": "success",
"code": "ui_component",
"message": "Embedded UI result is active and visible to the user.",
}
tool_result_files = [] tool_result_files = []
if isinstance(tool_result, list): if isinstance(tool_result, list):
for item in tool_result: for item in tool_result:
# check if string # check if string
if isinstance(item, str) and item.startswith("data:"): if isinstance(item, str) and item.startswith("data:"):
tool_result_files.append(item) tool_result_files.append(
{
"type": "data",
"content": item,
}
)
tool_result.remove(item) tool_result.remove(item)
if tool.get("type") == "mcp":
if isinstance(item, dict):
if (
item.get("type") == "image"
or item.get("type") == "audio"
):
file_url = get_file_url_from_base64(
request,
f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
{
"chat_id": metadata.get(
"chat_id", None
),
"message_id": metadata.get(
"message_id", None
),
"session_id": metadata.get(
"session_id", None
),
"result": item,
},
user,
)
tool_result_files.append(
{
"type": item.get("type", "data"),
"url": file_url,
}
)
tool_result.remove(item)
if tool_result_files:
if not isinstance(tool_result, list):
tool_result = [
tool_result,
]
for file in tool_result_files:
tool_result.append(
{
"type": file.get("type", "data"),
"content": "Result is being displayed as a file.",
}
)
if isinstance(tool_result, dict) or isinstance( if isinstance(tool_result, dict) or isinstance(
tool_result, list tool_result, list
): ):
@ -2426,6 +2664,11 @@ async def process_chat_response(
if tool_result_files if tool_result_files
else {} else {}
), ),
**(
{"embeds": tool_result_embeds}
if tool_result_embeds
else {}
),
} }
) )
@ -2571,23 +2814,18 @@ async def process_chat_response(
if isinstance(stdout, str): if isinstance(stdout, str):
stdoutLines = stdout.split("\n") stdoutLines = stdout.split("\n")
for idx, line in enumerate(stdoutLines): for idx, line in enumerate(stdoutLines):
if "data:image/png;base64" in line: if "data:image/png;base64" in line:
image_url = "" image_url = get_image_url_from_base64(
# Extract base64 image data from the line request,
image_data, content_type = ( line,
load_b64_image_data(line) metadata,
user,
) )
if image_data is not None: if image_url:
image_url = upload_image( stdoutLines[idx] = (
request, f"![Output Image]({image_url})"
image_data,
content_type,
metadata,
user,
) )
stdoutLines[idx] = (
f"![Output Image]({image_url})"
)
output["stdout"] = "\n".join(stdoutLines) output["stdout"] = "\n".join(stdoutLines)
@ -2597,19 +2835,12 @@ async def process_chat_response(
resultLines = result.split("\n") resultLines = result.split("\n")
for idx, line in enumerate(resultLines): for idx, line in enumerate(resultLines):
if "data:image/png;base64" in line: if "data:image/png;base64" in line:
image_url = "" image_url = get_image_url_from_base64(
# Extract base64 image data from the line request,
image_data, content_type = ( line,
load_b64_image_data(line) metadata,
user,
) )
if image_data is not None:
image_url = upload_image(
request,
image_data,
content_type,
metadata,
user,
)
resultLines[idx] = ( resultLines[idx] = (
f"![Output Image]({image_url})" f"![Output Image]({image_url})"
) )

View file

@ -120,19 +120,20 @@ def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]
return get_system_message(messages), remove_system_message(messages) return get_system_message(messages), remove_system_message(messages)
def prepend_to_first_user_message_content( def update_message_content(message: dict, content: str, append: bool = True) -> dict:
content: str, messages: list[dict] if isinstance(message["content"], list):
) -> list[dict]: for item in message["content"]:
for message in messages: if item["type"] == "text":
if message["role"] == "user": if append:
if isinstance(message["content"], list): item["text"] = f"{item['text']}\n{content}"
for item in message["content"]: else:
if item["type"] == "text": item["text"] = f"{content}\n{item['text']}"
item["text"] = f"{content}\n{item['text']}" else:
else: if append:
message["content"] = f"{content}\n{message['content']}" message["content"] = f"{message['content']}\n{content}"
break else:
return messages message["content"] = f"{content}\n{message['content']}"
return message
def add_or_update_system_message( def add_or_update_system_message(
@ -148,10 +149,7 @@ def add_or_update_system_message(
""" """
if messages and messages[0].get("role") == "system": if messages and messages[0].get("role") == "system":
if append: messages[0] = update_message_content(messages[0], content, append)
messages[0]["content"] = f"{messages[0]['content']}\n{content}"
else:
messages[0]["content"] = f"{content}\n{messages[0]['content']}"
else: else:
# Insert at the beginning # Insert at the beginning
messages.insert(0, {"role": "system", "content": content}) messages.insert(0, {"role": "system", "content": content})
@ -159,7 +157,7 @@ def add_or_update_system_message(
return messages return messages
def add_or_update_user_message(content: str, messages: list[dict]): def add_or_update_user_message(content: str, messages: list[dict], append: bool = True):
""" """
Adds a new user message at the end of the messages list Adds a new user message at the end of the messages list
or updates the existing user message at the end. or updates the existing user message at the end.
@ -170,7 +168,7 @@ def add_or_update_user_message(content: str, messages: list[dict]):
""" """
if messages and messages[-1].get("role") == "user": if messages and messages[-1].get("role") == "user":
messages[-1]["content"] = f"{messages[-1]['content']}\n{content}" messages[-1] = update_message_content(messages[-1], content, append)
else: else:
# Insert at the end # Insert at the end
messages.append({"role": "user", "content": content}) messages.append({"role": "user", "content": content})
@ -178,6 +176,16 @@ def add_or_update_user_message(content: str, messages: list[dict]):
return messages return messages
def prepend_to_first_user_message_content(
content: str, messages: list[dict]
) -> list[dict]:
for message in messages:
if message["role"] == "user":
message = update_message_content(message, content, append=False)
break
return messages
def append_or_update_assistant_message(content: str, messages: list[dict]): def append_or_update_assistant_message(content: str, messages: list[dict]):
""" """
Adds a new assistant message at the end of the messages list Adds a new assistant message at the end of the messages list

View file

@ -1,7 +1,9 @@
import base64 import base64
import hashlib
import logging import logging
import mimetypes import mimetypes
import sys import sys
import urllib
import uuid import uuid
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -9,6 +11,9 @@ from datetime import datetime, timedelta
import re import re
import fnmatch import fnmatch
import time import time
import secrets
from cryptography.fernet import Fernet
import aiohttp import aiohttp
from authlib.integrations.starlette_client import OAuth from authlib.integrations.starlette_client import OAuth
@ -18,6 +23,7 @@ from fastapi import (
status, status,
) )
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from typing import Optional
from open_webui.models.auths import Auths from open_webui.models.auths import Auths
@ -56,11 +62,27 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_COOKIE_SECURE,
ENABLE_OAUTH_ID_TOKEN_COOKIE, ENABLE_OAUTH_ID_TOKEN_COOKIE,
OAUTH_CLIENT_INFO_ENCRYPTION_KEY,
) )
from open_webui.utils.misc import parse_duration from open_webui.utils.misc import parse_duration
from open_webui.utils.auth import get_password_hash, create_token from open_webui.utils.auth import get_password_hash, create_token
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
from mcp.shared.auth import (
OAuthClientMetadata,
OAuthMetadata,
)
class OAuthClientInformationFull(OAuthClientMetadata):
issuer: Optional[str] = None # URL of the OAuth server that issued this client
client_id: str
client_secret: str | None = None
client_id_issued_at: int | None = None
client_secret_expires_at: int | None = None
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
@ -89,6 +111,42 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
FERNET = None
if len(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) != 44:
key_bytes = hashlib.sha256(OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()).digest()
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = base64.urlsafe_b64encode(key_bytes)
else:
OAUTH_CLIENT_INFO_ENCRYPTION_KEY = OAUTH_CLIENT_INFO_ENCRYPTION_KEY.encode()
try:
FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY)
except Exception as e:
log.error(f"Error initializing Fernet with provided key: {e}")
raise
def encrypt_data(data) -> str:
"""Encrypt data for storage"""
try:
data_json = json.dumps(data)
encrypted = FERNET.encrypt(data_json.encode()).decode()
return encrypted
except Exception as e:
log.error(f"Error encrypting data: {e}")
raise
def decrypt_data(data: str):
"""Decrypt data from storage"""
try:
decrypted = FERNET.decrypt(data.encode()).decode()
return json.loads(decrypted)
except Exception as e:
log.error(f"Error decrypting data: {e}")
raise
def is_in_blocked_groups(group_name: str, groups: list) -> bool: def is_in_blocked_groups(group_name: str, groups: list) -> bool:
""" """
Check if a group name matches any blocked pattern. Check if a group name matches any blocked pattern.
@ -133,6 +191,412 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool:
return False return False
def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
parsed = urllib.parse.urlparse(server_url)
base_url = f"{parsed.scheme}://{parsed.netloc}"
return parsed, base_url
def get_discovery_urls(server_url) -> list[str]:
urls = []
parsed, base_url = get_parsed_and_base_url(server_url)
urls.append(
urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server")
)
urls.append(urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"))
return urls
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
# This is not currently supported.
async def get_oauth_client_info_with_dynamic_client_registration(
request,
client_id: str,
oauth_server_url: str,
oauth_server_key: Optional[str] = None,
) -> OAuthClientInformationFull:
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("/")
oauth_client_metadata = OAuthClientMetadata(
client_name="Open WebUI",
redirect_uris=[f"{redirect_base_url}/oauth/clients/{client_id}/callback"],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="client_secret_post",
)
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
discovery_urls = get_discovery_urls(oauth_server_url)
for url in discovery_urls:
async with aiohttp.ClientSession() as session:
async with session.get(
url, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as oauth_server_metadata_response:
if oauth_server_metadata_response.status == 200:
try:
oauth_server_metadata = OAuthMetadata.model_validate(
await oauth_server_metadata_response.json()
)
oauth_server_metadata_url = url
if (
oauth_client_metadata.scope is None
and oauth_server_metadata.scopes_supported is not None
):
oauth_client_metadata.scope = " ".join(
oauth_server_metadata.scopes_supported
)
break
except Exception as e:
log.error(f"Error parsing OAuth metadata from {url}: {e}")
continue
registration_url = None
if oauth_server_metadata and oauth_server_metadata.registration_endpoint:
registration_url = str(oauth_server_metadata.registration_endpoint)
else:
_, base_url = get_parsed_and_base_url(oauth_server_url)
registration_url = urllib.parse.urljoin(base_url, "/register")
registration_data = oauth_client_metadata.model_dump(
exclude_none=True,
mode="json",
by_alias=True,
)
# Perform dynamic client registration and return client info
async with aiohttp.ClientSession() as session:
async with session.post(
registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as oauth_client_registration_response:
try:
registration_response_json = (
await oauth_client_registration_response.json()
)
oauth_client_info = OAuthClientInformationFull.model_validate(
{
**registration_response_json,
**{"issuer": oauth_server_metadata_url},
}
)
log.info(
f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}"
)
return oauth_client_info
except Exception as e:
error_text = None
try:
error_text = await oauth_client_registration_response.text()
log.error(
f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}"
)
except Exception as e:
pass
log.error(f"Error parsing client registration response: {e}")
raise Exception(
f"Dynamic client registration failed: {error_text}"
if error_text
else "Error parsing client registration response"
)
raise Exception("Dynamic client registration failed")
except Exception as e:
log.error(f"Exception during dynamic client registration: {e}")
raise e
class OAuthClientManager:
def __init__(self, app):
self.oauth = OAuth()
self.app = app
self.clients = {}
def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull):
self.clients[client_id] = {
"client": self.oauth.register(
name=client_id,
client_id=oauth_client_info.client_id,
client_secret=oauth_client_info.client_secret,
client_kwargs=(
{"scope": oauth_client_info.scope}
if oauth_client_info.scope
else {}
),
server_metadata_url=(
oauth_client_info.issuer if oauth_client_info.issuer else None
),
),
"client_info": oauth_client_info,
}
return self.clients[client_id]
def remove_client(self, client_id):
if client_id in self.clients:
del self.clients[client_id]
log.info(f"Removed OAuth client {client_id}")
return True
def get_client(self, client_id):
client = self.clients.get(client_id)
return client["client"] if client else None
def get_client_info(self, client_id):
client = self.clients.get(client_id)
return client["client_info"] if client else None
def get_server_metadata_url(self, client_id):
if client_id in self.clients:
client = self.clients[client_id]
return (
client.server_metadata_url
if hasattr(client, "server_metadata_url")
else None
)
return None
async def get_oauth_token(
self, user_id: str, client_id: str, force_refresh: bool = False
):
"""
Get a valid OAuth token for the user, automatically refreshing if needed.
Args:
user_id: The user ID
client_id: The OAuth client ID (provider)
force_refresh: Force token refresh even if current token appears valid
Returns:
dict: OAuth token data with access_token, or None if no valid token available
"""
try:
# Get the OAuth session
session = OAuthSessions.get_session_by_provider_and_user_id(
client_id, user_id
)
if not session:
log.warning(
f"No OAuth session found for user {user_id}, client_id {client_id}"
)
return None
if force_refresh or datetime.now() + timedelta(
minutes=5
) >= datetime.fromtimestamp(session.expires_at):
log.debug(
f"Token refresh needed for user {user_id}, client_id {session.provider}"
)
refreshed_token = await self._refresh_token(session)
if refreshed_token:
return refreshed_token
else:
log.warning(
f"Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}"
)
OAuthSessions.delete_session_by_id(session.id)
return None
return session.token
except Exception as e:
log.error(f"Error getting OAuth token for user {user_id}: {e}")
return None
async def _refresh_token(self, session) -> dict:
"""
Refresh an OAuth token if needed, with concurrency protection.
Args:
session: The OAuth session object
Returns:
dict: Refreshed token data, or None if refresh failed
"""
try:
# Perform the actual refresh
refreshed_token = await self._perform_token_refresh(session)
if refreshed_token:
# Update the session with new token data
session = OAuthSessions.update_session_by_id(
session.id, refreshed_token
)
log.info(f"Successfully refreshed token for session {session.id}")
return session.token
else:
log.error(f"Failed to refresh token for session {session.id}")
return None
except Exception as e:
log.error(f"Error refreshing token for session {session.id}: {e}")
return None
async def _perform_token_refresh(self, session) -> dict:
"""
Perform the actual OAuth token refresh.
Args:
session: The OAuth session object
Returns:
dict: New token data, or None if refresh failed
"""
client_id = session.provider
token_data = session.token
if not token_data.get("refresh_token"):
log.warning(f"No refresh token available for session {session.id}")
return None
try:
client = self.get_client(client_id)
if not client:
log.error(f"No OAuth client found for provider {client_id}")
return None
token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(
self.get_server_metadata_url(client_id)
) as r:
if r.status == 200:
openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint")
else:
log.error(
f"Failed to fetch OpenID configuration for client_id {client_id}"
)
if not token_endpoint:
log.error(f"No token endpoint found for client_id {client_id}")
return None
# Prepare refresh request
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": token_data["refresh_token"],
"client_id": client.client_id,
}
if hasattr(client, "client_secret") and client.client_secret:
refresh_data["client_secret"] = client.client_secret
# Make refresh request
async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.post(
token_endpoint,
data=refresh_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
if r.status == 200:
new_token_data = await r.json()
# Merge with existing token data (preserve refresh_token if not provided)
if "refresh_token" not in new_token_data:
new_token_data["refresh_token"] = token_data[
"refresh_token"
]
# Add timestamp for tracking
new_token_data["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if (
"expires_in" in new_token_data
and "expires_at" not in new_token_data
):
new_token_data["expires_at"] = int(
datetime.now().timestamp()
+ new_token_data["expires_in"]
)
log.debug(f"Token refresh successful for client_id {client_id}")
return new_token_data
else:
error_text = await r.text()
log.error(
f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}"
)
return None
except Exception as e:
log.error(f"Exception during token refresh for client_id {client_id}: {e}")
return None
async def handle_authorize(self, request, client_id: str) -> RedirectResponse:
client = self.get_client(client_id)
if client is None:
raise HTTPException(404)
client_info = self.get_client_info(client_id)
if client_info is None:
raise HTTPException(404)
redirect_uri = (
client_info.redirect_uris[0] if client_info.redirect_uris else None
)
return await client.authorize_redirect(request, str(redirect_uri))
async def handle_callback(self, request, client_id: str, user_id: str, response):
client = self.get_client(client_id)
if client is None:
raise HTTPException(404)
error_message = None
try:
token = await client.authorize_access_token(request)
if token:
try:
# Add timestamp for tracking
token["issued_at"] = datetime.now().timestamp()
# Calculate expires_at if we have expires_in
if "expires_in" in token and "expires_at" not in token:
token["expires_at"] = (
datetime.now().timestamp() + token["expires_in"]
)
# Clean up any existing sessions for this user/client_id first
sessions = OAuthSessions.get_sessions_by_user_id(user_id)
for session in sessions:
if session.provider == client_id:
OAuthSessions.delete_session_by_id(session.id)
session = OAuthSessions.create_session(
user_id=user_id,
provider=client_id,
token=token,
)
log.info(
f"Stored OAuth session server-side for user {user_id}, client_id {client_id}"
)
except Exception as e:
error_message = "Failed to store OAuth session server-side"
log.error(f"Failed to store OAuth session server-side: {e}")
else:
error_message = "Failed to obtain OAuth token"
log.warning(error_message)
except Exception as e:
error_message = "OAuth callback error"
log.warning(f"OAuth callback error: {e}")
redirect_url = (
str(request.app.state.config.WEBUI_URL or request.base_url)
).rstrip("/")
if error_message:
log.debug(error_message)
redirect_url = f"{redirect_url}/?error={error_message}"
return RedirectResponse(url=redirect_url, headers=response.headers)
response = RedirectResponse(url=redirect_url, headers=response.headers)
return response
class OAuthManager: class OAuthManager:
def __init__(self, app): def __init__(self, app):
self.oauth = OAuth() self.oauth = OAuth()
@ -157,7 +621,7 @@ class OAuthManager:
) )
return None return None
def get_oauth_token( async def get_oauth_token(
self, user_id: str, session_id: str, force_refresh: bool = False self, user_id: str, session_id: str, force_refresh: bool = False
): ):
""" """
@ -186,13 +650,15 @@ class OAuthManager:
log.debug( log.debug(
f"Token refresh needed for user {user_id}, provider {session.provider}" f"Token refresh needed for user {user_id}, provider {session.provider}"
) )
refreshed_token = self._refresh_token(session) refreshed_token = await self._refresh_token(session)
if refreshed_token: if refreshed_token:
return refreshed_token return refreshed_token
else: else:
log.warning( log.warning(
f"Token refresh failed for user {user_id}, provider {session.provider}" f"Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}"
) )
OAuthSessions.delete_session_by_id(session.id)
return None return None
return session.token return session.token
@ -252,9 +718,10 @@ class OAuthManager:
log.error(f"No OAuth client found for provider {provider}") log.error(f"No OAuth client found for provider {provider}")
return None return None
server_metadata_url = self.get_server_metadata_url(provider)
token_endpoint = None token_endpoint = None
async with aiohttp.ClientSession(trust_env=True) as session_http: async with aiohttp.ClientSession(trust_env=True) as session_http:
async with session_http.get(client.gserver_metadata_url) as r: async with session_http.get(server_metadata_url) as r:
if r.status == 200: if r.status == 200:
openid_data = await r.json() openid_data = await r.json()
token_endpoint = openid_data.get("token_endpoint") token_endpoint = openid_data.get("token_endpoint")
@ -301,7 +768,7 @@ class OAuthManager:
"expires_in" in new_token_data "expires_in" in new_token_data
and "expires_at" not in new_token_data and "expires_at" not in new_token_data
): ):
new_token_data["expires_at"] = ( new_token_data["expires_at"] = int(
datetime.now().timestamp() datetime.now().timestamp()
+ new_token_data["expires_in"] + new_token_data["expires_in"]
) )
@ -574,7 +1041,7 @@ class OAuthManager:
raise HTTPException(404) raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one # If the provider has a custom redirect URL, use that, otherwise automatically generate one
redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for( redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for(
"oauth_callback", provider=provider "oauth_login_callback", provider=provider
) )
client = self.get_client(provider) client = self.get_client(provider)
if client is None: if client is None:
@ -791,9 +1258,9 @@ class OAuthManager:
else ERROR_MESSAGES.DEFAULT("Error during OAuth process") else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
) )
redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url) redirect_base_url = (
if redirect_base_url.endswith("/"): str(request.app.state.config.WEBUI_URL or request.base_url)
redirect_base_url = redirect_base_url[:-1] ).rstrip("/")
redirect_url = f"{redirect_base_url}/auth" redirect_url = f"{redirect_base_url}/auth"
if error_message: if error_message:

View file

@ -96,92 +96,117 @@ async def get_tools(
for tool_id in tool_ids: for tool_id in tool_ids:
tool = Tools.get_tool_by_id(tool_id) tool = Tools.get_tool_by_id(tool_id)
if tool is None: if tool is None:
if tool_id.startswith("server:"): if tool_id.startswith("server:"):
server_id = tool_id.split(":")[1] splits = tool_id.split(":")
tool_server_data = None if len(splits) == 2:
for server in await get_tool_servers(request): type = "openapi"
if server["id"] == server_id: server_id = splits[1]
tool_server_data = server elif len(splits) == 3:
break type = splits[1]
server_id = splits[2]
if tool_server_data is None: server_id_splits = server_id.split("|")
log.warning(f"Tool server data not found for {server_id}") if len(server_id_splits) == 2:
server_id = server_id_splits[0]
function_names = server_id_splits[1].split(",")
if type == "openapi":
tool_server_data = None
for server in await get_tool_servers(request):
if server["id"] == server_id:
tool_server_data = server
break
if tool_server_data is None:
log.warning(f"Tool server data not found for {server_id}")
continue
tool_server_idx = tool_server_data.get("idx", 0)
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[
tool_server_idx
]
)
specs = tool_server_data.get("specs", [])
for spec in specs:
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
cookies = {}
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
headers["Content-Type"] = "application/json"
def make_tool_function(
function_name, tool_server_data, headers
):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
return tool_function
tool_function = make_tool_function(
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
# Misc info
"type": "external",
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend server ID to function name
function_name = f"{server_id}_{function_name}"
tools_dict[function_name] = tool_dict
else:
continue continue
tool_server_idx = tool_server_data.get("idx", 0)
tool_server_connection = (
request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
)
specs = tool_server_data.get("specs", [])
for spec in specs:
function_name = spec["name"]
auth_type = tool_server_connection.get("auth_type", "bearer")
cookies = {}
headers = {}
if auth_type == "bearer":
headers["Authorization"] = (
f"Bearer {tool_server_connection.get('key', '')}"
)
elif auth_type == "none":
# No authentication
pass
elif auth_type == "session":
cookies = request.cookies
headers["Authorization"] = (
f"Bearer {request.state.token.credentials}"
)
elif auth_type == "system_oauth":
cookies = request.cookies
oauth_token = extra_params.get("__oauth_token__", None)
if oauth_token:
headers["Authorization"] = (
f"Bearer {oauth_token.get('access_token', '')}"
)
headers["Content-Type"] = "application/json"
def make_tool_function(function_name, tool_server_data, headers):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data["url"],
headers=headers,
cookies=cookies,
name=function_name,
params=kwargs,
server_data=tool_server_data,
)
return tool_function
tool_function = make_tool_function(
function_name, tool_server_data, headers
)
callable = get_async_tool_function_and_apply_extra_params(
tool_function,
{},
)
tool_dict = {
"tool_id": tool_id,
"callable": callable,
"spec": spec,
}
# Handle function name collisions
while function_name in tools_dict:
log.warning(
f"Tool {function_name} already exists in another tools!"
)
# Prepend server ID to function name
function_name = f"{server_id}_{function_name}"
tools_dict[function_name] = tool_dict
else: else:
continue continue
else: else:
@ -577,7 +602,10 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str,
# Prepare list of enabled servers along with their original index # Prepare list of enabled servers along with their original index
server_entries = [] server_entries = []
for idx, server in enumerate(servers): for idx, server in enumerate(servers):
if server.get("config", {}).get("enable"): if (
server.get("config", {}).get("enable")
and server.get("type", "openapi") == "openapi"
):
# Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
openapi_path = server.get("path", "openapi.json") openapi_path = server.get("path", "openapi.json")
full_url = get_tool_server_url(server.get("url"), openapi_path) full_url = get_tool_server_url(server.get("url"), openapi_path)
@ -646,7 +674,7 @@ async def execute_tool_server(
name: str, name: str,
params: Dict[str, Any], params: Dict[str, Any],
server_data: Dict[str, Any], server_data: Dict[str, Any],
) -> Any: ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
error = None error = None
try: try:
openapi = server_data.get("openapi", {}) openapi = server_data.get("openapi", {})
@ -718,6 +746,7 @@ async def execute_tool_server(
headers=headers, headers=headers,
cookies=cookies, cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
) as response: ) as response:
if response.status >= 400: if response.status >= 400:
text = await response.text() text = await response.text()
@ -728,13 +757,15 @@ async def execute_tool_server(
except Exception: except Exception:
response_data = await response.text() response_data = await response.text()
return response_data response_headers = response.headers
return (response_data, response_headers)
else: else:
async with request_method( async with request_method(
final_url, final_url,
headers=headers, headers=headers,
cookies=cookies, cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
) as response: ) as response:
if response.status >= 400: if response.status >= 400:
text = await response.text() text = await response.text()
@ -745,12 +776,13 @@ async def execute_tool_server(
except Exception: except Exception:
response_data = await response.text() response_data = await response.text()
return response_data response_headers = response.headers
return (response_data, response_headers)
except Exception as err: except Exception as err:
error = str(err) error = str(err)
log.exception(f"API Request Error: {error}") log.exception(f"API Request Error: {error}")
return {"error": error} return ({"error": error}, None)
def get_tool_server_url(url: Optional[str], path: str) -> str: def get_tool_server_url(url: Optional[str], path: str) -> str:

View file

@ -2,58 +2,66 @@ fastapi==0.115.7
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
pydantic==2.11.7 pydantic==2.11.7
python-multipart==0.0.20 python-multipart==0.0.20
itsdangerous==2.2.0
python-socketio==5.13.0 python-socketio==5.13.0
python-jose==3.4.0 python-jose==3.4.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
cryptography cryptography
bcrypt==4.3.0
argon2-cffi==25.1.0
PyJWT[crypto]==2.10.1
authlib==1.6.3
requests==2.32.4 requests==2.32.5
aiohttp==3.12.15 aiohttp==3.12.15
async-timeout async-timeout
aiocache aiocache
aiofiles aiofiles
starlette-compress==1.6.0 starlette-compress==1.6.0
httpx[socks,http2,zstd,cli,brotli]==0.28.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.38 sqlalchemy==2.0.38
alembic==1.14.0 alembic==1.14.0
peewee==3.18.1 peewee==3.18.1
peewee-migrate==1.12.2 peewee-migrate==1.12.2
psycopg2-binary==2.9.10
pgvector==0.4.1
PyMySQL==1.1.1
bcrypt==4.3.0
pymongo
redis
boto3==1.40.5
argon2-cffi==25.1.0
APScheduler==3.10.4
pycrdt==0.12.25 pycrdt==0.12.25
redis
pymongo
psycopg2-binary==2.9.10
pgvector==0.4.1
PyMySQL==1.1.1
boto3==1.40.5
APScheduler==3.10.4
RestrictedPython==8.0 RestrictedPython==8.0
loguru==0.7.3 loguru==0.7.3
asgiref==3.8.1 asgiref==3.8.1
# AI libraries # AI libraries
tiktoken
mcp==1.14.1
openai openai
anthropic anthropic
google-genai==1.32.0 google-genai==1.38.0
google-generativeai==0.8.5 google-generativeai==0.8.5
tiktoken
langchain==0.3.26 langchain==0.3.27
langchain-community==0.3.27 langchain-community==0.3.29
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==1.0.20 chromadb==1.0.20
opensearch-py==2.8.0
pymilvus==2.5.0 pymilvus==2.5.0
qdrant-client==1.14.3 qdrant-client==1.14.3
opensearch-py==2.8.0
playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
elasticsearch==9.1.0 elasticsearch==9.1.0
pinecone==6.0.2 pinecone==6.0.2
@ -61,12 +69,12 @@ oracledb==3.2.0
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720 av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
transformers transformers
sentence-transformers==4.1.0 sentence-transformers==5.1.1
accelerate accelerate
colbert-ai==0.2.21
pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897
einops==0.8.1 einops==0.8.1
colbert-ai==0.2.21
ftfy==6.2.3 ftfy==6.2.3
pypdf==6.0.0 pypdf==6.0.0
@ -94,11 +102,8 @@ rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2 rank-bm25==0.2.2
onnxruntime==1.20.1 onnxruntime==1.20.1
faster-whisper==1.1.1 faster-whisper==1.1.1
PyJWT[crypto]==2.10.1
authlib==1.6.3
black==25.1.0 black==25.1.0
youtube-transcript-api==1.2.2 youtube-transcript-api==1.2.2
@ -120,7 +125,7 @@ pytest-docker~=3.1.1
googleapis-common-protos==1.70.0 googleapis-common-protos==1.70.0
google-cloud-storage==2.19.0 google-cloud-storage==2.19.0
azure-identity==1.23.0 azure-identity==1.25.0
azure-storage-blob==12.24.1 azure-storage-blob==12.24.1

65
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.30", "version": "0.6.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.30", "version": "0.6.31",
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.5.0", "@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
@ -23,7 +23,7 @@
"@tiptap/core": "^3.0.7", "@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1", "@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^3.0.7", "@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.0.7", "@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.3.0", "@tiptap/extension-highlight": "^3.3.0",
@ -39,6 +39,7 @@
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2", "@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
@ -3383,9 +3384,9 @@
} }
}, },
"node_modules/@tiptap/extension-collaboration": { "node_modules/@tiptap/extension-collaboration": {
"version": "3.0.7", "version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==", "integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {
@ -3393,8 +3394,8 @@
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.0.7", "@tiptap/core": "^3.4.5",
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.4.5",
"@tiptap/y-tiptap": "^3.0.0-beta.3", "@tiptap/y-tiptap": "^3.0.0-beta.3",
"yjs": "^13" "yjs": "^13"
} }
@ -3413,9 +3414,9 @@
} }
}, },
"node_modules/@tiptap/extension-drag-handle": { "node_modules/@tiptap/extension-drag-handle": {
"version": "3.0.7", "version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==", "integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13" "@floating-ui/dom": "^1.6.13"
@ -3425,10 +3426,10 @@
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.0.7", "@tiptap/core": "^3.4.5",
"@tiptap/extension-collaboration": "^3.0.7", "@tiptap/extension-collaboration": "^3.4.5",
"@tiptap/extension-node-range": "^3.0.7", "@tiptap/extension-node-range": "^3.4.5",
"@tiptap/pm": "^3.0.7", "@tiptap/pm": "^3.4.5",
"@tiptap/y-tiptap": "^3.0.0-beta.3" "@tiptap/y-tiptap": "^3.0.0-beta.3"
} }
}, },
@ -3642,9 +3643,9 @@
} }
}, },
"node_modules/@tiptap/extension-node-range": { "node_modules/@tiptap/extension-node-range": {
"version": "3.0.7", "version": "3.4.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==", "integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"funding": { "funding": {
@ -3652,8 +3653,8 @@
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
}, },
"peerDependencies": { "peerDependencies": {
"@tiptap/core": "^3.0.7", "@tiptap/core": "^3.4.5",
"@tiptap/pm": "^3.0.7" "@tiptap/pm": "^3.4.5"
} }
}, },
"node_modules/@tiptap/extension-ordered-list": { "node_modules/@tiptap/extension-ordered-list": {
@ -4569,6 +4570,21 @@
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
}, },
"node_modules/@vue/reactivity": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.1.5"
}
},
"node_modules/@vue/shared": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
"license": "MIT"
},
"node_modules/@webreflection/fetch": { "node_modules/@webreflection/fetch": {
"version": "0.1.5", "version": "0.1.5",
"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz", "resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
@ -4672,6 +4688,15 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/alpinejs": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/amator": { "node_modules/amator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.30", "version": "0.6.31",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -67,7 +67,7 @@
"@tiptap/core": "^3.0.7", "@tiptap/core": "^3.0.7",
"@tiptap/extension-bubble-menu": "^2.26.1", "@tiptap/extension-bubble-menu": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^3.0.7", "@tiptap/extension-code-block-lowlight": "^3.0.7",
"@tiptap/extension-drag-handle": "^3.0.7", "@tiptap/extension-drag-handle": "^3.4.5",
"@tiptap/extension-file-handler": "^3.0.7", "@tiptap/extension-file-handler": "^3.0.7",
"@tiptap/extension-floating-menu": "^2.26.1", "@tiptap/extension-floating-menu": "^2.26.1",
"@tiptap/extension-highlight": "^3.3.0", "@tiptap/extension-highlight": "^3.3.0",
@ -83,6 +83,7 @@
"@tiptap/starter-kit": "^3.0.7", "@tiptap/starter-kit": "^3.0.7",
"@tiptap/suggestion": "^3.4.2", "@tiptap/suggestion": "^3.4.2",
"@xyflow/svelte": "^0.1.19", "@xyflow/svelte": "^0.1.19",
"alpinejs": "^3.15.0",
"async": "^3.2.5", "async": "^3.2.5",
"bits-ui": "^0.21.15", "bits-ui": "^0.21.15",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",

View file

@ -10,23 +10,25 @@ dependencies = [
"uvicorn[standard]==0.35.0", "uvicorn[standard]==0.35.0",
"pydantic==2.11.7", "pydantic==2.11.7",
"python-multipart==0.0.20", "python-multipart==0.0.20",
"itsdangerous==2.2.0",
"python-socketio==5.13.0", "python-socketio==5.13.0",
"python-jose==3.4.0", "python-jose==3.4.0",
"passlib[bcrypt]==1.7.4", "passlib[bcrypt]==1.7.4",
"cryptography", "cryptography",
"bcrypt==4.3.0", "bcrypt==4.3.0",
"argon2-cffi==23.1.0", "argon2-cffi==25.1.0",
"PyJWT[crypto]==2.10.1", "PyJWT[crypto]==2.10.1",
"authlib==1.6.3", "authlib==1.6.3",
"requests==2.32.4", "requests==2.32.5",
"aiohttp==3.12.15", "aiohttp==3.12.15",
"async-timeout", "async-timeout",
"aiocache", "aiocache",
"aiofiles", "aiofiles",
"starlette-compress==1.6.0", "starlette-compress==1.6.0",
"httpx[socks,http2,zstd,cli,brotli]==0.28.1", "httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"starsessions[redis]==2.2.1",
"sqlalchemy==2.0.38", "sqlalchemy==2.0.38",
"alembic==1.14.0", "alembic==1.14.0",
@ -46,20 +48,22 @@ dependencies = [
"asgiref==3.8.1", "asgiref==3.8.1",
"tiktoken", "tiktoken",
"mcp==1.14.1",
"openai", "openai",
"anthropic", "anthropic",
"google-genai==1.32.0", "google-genai==1.38.0",
"google-generativeai==0.8.5", "google-generativeai==0.8.5",
"langchain==0.3.26", "langchain==0.3.27",
"langchain-community==0.3.27", "langchain-community==0.3.29",
"fake-useragent==2.2.0", "fake-useragent==2.2.0",
"chromadb==1.0.20", "chromadb==1.0.20",
"opensearch-py==2.8.0", "opensearch-py==2.8.0",
"transformers", "transformers",
"sentence-transformers==4.1.0", "sentence-transformers==5.1.1",
"accelerate", "accelerate",
"pyarrow==20.0.0", "pyarrow==20.0.0",
"einops==0.8.1", "einops==0.8.1",
@ -108,7 +112,7 @@ dependencies = [
"googleapis-common-protos==1.70.0", "googleapis-common-protos==1.70.0",
"google-cloud-storage==2.19.0", "google-cloud-storage==2.19.0",
"azure-identity==1.20.0", "azure-identity==1.25.0",
"azure-storage-blob==12.24.1", "azure-storage-blob==12.24.1",
"ldap3==2.9.1", "ldap3==2.9.1",

View file

@ -116,7 +116,7 @@ li p {
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
--tw-border-opacity: 1; --tw-border-opacity: 1;
background-color: rgba(215, 215, 215, 0.8); background-color: rgba(215, 215, 215, 0.6);
border-color: rgba(255, 255, 255, var(--tw-border-opacity)); border-color: rgba(255, 255, 255, var(--tw-border-opacity));
border-radius: 9999px; border-radius: 9999px;
border-width: 1px; border-width: 1px;
@ -124,12 +124,12 @@ li p {
/* Dark theme scrollbar styles */ /* Dark theme scrollbar styles */
.dark ::-webkit-scrollbar-thumb { .dark ::-webkit-scrollbar-thumb {
background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */ background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
border-color: rgba(0, 0, 0, var(--tw-border-opacity)); border-color: rgba(0, 0, 0, var(--tw-border-opacity));
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
height: 0.6rem; height: 0.4rem;
width: 0.4rem; width: 0.4rem;
} }
@ -661,3 +661,112 @@ body {
background: #171717; background: #171717;
color: #eee; color: #eee;
} }
/* Position the handle relative to each LI */
.pm-li--with-handle {
position: relative;
margin-left: 12px; /* make space for the handle */
}
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
margin-left: 0px;
}
/* The drag handle itself */
.pm-list-drag-handle {
position: absolute;
left: -36px; /* pull into the left gutter */
top: 1px;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
border-radius: 4px;
cursor: grab;
user-select: none;
opacity: 0.35;
transition:
opacity 120ms ease,
background 120ms ease;
}
.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
left: -16px; /* pull into the left gutter more to avoid the checkbox */
}
.pm-list-drag-handle:active {
cursor: grabbing;
}
.pm-li--with-handle:hover > .pm-list-drag-handle {
opacity: 1;
}
.pm-list-drag-handle:hover {
background: rgba(0, 0, 0, 0.06);
}
:root {
--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
}
/* BEFORE/AFTER lines */
.pm-li-drop-before::before,
.pm-li-drop-after::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 3px;
background: var(--pm-accent);
pointer-events: none;
}
.pm-li-drop-before::before {
top: -2px;
}
.pm-li-drop-after::after {
bottom: -2px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
background: var(--pm-fill-target);
border-radius: 6px;
}
.pm-li-drop-outdent::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 3px;
background: color-mix(in oklab, Highlight 35%, transparent);
}
.pm-li--with-handle:has(.pm-li-drop-before),
.pm-li--with-handle:has(.pm-li-drop-after),
.pm-li--with-handle:has(.pm-li-drop-into),
.pm-li--with-handle:has(.pm-li-drop-outdent) {
background: var(--pm-fill-ancestor);
border-radius: 6px;
}
.pm-li-drop-before,
.pm-li-drop-after,
.pm-li-drop-into,
.pm-li-drop-outdent {
position: relative;
z-index: 0;
}

View file

@ -77,7 +77,11 @@ export const importChat = async (
return res; return res;
}; };
export const getChatList = async (token: string = '', page: number | null = null) => { export const getChatList = async (
token: string = '',
page: number | null = null,
include_folders: boolean = false
) => {
let error = null; let error = null;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@ -85,6 +89,10 @@ export const getChatList = async (token: string = '', page: number | null = null
searchParams.append('page', `${page}`); searchParams.append('page', `${page}`);
} }
if (include_folders) {
searchParams.append('include_folders', 'true');
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, { const res = await fetch(`${WEBUI_API_BASE_URL}/chats/?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {

View file

@ -1,4 +1,4 @@
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { Banner } from '$lib/types'; import type { Banner } from '$lib/types';
export const importConfig = async (token: string, config) => { export const importConfig = async (token: string, config) => {
@ -202,6 +202,52 @@ export const verifyToolServerConnection = async (token: string, connection: obje
return res; return res;
}; };
type RegisterOAuthClientForm = {
url: string;
client_id: string;
client_name?: string;
};
export const registerOAuthClient = async (
token: string,
formData: RegisterOAuthClientForm,
type: null | string = null
) => {
let error = null;
const searchParams = type ? `?type=${type}` : '';
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/oauth/clients/register${searchParams}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({
...formData
})
})
.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 getOAuthClientAuthorizationUrl = (clientId: string, type: null | string = null) => {
const oauthClientId = type ? `${type}:${clientId}` : clientId;
return `${WEBUI_BASE_URL}/oauth/clients/${oauthClientId}/authorize`;
};
export const getCodeExecutionConfig = async (token: string) => { export const getCodeExecutionConfig = async (token: string) => {
let error = null; let error = null;

View file

@ -493,18 +493,25 @@ export const executeToolServer = async (
throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`); throw new Error(`HTTP error! Status: ${res.status}. Message: ${resText}`);
} }
let responseData; // make a clone of res and extract headers
try { const responseHeaders = {};
responseData = await res.json(); res.headers.forEach((value, key) => {
} catch (err) { responseHeaders[key] = value;
responseData = await res.text(); });
}
return responseData; const text = await res.text();
let responseData;
try {
responseData = JSON.parse(text);
} catch {
responseData = text;
}
return [responseData, responseHeaders];
} catch (err: any) { } catch (err: any) {
error = err.message; error = err.message;
console.error('API Request Error:', error); console.error('API Request Error:', error);
return { error }; return [{ error }, null];
} }
}; };

View file

@ -13,7 +13,7 @@
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import Tags from './common/Tags.svelte'; import Tags from './common/Tags.svelte';
import { getToolServerData } from '$lib/apis'; import { getToolServerData } from '$lib/apis';
import { verifyToolServerConnection } from '$lib/apis/configs'; import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
import AccessControl from './workspace/common/AccessControl.svelte'; import AccessControl from './workspace/common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
@ -30,6 +30,8 @@
let url = ''; let url = '';
let path = 'openapi.json'; let path = 'openapi.json';
let type = 'openapi'; // 'openapi', 'mcp'
let auth_type = 'bearer'; let auth_type = 'bearer';
let key = ''; let key = '';
@ -39,10 +41,47 @@
let name = ''; let name = '';
let description = ''; let description = '';
let enable = true; let oauthClientInfo = null;
let enable = true;
let loading = false; let loading = false;
const registerOAuthClientHandler = async () => {
if (url === '') {
toast.error($i18n.t('Please enter a valid URL'));
return;
}
if (id === '') {
toast.error($i18n.t('Please enter a valid ID'));
return;
}
const res = await registerOAuthClient(
localStorage.token,
{
url: url,
client_id: id
},
'mcp'
).catch((err) => {
toast.error($i18n.t('Registration failed'));
return null;
});
if (res) {
toast.warning(
$i18n.t(
'Please save the connection to persist the OAuth client information and do not change the ID'
)
);
toast.success($i18n.t('Registration successful'));
console.debug('Registration successful', res);
oauthClientInfo = res?.oauth_client_info ?? null;
}
};
const verifyHandler = async () => { const verifyHandler = async () => {
if (url === '') { if (url === '') {
toast.error($i18n.t('Please enter a valid URL')); toast.error($i18n.t('Please enter a valid URL'));
@ -70,6 +109,7 @@
const res = await verifyToolServerConnection(localStorage.token, { const res = await verifyToolServerConnection(localStorage.token, {
url, url,
path, path,
type,
auth_type, auth_type,
key, key,
config: { config: {
@ -97,10 +137,22 @@
// remove trailing slash from url // remove trailing slash from url
url = url.replace(/\/$/, ''); url = url.replace(/\/$/, '');
if (id.includes(':') || id.includes('|')) {
toast.error($i18n.t('ID cannot contain ":" or "|" characters'));
loading = false;
return;
}
if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) {
toast.error($i18n.t('Please register the OAuth client'));
loading = false;
return;
}
const connection = { const connection = {
url, url,
path, path,
type,
auth_type, auth_type,
key, key,
config: { config: {
@ -110,7 +162,8 @@
info: { info: {
id: id, id: id,
name: name, name: name,
description: description description: description,
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {})
} }
}; };
@ -119,14 +172,18 @@
loading = false; loading = false;
show = false; show = false;
// reset form
url = ''; url = '';
path = 'openapi.json'; path = 'openapi.json';
type = 'openapi';
key = ''; key = '';
auth_type = 'bearer'; auth_type = 'bearer';
id = ''; id = '';
name = ''; name = '';
description = ''; description = '';
oauthClientInfo = null;
enable = true; enable = true;
accessControl = null; accessControl = null;
@ -137,12 +194,14 @@
url = connection.url; url = connection.url;
path = connection?.path ?? 'openapi.json'; path = connection?.path ?? 'openapi.json';
type = connection?.type ?? 'openapi';
auth_type = connection?.auth_type ?? 'bearer'; auth_type = connection?.auth_type ?? 'bearer';
key = connection?.key ?? ''; key = connection?.key ?? '';
id = connection.info?.id ?? ''; id = connection.info?.id ?? '';
name = connection.info?.name ?? ''; name = connection.info?.name ?? '';
description = connection.info?.description ?? ''; description = connection.info?.description ?? '';
oauthClientInfo = connection.info?.oauth_client_info ?? null;
enable = connection.config?.enable ?? true; enable = connection.config?.enable ?? true;
accessControl = connection.config?.access_control ?? null; accessControl = connection.config?.access_control ?? null;
@ -189,6 +248,31 @@
}} }}
> >
<div class="px-1"> <div class="px-1">
{#if !direct}
<div class="flex gap-2 mb-1.5">
<div class="flex w-full justify-between items-center">
<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
<div class="">
<button
on:click={() => {
type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
}}
type="button"
class=" text-xs text-gray-700 dark:text-gray-300"
>
{#if ['', 'openapi'].includes(type)}
{$i18n.t('OpenAPI')}
{:else if type === 'mcp'}
{$i18n.t('MCP')}
<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
{/if}
</button>
</div>
</div>
</div>
{/if}
<div class="flex gap-2"> <div class="flex gap-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div class="flex justify-between mb-0.5"> <div class="flex justify-between mb-0.5">
@ -243,38 +327,85 @@
</Tooltip> </Tooltip>
</div> </div>
<div class="flex-1 flex items-center"> {#if ['', 'openapi'].includes(type)}
<label for="url-or-path" class="sr-only" <div class="flex-1 flex items-center">
>{$i18n.t('openapi.json URL or Path')}</label <label for="url-or-path" class="sr-only"
> >{$i18n.t('openapi.json URL or Path')}</label
<input >
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`} <input
type="text" class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
id="url-or-path" type="text"
bind:value={path} id="url-or-path"
placeholder={$i18n.t('openapi.json URL or Path')} bind:value={path}
autocomplete="off" placeholder={$i18n.t('openapi.json URL or Path')}
required autocomplete="off"
/> required
</div> />
</div>
{/if}
</div> </div>
</div> </div>
<div {#if ['', 'openapi'].includes(type)}
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} <div
> class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
{$i18n.t(`WebUI will make requests to "{{url}}"`, { >
url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}` {$i18n.t(`WebUI will make requests to "{{url}}"`, {
})} url: path.includes('://')
</div> ? path
: `${url}${path.startsWith('/') ? '' : '/'}${path}`
})}
</div>
{/if}
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<label <div class="flex justify-between items-center">
for="select-bearer-or-session" <div class="flex gap-2 items-center">
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} <div
>{$i18n.t('Auth')}</label for="select-bearer-or-session"
> class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Auth')}
</div>
</div>
{#if auth_type === 'oauth_2.1'}
<div class="flex items-center gap-2">
<div class="flex flex-col justify-end items-center shrink-0">
<Tooltip
content={oauthClientInfo
? $i18n.t('Register Again')
: $i18n.t('Register Client')}
>
<button
class=" text-xs underline dark:text-gray-500 dark:hover:text-gray-200 text-gray-700 hover:text-gray-900 transition"
type="button"
on:click={() => {
registerOAuthClientHandler();
}}
>
{$i18n.t('Register Client')}
</button>
</Tooltip>
</div>
{#if !oauthClientInfo}
<div
class="text-xs font-medium px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
>
{$i18n.t('Not Registered')}
</div>
{:else}
<div
class="text-xs font-medium px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
>
{$i18n.t('Registered')}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="flex-shrink-0 self-start"> <div class="flex-shrink-0 self-start">
@ -290,6 +421,9 @@
{#if !direct} {#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option> <option value="system_oauth">{$i18n.t('OAuth')}</option>
{#if type === 'mcp'}
<option value="oauth_2.1">{$i18n.t('OAuth 2.1')}</option>
{/if}
{/if} {/if}
</select> </select>
</div> </div>
@ -319,6 +453,12 @@
> >
{$i18n.t('Forwards system user OAuth access token to authenticate')} {$i18n.t('Forwards system user OAuth access token to authenticate')}
</div> </div>
{:else if auth_type === 'oauth_2.1'}
<div
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -334,9 +474,12 @@
for="enter-id" for="enter-id"
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`} class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('ID')} >{$i18n.t('ID')}
<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span {#if type !== 'mcp'}
> <span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
>{$i18n.t('Optional')}</span
>
{/if}
</label> </label>
<div class="flex-1"> <div class="flex-1">
@ -347,6 +490,7 @@
bind:value={id} bind:value={id}
placeholder={$i18n.t('Enter ID')} placeholder={$i18n.t('Enter ID')}
autocomplete="off" autocomplete="off"
required={type === 'mcp'}
/> />
</div> </div>
</div> </div>
@ -396,13 +540,32 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2"> <div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg"> <div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl /> <AccessControl bind:accessControl />
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{#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"
>
<span class="font-medium">
{$i18n.t('Warning')}:
</span>
{$i18n.t(
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
)}
<a
class="font-medium underline"
href="https://docs.openwebui.com/features/mcp"
target="_blank">{$i18n.t('Read more →')}</a
>
</div>
{/if}
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5"> <div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
{#if edit} {#if edit}
<button <button

View file

@ -19,16 +19,19 @@
let changelog = null; let changelog = null;
onMount(async () => { const init = async () => {
const res = await getChangelog(); changelog = await getChangelog();
changelog = res; };
});
$: if (show) {
init();
}
</script> </script>
<Modal bind:show size="xl"> <Modal bind:show size="xl">
<div class="px-5 pt-4 dark:text-gray-300 text-gray-700"> <div class="px-6 pt-5 dark:text-white text-black">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div class="text-xl font-semibold"> <div class="text-xl font-medium">
{$i18n.t("What's New in")} {$i18n.t("What's New in")}
{$WEBUI_NAME} {$WEBUI_NAME}
<Confetti x={[-1, -0.25]} y={[0, 0.5]} /> <Confetti x={[-1, -0.25]} y={[0, 0.5]} />
@ -48,7 +51,7 @@
</div> </div>
<div class="flex items-center mt-1"> <div class="flex items-center mt-1">
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div> <div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" /> <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
<div class="text-sm dark:text-gray-200"> <div class="text-sm dark:text-gray-200">
v{WEBUI_VERSION} v{WEBUI_VERSION}
</div> </div>
@ -56,7 +59,7 @@
</div> </div>
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100"> <div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
<div class=" overflow-y-scroll max-h-[32rem] scrollbar-hidden"> <div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
<div class="mb-3"> <div class="mb-3">
{#if changelog} {#if changelog}
{#each Object.keys(changelog) as version} {#each Object.keys(changelog) as version}
@ -65,20 +68,20 @@
v{version} - {changelog[version].date} v{version} - {changelog[version].date}
</div> </div>
<hr class="border-gray-100 dark:border-gray-850 my-2" /> <hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section} {#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
<div class="w-full"> <div class="w-full">
<div <div
class="font-semibold uppercase text-xs {section === 'added' class="font-semibold uppercase text-xs {section === 'added'
? 'text-white bg-blue-600' ? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
: section === 'fixed' : section === 'fixed'
? 'text-white bg-green-600' ? 'bg-green-500/20 text-green-700 dark:text-green-200'
: section === 'changed' : section === 'changed'
? 'text-white bg-yellow-600' ? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
: section === 'removed' : section === 'removed'
? 'text-white bg-red-600' ? 'bg-red-500/20 text-red-700 dark:text-red-200'
: ''} w-fit px-3 rounded-full my-2.5" : ''} w-fit rounded-xl px-2 my-2.5"
> >
{section} {section}
</div> </div>

View file

@ -202,7 +202,7 @@
{:else} {:else}
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"> <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200"> <thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800"> <tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3" class="px-2.5 py-2 cursor-pointer select-none w-3"

View file

@ -389,7 +389,7 @@
: ''}" : ''}"
> >
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200"> <thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800"> <tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3" class="px-2.5 py-2 cursor-pointer select-none w-3"

View file

@ -4,6 +4,7 @@
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -366,22 +367,20 @@ class Pipe:
</div> </div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg"> <div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }} <CodeEditor
<CodeEditor bind:this={codeEditor}
bind:this={codeEditor} value={content}
value={content} lang="python"
lang="python" {boilerplate}
{boilerplate} onChange={(e) => {
onChange={(e) => { _content = e;
_content = e; }}
}} onSave={async () => {
onSave={async () => { if (formElement) {
if (formElement) { formElement.requestSubmit();
formElement.requestSubmit(); }
} }}
}} />
/>
{/await}
</div> </div>
<div class="pb-3 flex justify-between"> <div class="pb-3 flex justify-between">

View file

@ -1143,7 +1143,7 @@
<div class=" mb-2.5 py-0.5 w-full justify-between"> <div class=" mb-2.5 py-0.5 w-full justify-between">
<Tooltip <Tooltip
content={$i18n.t( content={$i18n.t(
'The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5' 'The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5'
)} )}
placement="top-start" placement="top-start"
className="inline-tooltip" className="inline-tooltip"

View file

@ -293,7 +293,7 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2"> <div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg"> <div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl /> <AccessControl bind:accessControl />
</div> </div>
</div> </div>

View file

@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte'; import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte'; import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs'; import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
export let saveSettings: Function; export let saveSettings: Function;
@ -47,7 +47,7 @@
}); });
</script> </script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} /> <AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
<form <form
class="flex flex-col h-full justify-between text-sm" class="flex flex-col h-full justify-between text-sm"

View file

@ -13,6 +13,8 @@
export let saveHandler: Function; export let saveHandler: Function;
let webSearchEngines = [ let webSearchEngines = [
'ollama_cloud',
'perplexity_search',
'searxng', 'searxng',
'yacy', 'yacy',
'google_pse', 'google_pse',
@ -130,7 +132,41 @@
</div> </div>
{#if webConfig.WEB_SEARCH_ENGINE !== ''} {#if webConfig.WEB_SEARCH_ENGINE !== ''}
{#if webConfig.WEB_SEARCH_ENGINE === 'searxng'} {#if webConfig.WEB_SEARCH_ENGINE === 'ollama_cloud'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Ollama Cloud API Key')}
</div>
<div class="flex w-full">
<div class="flex-1">
<SensitiveInput
placeholder={$i18n.t('Enter Ollama Cloud API Key')}
bind:value={webConfig.OLLAMA_CLOUD_WEB_SEARCH_API_KEY}
/>
</div>
</div>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'perplexity_search'}
<div class="mb-2.5 flex w-full flex-col">
<div>
<div class=" self-center text-xs font-medium mb-1">
{$i18n.t('Perplexity API Key')}
</div>
<div class="flex w-full">
<div class="flex-1">
<SensitiveInput
placeholder={$i18n.t('Enter Perplexity API Key')}
bind:value={webConfig.PERPLEXITY_API_KEY}
/>
</div>
</div>
</div>
</div>
{:else if webConfig.WEB_SEARCH_ENGINE === 'searxng'}
<div class="mb-2.5 flex w-full flex-col"> <div class="mb-2.5 flex w-full flex-col">
<div> <div>
<div class=" self-center text-xs font-medium mb-1"> <div class=" self-center text-xs font-medium mb-1">

View file

@ -222,7 +222,7 @@
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full"> <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"> <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200"> <thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-2 border-gray-100 dark:border-gray-800"> <tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
<th <th
scope="col" scope="col"
class="px-2.5 py-2 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"

View file

@ -43,6 +43,10 @@
let searchDebounceTimeout; let searchDebounceTimeout;
const searchHandler = async () => { const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) { if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout); clearTimeout(searchDebounceTimeout);
} }

View file

@ -14,6 +14,7 @@
import Drawer from '../common/Drawer.svelte'; import Drawer from '../common/Drawer.svelte';
import EllipsisVertical from '../icons/EllipsisVertical.svelte'; import EllipsisVertical from '../icons/EllipsisVertical.svelte';
import Thread from './Thread.svelte'; import Thread from './Thread.svelte';
import i18n from '$lib/i18n';
export let id = ''; export let id = '';
@ -252,6 +253,10 @@
{typingUsers} {typingUsers}
userSuggestions={true} userSuggestions={true}
channelSuggestions={true} channelSuggestions={true}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this channel.')
: $i18n.t('Type here...')}
{onChange} {onChange}
onSubmit={submitHandler} onSubmit={submitHandler}
{scrollToBottom} {scrollToBottom}

View file

@ -38,7 +38,7 @@
import MentionList from './MessageInput/MentionList.svelte'; import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte'; import Skeleton from '../chat/Messages/Skeleton.svelte';
export let placeholder = $i18n.t('Send a Message'); export let placeholder = $i18n.t('Type here...');
export let id = null; export let id = null;
export let chatInputElement; export let chatInputElement;
@ -53,6 +53,7 @@
export let scrollEnd = true; export let scrollEnd = true;
export let scrollToBottom: Function = () => {}; export let scrollToBottom: Function = () => {};
export let disabled = false;
export let acceptFiles = true; export let acceptFiles = true;
export let showFormattingToolbar = true; export let showFormattingToolbar = true;
@ -731,7 +732,9 @@
</div> </div>
</div> </div>
<div class=""> <div
class="{disabled ? 'opacity-50 pointer-events-none cursor-not-allowed' : ''} relative z-20"
>
{#if recording} {#if recording}
<VoiceRecording <VoiceRecording
bind:recording bind:recording
@ -766,6 +769,7 @@
}} }}
> >
<div <div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100" class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'} dir={$settings?.chatDirection ?? 'auto'}
> >
@ -836,6 +840,8 @@
bind:this={chatInputElement} bind:this={chatInputElement}
json={true} json={true}
messageInput={true} messageInput={true}
editable={!disabled}
{placeholder}
richText={$settings?.richTextInput ?? true} richText={$settings?.richTextInput ?? true}
showFormattingToolbar={$settings?.showFormattingToolbar ?? false} showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
@ -936,6 +942,7 @@
}} }}
> >
<button <button
id="input-menu-button"
class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden" class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
type="button" type="button"
aria-label="More" aria-label="More"

View file

@ -54,7 +54,7 @@
transition={flyAndScale} transition={flyAndScale}
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => { on:click={() => {
uploadFilesHandler(); uploadFilesHandler();
}} }}
@ -64,7 +64,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => { on:click={() => {
screenCaptureHandler(); screenCaptureHandler();
}} }}

View file

@ -94,6 +94,7 @@
<Message <Message
{message} {message}
{thread} {thread}
disabled={!channel?.write_access}
showUserProfile={messageIdx === 0 || showUserProfile={messageIdx === 0 ||
messageList.at(messageIdx - 1)?.user_id !== message.user_id || messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id} messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id}

View file

@ -40,6 +40,7 @@
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
export let thread = false; export let thread = false;
export let disabled = false;
export let onDelete: Function = () => {}; export let onDelete: Function = () => {};
export let onEdit: Function = () => {}; export let onEdit: Function = () => {};
@ -68,7 +69,7 @@
? 'pt-1.5 pb-0.5' ? 'pt-1.5 pb-0.5'
: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative" : ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
> >
{#if !edit} {#if !edit && !disabled}
<div <div
class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10" class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
> >

View file

@ -10,6 +10,7 @@
import Messages from './Messages.svelte'; import Messages from './Messages.svelte';
import { onDestroy, onMount, tick, getContext } from 'svelte'; import { onDestroy, onMount, tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import Spinner from '../common/Spinner.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -175,32 +176,42 @@
</div> </div>
<div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}> <div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}>
<Messages {#if messages !== null}
id={threadId} <Messages
{channel} id={threadId}
{messages} {channel}
{top} {messages}
thread={true} {top}
onLoad={async () => { thread={true}
const newMessages = await getChannelThreadMessages( onLoad={async () => {
localStorage.token, const newMessages = await getChannelThreadMessages(
channel.id, localStorage.token,
threadId, channel.id,
messages.length threadId,
); messages.length
);
messages = [...messages, ...newMessages]; messages = [...messages, ...newMessages];
if (newMessages.length < 50) { if (newMessages.length < 50) {
top = true; top = true;
return; return;
} }
}} }}
/> />
{:else}
<div class="w-full flex justify-center pt-5 pb-10">
<Spinner />
</div>
{/if}
<div class=" pb-[1rem] px-2.5 w-full"> <div class=" pb-[1rem] px-2.5 w-full">
<MessageInput <MessageInput
id={threadId} id={threadId}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this thread.')
: $i18n.t('Reply to thread...')}
typingUsersClassName="from-gray-50 dark:from-gray-850" typingUsersClassName="from-gray-50 dark:from-gray-850"
{typingUsers} {typingUsers}
userSuggestions={true} userSuggestions={true}

View file

@ -465,6 +465,15 @@
return; return;
} }
if (event.data.type === 'action:submit') {
console.debug(event.data.text);
if (prompt !== '') {
await tick();
submitPrompt(prompt);
}
}
// Replace with your iframe's origin // Replace with your iframe's origin
if (event.data.type === 'input:prompt') { if (event.data.type === 'input:prompt') {
console.debug(event.data.text); console.debug(event.data.text);
@ -477,15 +486,6 @@
} }
} }
if (event.data.type === 'action:submit') {
console.debug(event.data.text);
if (prompt !== '') {
await tick();
submitPrompt(prompt);
}
}
if (event.data.type === 'input:prompt:submit') { if (event.data.type === 'input:prompt:submit') {
console.debug(event.data.text); console.debug(event.data.text);
@ -1494,7 +1494,9 @@
const _files = JSON.parse(JSON.stringify(files)); const _files = JSON.parse(JSON.stringify(files));
chatFiles.push( chatFiles.push(
..._files.filter((item) => ['doc', 'text', 'file', 'collection'].includes(item.type)) ..._files.filter((item) =>
['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type)
)
); );
chatFiles = chatFiles.filter( chatFiles = chatFiles.filter(
// Remove duplicates // Remove duplicates

View file

@ -1030,9 +1030,10 @@
}} }}
> >
<div <div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700' ? 'border-dashed border-gray-100 dark:border-gray-800 hover:border-gray-200 focus-within:border-gray-200 hover:dark:border-gray-700 focus-within:dark:border-gray-700'
: ' border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100" : ' border-gray-100 dark:border-gray-850 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800'} transition px-1 bg-white/5 dark:bg-gray-500/5 backdrop-blur-sm dark:text-gray-100"
dir={$settings?.chatDirection ?? 'auto'} dir={$settings?.chatDirection ?? 'auto'}
> >
{#if atSelectedModel !== undefined} {#if atSelectedModel !== undefined}
@ -1414,6 +1415,9 @@
console.error('OneDrive Error:', error); console.error('OneDrive Error:', error);
} }
}} }}
onUpload={async (e) => {
dispatch('upload', e);
}}
onClose={async () => { onClose={async () => {
await tick(); await tick();
@ -1422,13 +1426,16 @@
}} }}
> >
<div <div
id="input-menu-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden" class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
> >
<PlusAlt className="size-5.5" /> <PlusAlt className="size-5.5" />
</div> </div>
</InputMenu> </InputMenu>
<div class="flex self-center w-[1px] h-4 mx-1 bg-gray-50 dark:bg-gray-800" /> <div
class="flex self-center w-[1px] h-4 mx-1 bg-gray-200/50 dark:bg-gray-800/50"
/>
{#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)} {#if showWebSearchButton || showImageGenerationButton || showCodeInterpreterButton || showToolsButton || (toggleFilters && toggleFilters.length > 0)}
<IntegrationsMenu <IntegrationsMenu
@ -1450,6 +1457,7 @@
}} }}
> >
<div <div
id="integration-menu-button"
class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden" class="bg-transparent hover:bg-gray-100 text-gray-700 dark:text-white dark:hover:bg-gray-800 rounded-full size-8 flex justify-center items-center outline-hidden focus:outline-hidden"
> >
<Component className="size-4.5" strokeWidth="1.5" /> <Component className="size-4.5" strokeWidth="1.5" />

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { settings } from '$lib/stores';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
export let show = false;
export let onSubmit: (e) => void;
let url = '';
const submitHandler = () => {
if (isValidHttpUrl(url)) {
onSubmit({
type: isYoutubeUrl(url) ? 'youtube' : 'web',
data: url
});
show = false;
url = '';
} else {
toast.error($i18n.t('Please enter a valid URL.'));
}
};
</script>
<Modal bind:show size="sm">
<div class="flex flex-col h-full">
<div class="flex justify-between items-center dark:text-gray-100 px-5 pt-4 pb-1.5">
<h1 class="text-lg font-medium self-center font-primary">
{$i18n.t('Attach Webpage')}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;
}}
>
<XMark className="size-5" />
</button>
</div>
<div class="px-5 pb-4">
<form
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="flex justify-between mb-0.5">
<label
for="webpage-url"
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Webpage URL')}</label
>
</div>
<input
id="webpage-url"
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
type="text"
bind:value={url}
placeholder={'https://example.com'}
autocomplete="off"
required
/>
<div class="flex justify-end gap-2 pt-3 bg-gray-50 dark:bg-gray-900/50">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-200 transition rounded-full"
type="submit"
>
{$i18n.t('Add')}
</button>
</div>
</form>
</div>
</div>
</Modal>

View file

@ -7,7 +7,7 @@
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
import { tick, getContext, onMount, onDestroy } from 'svelte'; import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils'; import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte'; import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Database from '$lib/components/icons/Database.svelte'; import Database from '$lib/components/icons/Database.svelte';
@ -36,7 +36,7 @@
: items), : items),
...(query.startsWith('http') ...(query.startsWith('http')
? query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be') ? isYoutubeUrl(query)
? [{ type: 'youtube', name: query, description: query }] ? [{ type: 'youtube', name: query, description: query }]
: [ : [
{ {
@ -228,7 +228,7 @@
{/if} {/if}
{/each} {/each}
{#if query.startsWith('https://www.youtube.com') || query.startsWith('https://youtu.be')} {#if isYoutubeUrl(query)}
<button <button
class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button" class="px-2 py-1 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button"
type="button" type="button"

View file

@ -5,6 +5,8 @@
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile, knowledge, chats } from '$lib/stores'; import { config, user, tools as _tools, mobile, knowledge, chats } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { createPicker } from '$lib/utils/google-drive-picker'; import { createPicker } from '$lib/utils/google-drive-picker';
import Dropdown from '$lib/components/common/Dropdown.svelte'; import Dropdown from '$lib/components/common/Dropdown.svelte';
@ -24,6 +26,8 @@
import Chats from './InputMenu/Chats.svelte'; import Chats from './InputMenu/Chats.svelte';
import Notes from './InputMenu/Notes.svelte'; import Notes from './InputMenu/Notes.svelte';
import Knowledge from './InputMenu/Knowledge.svelte'; import Knowledge from './InputMenu/Knowledge.svelte';
import AttachWebpageModal from './AttachWebpageModal.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -39,11 +43,14 @@
export let uploadGoogleDriveHandler: Function; export let uploadGoogleDriveHandler: Function;
export let uploadOneDriveHandler: Function; export let uploadOneDriveHandler: Function;
export let onUpload: Function;
export let onClose: Function; export let onClose: Function;
let show = false; let show = false;
let tab = ''; let tab = '';
let showAttachWebpageModal = false;
let fileUploadEnabled = true; let fileUploadEnabled = true;
$: fileUploadEnabled = $: fileUploadEnabled =
fileUploadCapableModels.length === selectedModels.length && fileUploadCapableModels.length === selectedModels.length &&
@ -62,6 +69,16 @@
} }
}; };
const init = async () => {
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token));
}
};
$: if (show) {
init();
}
const onSelect = (item) => { const onSelect = (item) => {
if (files.find((f) => f.id === item.id)) { if (files.find((f) => f.id === item.id)) {
return; return;
@ -78,6 +95,13 @@
}; };
</script> </script>
<AttachWebpageModal
bind:show={showAttachWebpageModal}
onSubmit={(e) => {
onUpload(e);
}}
/>
<!-- Hidden file input used to open the camera on mobile --> <!-- Hidden file input used to open the camera on mobile -->
<input <input
id="camera-input" id="camera-input"
@ -120,7 +144,7 @@
className="w-full" className="w-full"
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50' ? 'opacity-50'
: ''}" : ''}"
on:click={() => { on:click={() => {
@ -144,7 +168,7 @@
className="w-full" className="w-full"
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50' ? 'opacity-50'
: ''}" : ''}"
on:click={() => { on:click={() => {
@ -166,6 +190,27 @@
</DropdownMenu.Item> </DropdownMenu.Item>
</Tooltip> </Tooltip>
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
if (fileUploadEnabled) {
showAttachWebpageModal = true;
}
}}
>
<GlobeAlt />
<div class="line-clamp-1">{$i18n.t('Attach Webpage')}</div>
</DropdownMenu.Item>
</Tooltip>
{#if $config?.features?.enable_notes ?? false} {#if $config?.features?.enable_notes ?? false}
<Tooltip <Tooltip
content={fileUploadCapableModels.length !== selectedModels.length content={fileUploadCapableModels.length !== selectedModels.length
@ -176,7 +221,7 @@
className="w-full" className="w-full"
> >
<button <button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50' ? 'opacity-50'
: ''}" : ''}"
on:click={() => { on:click={() => {
@ -198,35 +243,37 @@
</Tooltip> </Tooltip>
{/if} {/if}
<Tooltip {#if ($knowledge ?? []).length > 0}
content={fileUploadCapableModels.length !== selectedModels.length <Tooltip
? $i18n.t('Model(s) do not support file upload') content={fileUploadCapableModels.length !== selectedModels.length
: !fileUploadEnabled ? $i18n.t('Model(s) do not support file upload')
? $i18n.t('You do not have permission to upload files.') : !fileUploadEnabled
: ''} ? $i18n.t('You do not have permission to upload files.')
className="w-full" : ''}
> className="w-full"
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'knowledge';
}}
> >
<Database /> <button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'knowledge';
}}
>
<Database />
<div class="flex items-center w-full justify-between"> <div class="flex items-center w-full justify-between">
<div class=" line-clamp-1"> <div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')} {$i18n.t('Attach Knowledge')}
</div> </div>
<div class="text-gray-500"> <div class="text-gray-500">
<ChevronRight /> <ChevronRight />
</div>
</div> </div>
</div> </button>
</button> </Tooltip>
</Tooltip> {/if}
{#if ($chats ?? []).length > 0} {#if ($chats ?? []).length > 0}
<Tooltip <Tooltip
@ -238,7 +285,7 @@
className="w-full" className="w-full"
> >
<button <button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl {!fileUploadEnabled class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50' ? 'opacity-50'
: ''}" : ''}"
on:click={() => { on:click={() => {
@ -263,12 +310,12 @@
{#if fileUploadEnabled} {#if fileUploadEnabled}
{#if $config?.features?.enable_google_drive_integration} {#if $config?.features?.enable_google_drive_integration}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
on:click={() => { on:click={() => {
uploadGoogleDriveHandler(); uploadGoogleDriveHandler();
}} }}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-4">
<path <path
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
fill="#0066da" fill="#0066da"
@ -299,140 +346,117 @@
{/if} {/if}
{#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)} {#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)}
<DropdownMenu.Sub> <button
<DropdownMenu.SubTrigger class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full" ? 'opacity-50'
: ''}"
on:click={() => {
tab = 'microsoft_onedrive';
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
class="size-4"
fill="none"
> >
<svg <mask
xmlns="http://www.w3.org/2000/svg" id="mask0_87_7796"
viewBox="0 0 32 32" style="mask-type:alpha"
class="w-5 h-5" maskUnits="userSpaceOnUse"
fill="none" x="0"
y="6"
width="32"
height="20"
> >
<mask <path
id="mask0_87_7796" d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
style="mask-type:alpha" fill="#C4C4C4"
maskUnits="userSpaceOnUse" />
x="0" </mask>
y="6" <g mask="url(#mask0_87_7796)">
width="32" <path
height="20" d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
fill="url(#paint0_linear_87_7796)"
/>
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
> >
<path <stop stop-color="#2086B8" />
d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z" <stop offset="1" stop-color="#46D3F6" />
fill="#C4C4C4" </linearGradient>
/> <linearGradient
</mask> id="paint1_linear_87_7796"
<g mask="url(#mask0_87_7796)"> x1="23.8302"
<path y1="19.6668"
d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z" x2="30.2108"
fill="url(#paint0_linear_87_7796)" y2="15.2082"
/> gradientUnits="userSpaceOnUse"
<path
d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
fill="url(#paint1_linear_87_7796)"
/>
<path
d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
fill="url(#paint2_linear_87_7796)"
/>
<path
d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
fill="url(#paint3_linear_87_7796)"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_87_7796"
x1="4.42591"
y1="24.6668"
x2="27.2309"
y2="23.2764"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#2086B8" />
<stop offset="1" stop-color="#46D3F6" />
</linearGradient>
<linearGradient
id="paint1_linear_87_7796"
x1="23.8302"
y1="19.6668"
x2="30.2108"
y2="15.2082"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#1694DB" />
<stop offset="1" stop-color="#62C3FE" />
</linearGradient>
<linearGradient
id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
side={$mobile ? 'bottom' : 'right'}
sideOffset={$mobile ? 5 : 0}
alignOffset={$mobile ? 0 : -8}
>
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
uploadOneDriveHandler('personal');
}}
> >
<div class="flex flex-col"> <stop stop-color="#1694DB" />
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div> <stop offset="1" stop-color="#62C3FE" />
</div> </linearGradient>
</DropdownMenu.Item> <linearGradient
{/if} id="paint2_linear_87_7796"
x1="8.51037"
y1="7.33333"
x2="23.3335"
y2="15.9348"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#0D3D78" />
<stop offset="1" stop-color="#063B83" />
</linearGradient>
<linearGradient
id="paint3_linear_87_7796"
x1="-0.340429"
y1="19.9998"
x2="14.5634"
y2="14.4649"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#16589B" />
<stop offset="1" stop-color="#1464B7" />
</linearGradient>
</defs>
</svg>
{#if $config?.features?.enable_onedrive_business} <div class="flex items-center w-full justify-between">
<DropdownMenu.Item <div class=" line-clamp-1">
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl" {$i18n.t('Microsoft OneDrive')}
on:click={() => { </div>
uploadOneDriveHandler('organizations');
}} <div class="text-gray-500">
> <ChevronRight />
<div class="flex flex-col"> </div>
<div class="line-clamp-1"> </div>
{$i18n.t('Microsoft OneDrive (work/school)')} </button>
</div>
<div class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if} {/if}
{/if} {/if}
</div> </div>
{:else if tab === 'knowledge'} {:else if tab === 'knowledge'}
<div in:fly={{ x: 20, duration: 150 }}> <div in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@ -451,7 +475,7 @@
{:else if tab === 'notes'} {:else if tab === 'notes'}
<div in:fly={{ x: 20, duration: 150 }}> <div in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@ -470,7 +494,7 @@
{:else if tab === 'chats'} {:else if tab === 'chats'}
<div in:fly={{ x: 20, duration: 150 }}> <div in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@ -486,6 +510,52 @@
<Chats {onSelect} /> <Chats {onSelect} />
</div> </div>
{:else if tab === 'microsoft_onedrive'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Microsoft OneDrive')}
</div>
</div>
</button>
{#if $config?.features?.enable_onedrive_personal}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('personal');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</div>
</DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_business}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl text-left"
on:click={() => {
uploadOneDriveHandler('organizations');
}}
>
<div class="flex flex-col">
<div class="line-clamp-1">
{$i18n.t('Microsoft OneDrive (work/school)')}
</div>
<div class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div>
</DropdownMenu.Item>
{/if}
</div>
{/if} {/if}
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>

View file

@ -31,7 +31,7 @@
const getItemsPage = async () => { const getItemsPage = async () => {
itemsLoading = true; itemsLoading = true;
let res = await getChatList(localStorage.token, page).catch(() => { let res = await getChatList(localStorage.token, page, true).catch(() => {
return []; return [];
}); });

View file

@ -19,6 +19,9 @@
import Terminal from '$lib/components/icons/Terminal.svelte'; import Terminal from '$lib/components/icons/Terminal.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ValvesModal from '$lib/components/workspace/common/ValvesModal.svelte';
import { getOAuthClientAuthorizationUrl } from '$lib/apis/configs';
import { partition } from 'd3-hierarchy';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -43,6 +46,11 @@
let show = false; let show = false;
let tab = ''; let tab = '';
let showValvesModal = false;
let selectedValvesType = 'tool';
let selectedValvesItemId = null;
let tools = null; let tools = null;
$: if (show) { $: if (show) {
@ -64,7 +72,8 @@
a[tool.id] = { a[tool.id] = {
name: tool.name, name: tool.name,
description: tool.meta.description, description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id) enabled: selectedToolIds.includes(tool.id),
...tool
}; };
return a; return a;
}, {}); }, {});
@ -87,6 +96,16 @@
}; };
</script> </script>
<ValvesModal
bind:show={showValvesModal}
userValves={true}
type={selectedValvesType}
id={selectedValvesItemId ?? null}
on:save={async () => {
await tick();
}}
/>
<Dropdown <Dropdown
bind:show bind:show
on:change={(e) => { on:change={(e) => {
@ -112,7 +131,7 @@
{#if tools} {#if tools}
{#if Object.keys(tools).length > 0} {#if Object.keys(tools).length > 0}
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
tab = 'tools'; tab = 'tools';
}} }}
@ -141,7 +160,7 @@
{#each toggleFilters.sort( (a, b) => a.name.localeCompare( b.name, undefined, { sensitivity: 'base' } ) ) as filter, filterIdx (filter.id)} {#each toggleFilters.sort( (a, b) => a.name.localeCompare( b.name, undefined, { sensitivity: 'base' } ) ) as filter, filterIdx (filter.id)}
<Tooltip content={filter?.description} placement="top-start"> <Tooltip content={filter?.description} placement="top-start">
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
if (selectedFilterIds.includes(filter.id)) { if (selectedFilterIds.includes(filter.id)) {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id); selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id);
@ -190,7 +209,7 @@
{#if showWebSearchButton} {#if showWebSearchButton}
<Tooltip content={$i18n.t('Search the internet')} placement="top-start"> <Tooltip content={$i18n.t('Search the internet')} placement="top-start">
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
webSearchEnabled = !webSearchEnabled; webSearchEnabled = !webSearchEnabled;
}} }}
@ -221,7 +240,7 @@
{#if showImageGenerationButton} {#if showImageGenerationButton}
<Tooltip content={$i18n.t('Generate an image')} placement="top-start"> <Tooltip content={$i18n.t('Generate an image')} placement="top-start">
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
imageGenerationEnabled = !imageGenerationEnabled; imageGenerationEnabled = !imageGenerationEnabled;
}} }}
@ -252,7 +271,7 @@
{#if showCodeInterpreterButton} {#if showCodeInterpreterButton}
<Tooltip content={$i18n.t('Execute code for analysis')} placement="top-start"> <Tooltip content={$i18n.t('Execute code for analysis')} placement="top-start">
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
aria-pressed={codeInterpreterEnabled} aria-pressed={codeInterpreterEnabled}
aria-label={codeInterpreterEnabled aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter') ? $i18n.t('Disable Code Interpreter')
@ -287,7 +306,7 @@
{:else if tab === 'tools' && tools} {:else if tab === 'tools' && tools}
<div in:fly={{ x: 20, duration: 150 }}> <div in:fly={{ x: 20, duration: 150 }}>
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={() => {
tab = ''; tab = '';
}} }}
@ -304,11 +323,25 @@
{#each Object.keys(tools) as toolId} {#each Object.keys(tools) as toolId}
<button <button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800" class="relative flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => { on:click={(e) => {
tools[toolId].enabled = !tools[toolId].enabled; if (!(tools[toolId]?.authenticated ?? true)) {
e.preventDefault();
let parts = toolId.split(':');
let serverId = parts?.at(-1) ?? toolId;
const authUrl = getOAuthClientAuthorizationUrl(serverId, 'mcp');
window.open(authUrl, '_blank', 'noopener');
} else {
tools[toolId].enabled = !tools[toolId].enabled;
}
}} }}
> >
{#if !(tools[toolId]?.authenticated ?? true)}
<!-- make it slighly darker and not clickable -->
<div class="absolute inset-0 opacity-50 rounded-xl cursor-not-allowed z-10" />
{/if}
<div class="flex-1 truncate"> <div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center"> <div class="flex flex-1 gap-2 items-center">
<Tooltip content={tools[toolId]?.name ?? ''} placement="top"> <Tooltip content={tools[toolId]?.name ?? ''} placement="top">
@ -322,6 +355,44 @@
</div> </div>
</div> </div>
{#if tools[toolId]?.has_user_valves}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedValvesType = 'tool';
selectedValvesItemId = toolId;
showValvesModal = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
<div class=" shrink-0"> <div class=" shrink-0">
<Switch <Switch
state={tools[toolId].enabled} state={tools[toolId].enabled}

View file

@ -1,16 +1,18 @@
<script lang="ts"> <script lang="ts">
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import { toast } from 'svelte-sonner';
import { getContext, onMount, tick, onDestroy } from 'svelte'; import { getContext, onMount, tick, onDestroy } from 'svelte';
import { config } from '$lib/stores';
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import { executeCode } from '$lib/apis/utils';
import { copyToClipboard, renderMermaidDiagram } from '$lib/utils'; import { copyToClipboard, renderMermaidDiagram } from '$lib/utils';
import 'highlight.js/styles/github-dark.min.css'; import 'highlight.js/styles/github-dark.min.css';
import PyodideWorker from '$lib/workers/pyodide.worker?worker'; import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte'; import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
import { config } from '$lib/stores';
import { executeCode } from '$lib/apis/utils';
import { toast } from 'svelte-sonner';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronUpDown from '$lib/components/icons/ChevronUpDown.svelte'; import ChevronUpDown from '$lib/components/icons/ChevronUpDown.svelte';
import CommandLine from '$lib/components/icons/CommandLine.svelte'; import CommandLine from '$lib/components/icons/CommandLine.svelte';
@ -480,19 +482,17 @@
{#if !collapsed} {#if !collapsed}
{#if edit} {#if edit}
{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }} <CodeEditor
<CodeEditor value={code}
value={code} {id}
{id} {lang}
{lang} onSave={() => {
onSave={() => { saveCode();
saveCode(); }}
}} onChange={(value) => {
onChange={(value) => { _code = value;
_code = value; }}
}} />
/>
{/await}
{:else} {:else}
<pre <pre
class=" hljs p-4 px-5 overflow-x-auto" class=" hljs p-4 px-5 overflow-x-auto"

View file

@ -286,9 +286,11 @@
} }
}; };
onMount(async () => { const setOllamaVersion = async () => {
ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false); ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
};
onMount(async () => {
if (items) { if (items) {
tags = items tags = items
.filter((item) => !(item.model?.info?.meta?.hidden ?? false)) .filter((item) => !(item.model?.info?.meta?.hidden ?? false))
@ -300,6 +302,10 @@
} }
}); });
$: if (show) {
setOllamaVersion();
}
const cancelModelPullHandler = async (model: string) => { const cancelModelPullHandler = async (model: string) => {
const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model]; const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
if (abortController) { if (abortController) {

View file

@ -45,7 +45,7 @@
</script> </script>
<div id="tab-about" class="flex flex-col h-full justify-between space-y-3 text-sm mb-6"> <div id="tab-about" class="flex flex-col h-full justify-between space-y-3 text-sm mb-6">
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div> <div>
<div class=" mb-2.5 text-sm font-medium flex space-x-2 items-center"> <div class=" mb-2.5 text-sm font-medium flex space-x-2 items-center">
<div> <div>

View file

@ -117,7 +117,7 @@
</script> </script>
<div id="tab-account" class="flex flex-col h-full justify-between text-sm"> <div id="tab-account" class="flex flex-col h-full justify-between text-sm">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<input <input
id="profile-image-input" id="profile-image-input"
bind:this={profileImageInputElement} bind:this={profileImageInputElement}

View file

@ -175,7 +175,7 @@
dispatch('save'); dispatch('save');
}} }}
> >
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div> <div>
<div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div> <div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div>

View file

@ -117,7 +117,7 @@
<ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} /> <ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
<div id="tab-chats" class="flex flex-col h-full justify-between space-y-3 text-sm"> <div id="tab-chats" class="flex flex-col h-full justify-between space-y-3 text-sm">
<div class=" space-y-2 overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" space-y-2 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class="flex flex-col"> <div class="flex flex-col">
<input <input
id="chat-import-input" id="chat-import-input"

View file

@ -191,7 +191,7 @@
</script> </script>
<div class="flex flex-col h-full justify-between text-sm" id="tab-general"> <div class="flex flex-col h-full justify-between text-sm" id="tab-general">
<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" overflow-y-scroll max-h-[28rem] md:max-h-full">
<div class=""> <div class="">
<div class=" mb-1 text-sm font-medium">{$i18n.t('WebUI Settings')}</div> <div class=" mb-1 text-sm font-medium">{$i18n.t('WebUI Settings')}</div>
@ -277,7 +277,7 @@
</div> </div>
{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)} {#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
<hr class="border-gray-50 dark:border-gray-850 my-3" /> <hr class="border-gray-100/50 dark:border-gray-850 my-3" />
<div> <div>
<div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div> <div class=" my-2.5 text-sm font-medium">{$i18n.t('System Prompt')}</div>
@ -285,8 +285,8 @@
bind:value={system} bind:value={system}
className={'w-full text-sm outline-hidden resize-vertical' + className={'w-full text-sm outline-hidden resize-vertical' +
($settings.highContrastMode ($settings.highContrastMode
? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-850 text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden' ? ' p-2.5 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-transparent text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 overflow-y-hidden'
: ' bg-white dark:text-gray-300 dark:bg-gray-900')} : ' dark:text-gray-300 ')}
rows="4" rows="4"
placeholder={$i18n.t('Enter system prompt here')} placeholder={$i18n.t('Enter system prompt here')}
/> />

View file

@ -306,7 +306,7 @@
}} }}
/> />
<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class=" space-y-3 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div> <div>
<h1 class=" mb-2 text-sm font-medium">{$i18n.t('UI')}</h1> <h1 class=" mb-2 text-sm font-medium">{$i18n.t('UI')}</h1>

View file

@ -30,7 +30,7 @@
dispatch('save'); dispatch('save');
}} }}
> >
<div class="py-1 overflow-y-scroll max-h-[28rem] lg:max-h-full"> <div class="py-1 overflow-y-scroll max-h-[28rem] md:max-h-full">
<div> <div>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<Tooltip <Tooltip

View file

@ -14,7 +14,7 @@
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import Connection from './Tools/Connection.svelte'; import Connection from './Tools/Connection.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte'; import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let saveSettings: Function; export let saveSettings: Function;
@ -52,7 +52,7 @@
}); });
</script> </script>
<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} direct /> <AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} direct />
<form <form
id="tab-tools" id="tab-tools"

View file

@ -6,7 +6,7 @@
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import AddServerModal from '$lib/components/AddServerModal.svelte'; import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
export let onDelete = () => {}; export let onDelete = () => {};
export let onSubmit = () => {}; export let onSubmit = () => {};
@ -18,7 +18,7 @@
let showDeleteConfirmDialog = false; let showDeleteConfirmDialog = false;
</script> </script>
<AddServerModal <AddToolServerModal
edit edit
{direct} {direct}
bind:show={showConfigModal} bind:show={showConfigModal}
@ -48,15 +48,12 @@
})} })}
placement="top-start" placement="top-start"
> >
{#if !(connection?.config?.enable ?? true)}
<div
class="absolute top-0 bottom-0 left-0 right-0 opacity-60 bg-white dark:bg-gray-900 z-10"
></div>
{/if}
<div class="flex w-full"> <div class="flex w-full">
<div class="flex-1 relative"> <div class="flex-1 relative">
<input <input
class=" outline-hidden w-full bg-transparent" class=" outline-hidden w-full bg-transparent {!(connection?.config?.enable ?? true)
? 'opacity-50'
: ''}"
placeholder={$i18n.t('API Base URL')} placeholder={$i18n.t('API Base URL')}
bind:value={connection.url} bind:value={connection.url}
autocomplete="off" autocomplete="off"

View file

@ -569,9 +569,9 @@
}); });
</script> </script>
<Modal size="lg" bind:show> <Modal size="xl" bind:show>
<div class="text-gray-700 dark:text-gray-100 mx-1"> <div class="text-gray-700 dark:text-gray-100 mx-1">
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1"> <div class=" flex justify-between dark:text-gray-300 px-4 md:px-4.5 pt-4.5 pb-0.5 md:pb-2.5">
<div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div> <div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div>
<button <button
aria-label={$i18n.t('Close settings modal')} aria-label={$i18n.t('Close settings modal')}
@ -584,13 +584,16 @@
</button> </button>
</div> </div>
<div class="flex flex-col md:flex-row w-full px-4 pt-1 pb-4 md:space-x-4"> <div class="flex flex-col md:flex-row w-full pt-1 pb-4">
<div <div
role="tablist" role="tablist"
id="settings-tabs-container" id="settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 md:gap-1 md:flex-col flex-1 md:flex-none md:w-40 md:min-h-[32rem] md:max-h-[32rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1" class="tabs flex flex-row overflow-x-auto gap-2.5 mx-3 md:pr-4 md:gap-1 md:flex-col flex-1 md:flex-none md:w-50 md:min-h-[36rem] md:max-h-[36rem] dark:text-gray-200 text-sm text-left mb-1 md:mb-0 -translate-y-1"
> >
<div class="hidden md:flex w-full rounded-xl -mb-1 px-[0.5px] gap-2" id="settings-search"> <div
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 mb-1.5"
id="settings-search"
>
<div class="self-center rounded-l-xl bg-transparent"> <div class="self-center rounded-l-xl bg-transparent">
<Search <Search
className="size-3.5" className="size-3.5"
@ -599,7 +602,7 @@
</div> </div>
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label> <label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
<input <input
class={`w-full py-1.5 text-sm bg-transparent dark:text-gray-300 outline-hidden class={`w-full py-1 text-sm bg-transparent dark:text-gray-300 outline-hidden
${($settings?.highContrastMode ?? false) ? 'placeholder-gray-800' : ''}`} ${($settings?.highContrastMode ?? false) ? 'placeholder-gray-800' : ''}`}
bind:value={search} bind:value={search}
id="search-input-settings-modal" id="search-input-settings-modal"
@ -614,7 +617,7 @@
role="tab" role="tab"
aria-controls="tab-general" aria-controls="tab-general"
aria-selected={selectedTab === 'general'} aria-selected={selectedTab === 'general'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'general' selectedTab === 'general'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -638,7 +641,7 @@
role="tab" role="tab"
aria-controls="tab-interface" aria-controls="tab-interface"
aria-selected={selectedTab === 'interface'} aria-selected={selectedTab === 'interface'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'interface' selectedTab === 'interface'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -663,7 +666,7 @@
role="tab" role="tab"
aria-controls="tab-connections" aria-controls="tab-connections"
aria-selected={selectedTab === 'connections'} aria-selected={selectedTab === 'connections'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'connections' selectedTab === 'connections'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -689,7 +692,7 @@
role="tab" role="tab"
aria-controls="tab-tools" aria-controls="tab-tools"
aria-selected={selectedTab === 'tools'} aria-selected={selectedTab === 'tools'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'tools' selectedTab === 'tools'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -714,7 +717,7 @@
role="tab" role="tab"
aria-controls="tab-personalization" aria-controls="tab-personalization"
aria-selected={selectedTab === 'personalization'} aria-selected={selectedTab === 'personalization'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'personalization' selectedTab === 'personalization'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -738,7 +741,7 @@
role="tab" role="tab"
aria-controls="tab-audio" aria-controls="tab-audio"
aria-selected={selectedTab === 'audio'} aria-selected={selectedTab === 'audio'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'audio' selectedTab === 'audio'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -762,7 +765,7 @@
role="tab" role="tab"
aria-controls="tab-data-controls" aria-controls="tab-data-controls"
aria-selected={selectedTab === 'data_controls'} aria-selected={selectedTab === 'data_controls'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'data_controls' selectedTab === 'data_controls'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -786,7 +789,7 @@
role="tab" role="tab"
aria-controls="tab-account" aria-controls="tab-account"
aria-selected={selectedTab === 'account'} aria-selected={selectedTab === 'account'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'account' selectedTab === 'account'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -810,7 +813,7 @@
role="tab" role="tab"
aria-controls="tab-about" aria-controls="tab-about"
aria-selected={selectedTab === 'about'} aria-selected={selectedTab === 'about'}
class={`px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none flex text-left transition class={`px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none flex text-left transition
${ ${
selectedTab === 'about' selectedTab === 'about'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
@ -839,7 +842,7 @@
{#if $user?.role === 'admin'} {#if $user?.role === 'admin'}
<a <a
href="/admin/settings" href="/admin/settings"
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 md:flex-none md:mt-auto flex text-left transition {$settings?.highContrastMode class="px-0.5 md:px-2.5 py-1 min-w-fit rounded-xl flex-1 md:flex-none md:mt-auto flex text-left transition {$settings?.highContrastMode
? 'hover:bg-gray-200 dark:hover:bg-gray-800' ? 'hover:bg-gray-200 dark:hover:bg-gray-800'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
on:click={async (e) => { on:click={async (e) => {
@ -855,7 +858,7 @@
</a> </a>
{/if} {/if}
</div> </div>
<div class="flex-1 md:min-h-[32rem] max-h-[32rem]"> <div class="flex-1 px-3.5 md:pl-0 md:pr-4.5 md:min-h-[36rem] max-h-[36rem]">
{#if selectedTab === 'general'} {#if selectedTab === 'general'}
<General <General
{getModels} {getModels}

View file

@ -38,6 +38,8 @@
import CodeBlock from '../chat/Messages/CodeBlock.svelte'; import CodeBlock from '../chat/Messages/CodeBlock.svelte';
import Markdown from '../chat/Messages/Markdown.svelte'; import Markdown from '../chat/Messages/Markdown.svelte';
import Image from './Image.svelte'; import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
import { settings } from '$lib/stores';
export let open = false; export let open = false;
@ -87,54 +89,56 @@
</script> </script>
<div {id} class={className}> <div {id} class={className}>
{#if title !== null} {#if attributes?.type === 'tool_calls'}
<!-- svelte-ignore a11y-no-static-element-interactions --> {@const args = decode(attributes?.arguments)}
<!-- svelte-ignore a11y-click-events-have-key-events --> {@const result = decode(attributes?.result ?? '')}
<div {@const files = parseJSONString(decode(attributes?.files ?? ''))}
class="{buttonClassName} cursor-pointer" {@const embeds = parseJSONString(decode(attributes?.embeds ?? ''))}
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class=""> {#if embeds && Array.isArray(embeds) && embeds.length > 0}
{#if attributes?.type === 'reasoning'} <div class="py-1 w-full cursor-pointer">
{#if attributes?.done === 'true' && attributes?.duration} <div class=" w-full text-xs text-gray-500">
{#if attributes.duration < 1} <div class="">
{$i18n.t('Thought for less than a second')} {attributes.name}
{:else if attributes.duration < 60} </div>
{$i18n.t('Thought for {{DURATION}} seconds', { </div>
DURATION: attributes.duration
})} {#each embeds as embed, idx}
{:else} <div class="my-2" id={`${collapsibleId}-tool-calls-${attributes?.id}-embed-${idx}`}>
{$i18n.t('Thought for {{DURATION}}', { <FullHeightIframe
DURATION: dayjs.duration(attributes.duration, 'seconds').humanize() src={embed}
})} {args}
{/if} allowScripts={true}
{:else} allowForms={true}
{$i18n.t('Thinking...')} allowSameOrigin={true}
{/if} allowPopups={true}
{:else if attributes?.type === 'code_interpreter'} />
{#if attributes?.done === 'true'} </div>
{$i18n.t('Analyzed')} {/each}
{:else} </div>
{$i18n.t('Analyzing...')} {:else}
{/if} <div
{:else if attributes?.type === 'tool_calls'} class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if attributes?.done === 'true'} {#if attributes?.done === 'true'}
<Markdown <Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}`} id={`${collapsibleId}-tool-calls-${attributes?.id}`}
@ -150,99 +154,50 @@
})} })}
/> />
{/if} {/if}
{:else} </div>
{title}
{/if}
</div>
<div class="flex self-center translate-y-[1px]"> <div class="flex self-center translate-y-[1px]">
{#if open} {#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" /> <ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else} {:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" /> <ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if} {/if}
</div>
</div> </div>
</div> </div>
</div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:click={(e) => {
e.stopPropagation();
}}
on:pointerup={(e) => {
if (!disabled) {
open = !open;
}
}}
>
<div>
<div class="flex items-start justify-between">
<slot />
{#if chevron} {#if !grow}
<div class="flex self-start translate-y-1"> {#if open && !hide}
{#if open} <div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<ChevronUp strokeWidth="3.5" className="size-3.5" /> {#if attributes?.type === 'tool_calls'}
{:else} {#if attributes?.done === 'true'}
<ChevronDown strokeWidth="3.5" className="size-3.5" /> <Markdown
{/if} id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
</div> content={`> \`\`\`json
{/if}
</div>
{#if grow}
{#if open && !hide}
<div
transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<slot name="content" />
</div>
{/if}
{/if}
</div>
</div>
{/if}
{#if attributes?.type === 'tool_calls'}
{@const args = decode(attributes?.arguments)}
{@const result = decode(attributes?.result ?? '')}
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if attributes?.type === 'tool_calls'}
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)} > ${formatJSONString(args)}
> ${formatJSONString(result)} > ${formatJSONString(result)}
> \`\`\``} > \`\`\``}
/> />
{:else} {:else}
<Markdown <Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`} id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json content={`> \`\`\`json
> ${formatJSONString(args)} > ${formatJSONString(args)}
> \`\`\``} > \`\`\``}
/> />
{/if}
{:else}
<slot name="content" />
{/if} {/if}
{:else} </div>
<slot name="content" /> {/if}
{/if}
</div>
{/if} {/if}
{/if}
{#if attributes?.done === 'true'} {#if attributes?.done === 'true'}
{#if typeof files === 'object'} {#if typeof files === 'object'}
{#each files ?? [] as file, idx} {#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')} {#if file.startsWith('data:image/')}
<Image <Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`} id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
@ -250,15 +205,131 @@
alt="Image" alt="Image"
/> />
{/if} {/if}
{/each} {:else if typeof file === 'object'}
{/if} {#if file.type === 'image' && file.url}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/each}
{/if} {/if}
{/if} {/if}
{:else if !grow} {:else}
{#if open && !hide} {#if title !== null}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}> <!-- svelte-ignore a11y-no-static-element-interactions -->
<slot name="content" /> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if attributes?.type === 'reasoning'}
{#if attributes?.done === 'true' && attributes?.duration}
{#if attributes.duration < 1}
{$i18n.t('Thought for less than a second')}
{:else if attributes.duration < 60}
{$i18n.t('Thought for {{DURATION}} seconds', {
DURATION: attributes.duration
})}
{:else}
{$i18n.t('Thought for {{DURATION}}', {
DURATION: dayjs.duration(attributes.duration, 'seconds').humanize()
})}
{/if}
{:else}
{$i18n.t('Thinking...')}
{/if}
{:else if attributes?.type === 'code_interpreter'}
{#if attributes?.done === 'true'}
{$i18n.t('Analyzed')}
{:else}
{$i18n.t('Analyzing...')}
{/if}
{:else}
{title}
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div> </div>
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="{buttonClassName} cursor-pointer"
on:click={(e) => {
e.stopPropagation();
}}
on:pointerup={(e) => {
if (!disabled) {
open = !open;
}
}}
>
<div>
<div class="flex items-start justify-between">
<slot />
{#if chevron}
<div class="flex self-start translate-y-1">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
{/if}
</div>
{#if grow}
{#if open && !hide}
<div
transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}
on:pointerup={(e) => {
e.stopPropagation();
}}
>
<slot name="content" />
</div>
{/if}
{/if}
</div>
</div>
{/if}
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" />
</div>
{/if}
{/if} {/if}
{/if} {/if}
</div> </div>

View file

@ -139,7 +139,8 @@
> >
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="w-full group rounded-lg relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 transition {buttonClassName}" id="sidebar-folder-button"
class=" w-full group rounded-xl relative flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-900 transition {buttonClassName}"
> >
<button class="w-full py-1.5 pl-2 flex items-center gap-1.5 text-xs font-medium"> <button class="w-full py-1.5 pl-2 flex items-center gap-1.5 text-xs font-medium">
{#if chevron} {#if chevron}

View file

@ -0,0 +1,199 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte';
// Props
export let src: string | null = null; // URL or raw HTML (auto-detected)
export let title = 'Embedded Content';
export let initialHeight: number | null = null; // initial height in px, null = auto
export let args = null;
export let allowScripts = true;
export let allowForms = false;
export let allowSameOrigin = false; // set to true only when you trust the content
export let allowPopups = false;
export let allowDownloads = true;
export let referrerPolicy: HTMLIFrameElement['referrerPolicy'] =
'strict-origin-when-cross-origin';
export let allowFullscreen = true;
let iframe: HTMLIFrameElement | null = null;
let iframeSrc: string | null = null;
let iframeDoc: string | null = null;
// Derived: build sandbox attribute from flags
$: sandbox =
[
allowScripts && 'allow-scripts',
allowForms && 'allow-forms',
allowSameOrigin && 'allow-same-origin',
allowPopups && 'allow-popups',
allowDownloads && 'allow-downloads'
]
.filter(Boolean)
.join(' ') || undefined;
// Detect URL vs raw HTML and prep src/srcdoc
$: isUrl = typeof src === 'string' && /^(https?:)?\/\//i.test(src);
$: if (src) {
setIframeSrc();
}
const setIframeSrc = async () => {
await tick();
if (isUrl) {
iframeSrc = src as string;
iframeDoc = null;
} else {
iframeDoc = await processHtmlForDeps(src as string);
iframeSrc = null;
}
};
// Alpine directives detection
const alpineDirectives = [
'x-data',
'x-init',
'x-show',
'x-bind',
'x-on',
'x-text',
'x-html',
'x-model',
'x-modelable',
'x-ref',
'x-for',
'x-if',
'x-effect',
'x-transition',
'x-cloak',
'x-ignore',
'x-teleport',
'x-id'
];
async function processHtmlForDeps(html: string): Promise<string> {
if (!allowSameOrigin) return html;
const scriptTags: string[] = [];
// --- Alpine.js detection & injection ---
const hasAlpineDirectives = alpineDirectives.some((dir) => html.includes(dir));
if (hasAlpineDirectives) {
try {
const { default: alpineCode } = await import('alpinejs/dist/cdn.min.js?raw');
const alpineBlob = new Blob([alpineCode], { type: 'text/javascript' });
const alpineUrl = URL.createObjectURL(alpineBlob);
const alpineTag = `<script src="${alpineUrl}" defer><\/script>`;
scriptTags.push(alpineTag);
} catch (error) {
console.error('Error processing Alpine for iframe:', error);
}
}
// --- Chart.js detection & injection ---
const chartJsDirectives = ['new Chart(', 'Chart.'];
const hasChartJsDirectives = chartJsDirectives.some((dir) => html.includes(dir));
if (hasChartJsDirectives) {
try {
// import chartUrl from 'chart.js/auto?url';
const { default: Chart } = await import('chart.js/auto');
(window as any).Chart = Chart;
const chartTag = `<script>
window.Chart = parent.Chart; // Chart previously assigned on parent
<\/script>`;
scriptTags.push(chartTag);
} catch (error) {
console.error('Error processing Chart.js for iframe:', error);
}
}
// If nothing to inject, return original HTML
if (scriptTags.length === 0) return html;
const tags = scriptTags.join('\n');
// Prefer injecting into <head>, then before </body>, otherwise prepend
if (html.includes('</head>')) {
return html.replace('</head>', `${tags}\n</head>`);
}
if (html.includes('</body>')) {
return html.replace('</body>', `${tags}\n</body>`);
}
return `${tags}\n${html}`;
}
// Try to measure same-origin content safely
function resizeSameOrigin() {
if (!iframe) return;
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
console.log('iframe doc:', doc);
if (!doc) return;
const h = Math.max(doc.documentElement?.scrollHeight ?? 0, doc.body?.scrollHeight ?? 0);
if (h > 0) iframe.style.height = h + 20 + 'px';
} catch {
// Cross-origin → rely on postMessage from inside the iframe
}
}
// Handle height messages from the iframe (we also verify the sender)
function onMessage(e: MessageEvent) {
if (!iframe || e.source !== iframe.contentWindow) return;
const data = e.data as { type?: string; height?: number };
if (data?.type === 'iframe:height' && typeof data.height === 'number') {
iframe.style.height = Math.max(0, data.height) + 'px';
}
}
// When the iframe loads, try same-origin resize (cross-origin will noop)
const onLoad = async () => {
requestAnimationFrame(resizeSameOrigin);
// if arguments are provided, inject them into the iframe window
if (args && iframe?.contentWindow) {
(iframe.contentWindow as any).args = args;
}
};
// Ensure event listener bound only while component lives
onMount(() => {
window.addEventListener('message', onMessage);
});
onDestroy(() => {
window.removeEventListener('message', onMessage);
});
</script>
{#if iframeDoc}
<iframe
bind:this={iframe}
srcdoc={iframeDoc}
{title}
class="w-full rounded-2xl"
style={`${initialHeight ? `height:${initialHeight}px;` : ''}`}
width="100%"
frameborder="0"
{sandbox}
{allowFullscreen}
on:load={onLoad}
/>
{:else if iframeSrc}
<iframe
bind:this={iframe}
src={iframeSrc}
{title}
class="w-full rounded-2xl"
style={`${initialHeight ? `height:${initialHeight}px;` : ''}`}
width="100%"
frameborder="0"
{sandbox}
referrerpolicy={referrerPolicy}
{allowFullscreen}
on:load={onLoad}
/>
{/if}

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount, getContext } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom'; import panzoom, { type PanZoom } from 'panzoom';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
@ -11,6 +11,8 @@
export let src = ''; export let src = '';
export let alt = ''; export let alt = '';
const i18n = getContext('i18n');
let mounted = false; let mounted = false;
let previewElement = null; let previewElement = null;
@ -100,9 +102,10 @@
const mimeType = blob.type || 'image/png'; const mimeType = blob.type || 'image/png';
// create file name based on the MIME type, alt should be a valid file name with extension // create file name based on the MIME type, alt should be a valid file name with extension
const fileName = alt const fileName = `${$i18n
? `${alt.replaceAll('.', '')}.${mimeType.split('/')[1]}` .t('Generated Image')
: 'download.png'; .toLowerCase()
.replace(/ /g, '_')}.${mimeType.split('/')[1]}`;
// Use FileSaver to save the blob // Use FileSaver to save the blob
saveAs(blob, fileName); saveAs(blob, fileName);
@ -119,9 +122,10 @@
const blobWithType = new Blob([blob], { type: mimeType }); const blobWithType = new Blob([blob], { type: mimeType });
// create file name based on the MIME type, alt should be a valid file name with extension // create file name based on the MIME type, alt should be a valid file name with extension
const fileName = alt const fileName = `${$i18n
? `${alt.replaceAll('.', '')}.${mimeType.split('/')[1]}` .t('Generated Image')
: 'download.png'; .toLowerCase()
.replace(/ /g, '_')}.${mimeType.split('/')[1]}`;
// Use FileSaver to save the blob // Use FileSaver to save the blob
saveAs(blobWithType, fileName); saveAs(blobWithType, fileName);
@ -146,9 +150,10 @@
const blobWithType = new Blob([blob], { type: mimeType }); const blobWithType = new Blob([blob], { type: mimeType });
// create file name based on the MIME type, alt should be a valid file name with extension // create file name based on the MIME type, alt should be a valid file name with extension
const fileName = alt const fileName = `${$i18n
? `${alt.replaceAll('.', '')}.${mimeType.split('/')[1]}` .t('Generated Image')
: 'download.png'; .toLowerCase()
.replace(/ /g, '_')}.${mimeType.split('/')[1]}`;
// Use FileSaver to save the blob // Use FileSaver to save the blob
saveAs(blobWithType, fileName); saveAs(blobWithType, fileName);

View file

@ -7,7 +7,7 @@
export let show = true; export let show = true;
export let size = 'md'; export let size = 'md';
export let containerClassName = 'p-3'; export let containerClassName = 'p-3';
export let className = 'bg-white dark:bg-gray-900 rounded-3xl'; export let className = 'bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm rounded-4xl';
let modalElement = null; let modalElement = null;
let mounted = false; let mounted = false;
@ -87,7 +87,7 @@
bind:this={modalElement} bind:this={modalElement}
aria-modal="true" aria-modal="true"
role="dialog" role="dialog"
class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/60 w-full h-screen max-h-[100dvh] {containerClassName} flex justify-center z-9999 overflow-y-auto overscroll-contain" class="modal fixed top-0 right-0 left-0 bottom-0 bg-black/30 dark:bg-black/60 w-full h-screen max-h-[100dvh] {containerClassName} flex justify-center z-9999 overflow-y-auto overscroll-contain"
in:fade={{ duration: 10 }} in:fade={{ duration: 10 }}
on:mousedown={() => { on:mousedown={() => {
show = false; show = false;
@ -96,7 +96,7 @@
<div <div
class="m-auto max-w-full {sizeToWidth(size)} {size !== 'full' class="m-auto max-w-full {sizeToWidth(size)} {size !== 'full'
? 'mx-2' ? 'mx-2'
: ''} shadow-3xl min-h-fit scrollbar-hidden {className}" : ''} shadow-3xl min-h-fit scrollbar-hidden {className} border border-white dark:border-gray-850"
in:flyAndScale in:flyAndScale
on:mousedown={(e) => { on:mousedown={(e) => {
e.stopPropagation(); e.stopPropagation();

View file

@ -114,19 +114,6 @@
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import { Editor, Extension, mergeAttributes } from '@tiptap/core'; import { Editor, Extension, mergeAttributes } from '@tiptap/core';
// Yjs imports
import * as Y from 'yjs';
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
prosemirrorJSONToYDoc,
yDocToProsemirrorJSON
} from 'y-prosemirror';
import { keymap } from 'prosemirror-keymap';
import { AIAutocompletion } from './RichTextInput/AutoCompletion.js'; import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
@ -152,16 +139,27 @@
import FormattingButtons from './RichTextInput/FormattingButtons.svelte'; import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { all, createLowlight } from 'lowlight'; import { createLowlight } from 'lowlight';
import hljs from 'highlight.js';
import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration';
export let oncompositionstart = (e) => {}; export let oncompositionstart = (e) => {};
export let oncompositionend = (e) => {}; export let oncompositionend = (e) => {};
export let onChange = (e) => {}; export let onChange = (e) => {};
// create a lowlight instance with all languages loaded // create a lowlight instance with all languages loaded
const lowlight = createLowlight(all); const lowlight = createLowlight(
hljs.listLanguages().reduce(
(obj, lang) => {
obj[lang] = () => hljs.getLanguage(lang);
return obj;
},
{} as Record<string, any>
)
);
export let editor = null; export let editor: Editor | null = null;
export let socket = null; export let socket = null;
export let user = null; export let user = null;
@ -170,7 +168,7 @@
export let documentId = ''; export let documentId = '';
export let className = 'input-prose'; export let className = 'input-prose';
export let placeholder = 'Type here...'; export let placeholder = $i18n.t('Type here...');
let _placeholder = placeholder; let _placeholder = placeholder;
$: if (placeholder !== _placeholder) { $: if (placeholder !== _placeholder) {
@ -185,6 +183,7 @@
}; };
export let richText = true; export let richText = true;
export let dragHandle = false;
export let link = false; export let link = false;
export let image = false; export let image = false;
export let fileHandler = false; export let fileHandler = false;
@ -264,325 +263,11 @@
let jsonValue = ''; let jsonValue = '';
let mdValue = ''; let mdValue = '';
let lastSelectionBookmark = null; let provider: SocketIOCollaborationProvider | null = null;
// Yjs setup let floatingMenuElement: Element | null = null;
let ydoc = null; let bubbleMenuElement: Element | null = null;
let yXmlFragment = null; let element: Element | null = null;
let awareness = null;
const getEditorInstance = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(editor);
}, 0);
});
};
// Custom Yjs Socket.IO provider
class SocketIOProvider {
constructor(doc, documentId, socket, user) {
this.doc = doc;
this.documentId = documentId;
this.socket = socket;
this.user = user;
this.isConnected = false;
this.synced = false;
this.setupEventListeners();
}
generateUserColor() {
const colors = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9'
];
return colors[Math.floor(Math.random() * colors.length)];
}
joinDocument() {
const userColor = this.generateUserColor();
this.socket.emit('ydoc:document:join', {
document_id: this.documentId,
user_id: this.user?.id,
user_name: this.user?.name,
user_color: userColor
});
// Set user awareness info
if (awareness && this.user) {
awareness.setLocalStateField('user', {
name: `${this.user.name}`,
color: userColor,
id: this.socket.id
});
}
}
setupEventListeners() {
// Listen for document updates from server
this.socket.on('ydoc:document:update', (data) => {
if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
try {
const update = new Uint8Array(data.update);
Y.applyUpdate(this.doc, update);
} catch (error) {
console.error('Error applying Yjs update:', error);
}
}
});
// Listen for document state from server
this.socket.on('ydoc:document:state', async (data) => {
if (data.document_id === this.documentId) {
try {
if (data.state) {
const state = new Uint8Array(data.state);
if (state.length === 2 && state[0] === 0 && state[1] === 0) {
// Empty state, check if we have content to initialize
// check if editor empty as well
// const editor = await getEditorInstance();
const isEmptyEditor = !editor || editor.getText().trim() === '';
if (isEmptyEditor) {
if (content && (data?.sessions ?? ['']).length === 1) {
const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content);
if (editorYdoc) {
Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
}
}
} else {
// If the editor already has content, we don't need to send an empty state
if (this.doc.getXmlFragment('prosemirror').length > 0) {
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Y.encodeStateAsUpdate(this.doc)
});
} else {
console.warn('Yjs document is empty, not sending state.');
}
}
} else {
Y.applyUpdate(this.doc, state, 'server');
}
}
this.synced = true;
} catch (error) {
console.error('Error applying Yjs state:', error);
this.synced = false;
this.socket.emit('ydoc:document:state', {
document_id: this.documentId
});
}
}
});
// Listen for awareness updates
this.socket.on('ydoc:awareness:update', (data) => {
if (data.document_id === this.documentId && awareness) {
try {
const awarenessUpdate = new Uint8Array(data.update);
awareness.applyUpdate(awarenessUpdate, 'server');
} catch (error) {
console.error('Error applying awareness update:', error);
}
}
});
// Handle connection events
this.socket.on('connect', this.onConnect);
this.socket.on('disconnect', this.onDisconnect);
// Listen for document updates from Yjs
this.doc.on('update', async (update, origin) => {
if (origin !== 'server' && this.isConnected) {
await tick(); // Ensure the DOM is updated before sending
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Array.from(update),
data: {
content: {
md: mdValue,
html: htmlValue,
json: jsonValue
}
}
});
}
});
// Listen for awareness updates from Yjs
if (awareness) {
awareness.on('change', ({ added, updated, removed }, origin) => {
if (origin !== 'server' && this.isConnected) {
const changedClients = added.concat(updated).concat(removed);
const awarenessUpdate = awareness.encodeUpdate(changedClients);
this.socket.emit('ydoc:awareness:update', {
document_id: this.documentId,
user_id: this.socket.id,
update: Array.from(awarenessUpdate)
});
}
});
}
if (this.socket.connected) {
this.isConnected = true;
this.joinDocument();
}
}
onConnect = () => {
this.isConnected = true;
this.joinDocument();
};
onDisconnect = () => {
this.isConnected = false;
this.synced = false;
};
destroy() {
this.socket.off('ydoc:document:update');
this.socket.off('ydoc:document:state');
this.socket.off('ydoc:awareness:update');
this.socket.off('connect', this.onConnect);
this.socket.off('disconnect', this.onDisconnect);
if (this.isConnected) {
this.socket.emit('ydoc:document:leave', {
document_id: this.documentId,
user_id: this.user?.id
});
}
}
}
let provider = null;
// Simple awareness implementation
class SimpleAwareness {
constructor(yDoc) {
// Yjs awareness expects clientID (not clientId) property
this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff);
// Map from clientID (number) to state (object)
this._states = new Map(); // _states, not states; will make getStates() for compat
this._updateHandlers = [];
this._localState = {};
// As in Yjs Awareness, add our local state to the states map from the start:
this._states.set(this.clientID, this._localState);
}
on(event, handler) {
if (event === 'change') this._updateHandlers.push(handler);
}
off(event, handler) {
if (event === 'change') {
const i = this._updateHandlers.indexOf(handler);
if (i !== -1) this._updateHandlers.splice(i, 1);
}
}
getLocalState() {
return this._states.get(this.clientID) || null;
}
getStates() {
// Yjs returns a Map (clientID->state)
return this._states;
}
setLocalStateField(field, value) {
let localState = this._states.get(this.clientID);
if (!localState) {
localState = {};
this._states.set(this.clientID, localState);
}
localState[field] = value;
// After updating, fire 'update' event to all handlers
for (const cb of this._updateHandlers) {
// Follows Yjs Awareness ({ added, updated, removed }, origin)
cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
}
}
applyUpdate(update, origin) {
// Very simple: Accepts a serialized JSON state for now as Uint8Array
try {
const str = new TextDecoder().decode(update);
const obj = JSON.parse(str);
// Should be a plain object: { clientID: state, ... }
for (const [k, v] of Object.entries(obj)) {
this._states.set(+k, v);
}
for (const cb of this._updateHandlers) {
cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
}
} catch (e) {
console.warn('SimpleAwareness: Could not decode update:', e);
}
}
encodeUpdate(clients) {
// Encodes the states for the given clientIDs as Uint8Array (JSON)
const obj = {};
for (const id of clients || Array.from(this._states.keys())) {
const st = this._states.get(id);
if (st) obj[id] = st;
}
const json = JSON.stringify(obj);
return new TextEncoder().encode(json);
}
}
// Yjs collaboration extension
const YjsCollaboration = Extension.create({
name: 'yjsCollaboration',
addProseMirrorPlugins() {
if (!collaboration || !yXmlFragment) return [];
const plugins = [
ySyncPlugin(yXmlFragment),
yUndoPlugin(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-Shift-z': redo
})
];
if (awareness) {
plugins.push(yCursorPlugin(awareness));
}
return plugins;
}
});
function initializeCollaboration() {
if (!collaboration) return;
// Create Yjs document
ydoc = new Y.Doc();
yXmlFragment = ydoc.getXmlFragment('prosemirror');
awareness = new SimpleAwareness(ydoc);
// Create custom Socket.IO provider
provider = new SocketIOProvider(ydoc, documentId, socket, user);
}
let floatingMenuElement = null;
let bubbleMenuElement = null;
let element;
const options = { const options = {
throwOnError: false throwOnError: false
@ -821,9 +506,14 @@
export const focus = () => { export const focus = () => {
if (editor) { if (editor) {
editor.view.focus(); try {
// Scroll to the current selection editor.view?.focus();
editor.view.dispatch(editor.view.state.tr.scrollIntoView()); // Scroll to the current selection
editor.view?.dispatch(editor.view.state.tr.scrollIntoView());
} catch (e) {
// sometimes focusing throws an error, ignore
console.warn('Error focusing editor', e);
}
} }
}; };
@ -928,6 +618,20 @@
} }
}); });
import { listDragHandlePlugin } from './RichTextInput/listDragHandlePlugin.js';
const ListItemDragHandle = Extension.create({
name: 'listItemDragHandle',
addProseMirrorPlugins() {
return [
listDragHandlePlugin({
itemTypeNames: ['listItem', 'taskItem'],
getEditor: () => this.editor
})
];
}
});
onMount(async () => { onMount(async () => {
content = value; content = value;
@ -970,8 +674,9 @@
console.log('content', content); console.log('content', content);
if (collaboration) { if (collaboration && documentId && socket && user) {
initializeCollaboration(); const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration');
provider = new SocketIOCollaborationProvider(documentId, socket, user, content);
} }
console.log(bubbleMenuElement, floatingMenuElement); console.log(bubbleMenuElement, floatingMenuElement);
@ -983,7 +688,8 @@
StarterKit.configure({ StarterKit.configure({
link: link link: link
}), }),
Placeholder.configure({ placeholder: () => _placeholder }), ...(dragHandle ? [ListItemDragHandle] : []),
Placeholder.configure({ placeholder: () => _placeholder, showOnlyWhenEditable: false }),
SelectionDecoration, SelectionDecoration,
...(richText ...(richText
@ -1064,7 +770,7 @@
}) })
] ]
: []), : []),
...(collaboration ? [YjsCollaboration] : []) ...(collaboration && provider ? [provider.getEditorExtension()] : [])
], ],
content: collaboration ? undefined : content, content: collaboration ? undefined : content,
autofocus: messageInput ? true : false, autofocus: messageInput ? true : false,
@ -1337,6 +1043,8 @@
enablePasteRules: richText enablePasteRules: richText
}); });
provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue }));
if (messageInput) { if (messageInput) {
selectTemplate(); selectTemplate();
} }
@ -1406,13 +1114,18 @@
</script> </script>
{#if richText && showFormattingToolbar} {#if richText && showFormattingToolbar}
<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0"> <div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} /> <FormattingButtons {editor} />
</div> </div>
<div bind:this={floatingMenuElement} id="floating-menu" class="p-0"> <div bind:this={floatingMenuElement} id="floating-menu" class="p-0 {editor ? '' : 'hidden'}">
<FormattingButtons {editor} /> <FormattingButtons {editor} />
</div> </div>
{/if} {/if}
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" /> <div
bind:this={element}
class="relative w-full min-w-full h-full min-h-fit {className} {!editable
? 'cursor-not-allowed'
: ''}"
/>

View file

@ -0,0 +1,337 @@
import * as Y from 'yjs';
import {
ySyncPlugin,
yCursorPlugin,
yUndoPlugin,
undo,
redo,
prosemirrorJSONToYDoc
} from 'y-prosemirror';
import type { Socket } from 'socket.io-client';
import type { Awareness } from 'y-protocols/awareness';
import type { SessionUser } from '$lib/stores';
import { Editor, Extension } from '@tiptap/core';
import { keymap } from 'prosemirror-keymap';
import { tick } from 'svelte';
const USER_COLORS = [
'#FF6B6B',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9'
];
const generateUserColor = () => {
return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)];
};
export type EditorContentGetter = () => {
md: string;
html: string;
json: string;
};
// Custom Yjs Socket.IO provider
export class SocketIOCollaborationProvider {
private readonly doc = new Y.Doc();
private readonly awareness = new SimpleAwareness(this.doc);
private isConnected = false;
private synced = false;
private editor: Editor | null = null;
private editorContentGetter: EditorContentGetter | null = null;
constructor(
private readonly documentId: string,
private readonly socket: Socket,
private readonly user: SessionUser,
private readonly initialContent: string | null = null
) {
this.setupEventListeners();
}
public getEditorExtension() {
return Extension.create({
name: 'yjsCollaboration',
addProseMirrorPlugins: () => {
const yXmlFragment = this.doc.getXmlFragment('prosemirror');
if (!yXmlFragment) return [];
const plugins = [
ySyncPlugin(yXmlFragment),
yUndoPlugin(),
keymap({
'Mod-z': undo,
'Mod-y': redo,
'Mod-Shift-z': redo
})
];
plugins.push(yCursorPlugin(this.awareness as unknown as Awareness));
return plugins;
}
});
}
public setEditor(editor: Editor, editorContentGetter: EditorContentGetter) {
this.editor = editor;
this.editorContentGetter = editorContentGetter;
}
private joinDocument() {
const userColor = generateUserColor();
this.socket.emit('ydoc:document:join', {
document_id: this.documentId,
user_id: this.user?.id,
user_name: this.user?.name,
user_color: userColor
});
// Set user awareness info
if (this.user) {
this.awareness.setLocalStateField('user', {
name: `${this.user.name}`,
color: userColor,
id: this.socket.id
});
}
}
private setupEventListeners() {
// Listen for document updates from server
this.socket.on('ydoc:document:update', (data) => {
if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
try {
const update = new Uint8Array(data.update);
Y.applyUpdate(this.doc, update);
} catch (error) {
console.error('Error applying Yjs update:', error);
}
}
});
// Listen for document state from server
this.socket.on('ydoc:document:state', async (data) => {
if (data.document_id === this.documentId) {
try {
if (data.state) {
const state = new Uint8Array(data.state);
if (state.length === 2 && state[0] === 0 && state[1] === 0) {
// Empty state, check if we have content to initialize
// check if editor empty as well
// const editor = await getEditorInstance();
const isEmptyEditor = !this.editor?.getText().trim();
if (isEmptyEditor && this.editor) {
if (this.initialContent && (data?.sessions ?? ['']).length === 1) {
const editorYdoc = prosemirrorJSONToYDoc(this.editor.schema, this.initialContent);
if (editorYdoc) {
Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
}
}
} else {
// If the editor already has content, we don't need to send an empty state
if (this.doc.getXmlFragment('prosemirror').length > 0) {
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Y.encodeStateAsUpdate(this.doc)
});
} else {
console.warn('Yjs document is empty, not sending state.');
}
}
} else {
Y.applyUpdate(this.doc, state, 'server');
}
}
this.synced = true;
} catch (error) {
console.error('Error applying Yjs state:', error);
this.synced = false;
this.socket.emit('ydoc:document:state', {
document_id: this.documentId
});
}
}
});
// Listen for awareness updates
this.socket.on('ydoc:awareness:update', (data) => {
if (data.document_id === this.documentId) {
try {
const awarenessUpdate = new Uint8Array(data.update);
this.awareness.applyUpdate(awarenessUpdate, 'server');
} catch (error) {
console.error('Error applying awareness update:', error);
}
}
});
// Handle connection events
this.socket.on('connect', this.onConnect);
this.socket.on('disconnect', this.onDisconnect);
// Listen for document updates from Yjs
this.doc.on('update', async (update, origin) => {
if (this.editor && origin !== 'server' && this.isConnected) {
await tick(); // Ensure the DOM is updated before sending
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
user_id: this.user?.id,
socket_id: this.socket.id,
update: Array.from(update),
data: {
content: this.editorContentGetter?.() ?? {
md: '',
html: '',
json: ''
}
}
});
}
});
// Listen for awareness updates from Yjs
this.awareness.on(
'change',
(
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
origin: string
) => {
if (origin !== 'server' && this.isConnected) {
const changedClients = added.concat(updated).concat(removed);
const awarenessUpdate = this.awareness.encodeUpdate(changedClients);
this.socket.emit('ydoc:awareness:update', {
document_id: this.documentId,
user_id: this.socket.id,
update: Array.from(awarenessUpdate)
});
}
}
);
if (this.socket.connected) {
this.isConnected = true;
this.joinDocument();
}
}
private readonly onConnect = () => {
this.isConnected = true;
this.joinDocument();
};
private readonly onDisconnect = () => {
this.isConnected = false;
this.synced = false;
};
public destroy() {
this.socket.off('ydoc:document:update');
this.socket.off('ydoc:document:state');
this.socket.off('ydoc:awareness:update');
this.socket.off('connect', this.onConnect);
this.socket.off('disconnect', this.onDisconnect);
if (this.isConnected) {
this.socket.emit('ydoc:document:leave', {
document_id: this.documentId,
user_id: this.user?.id
});
}
this.editor = null;
this.editorContentGetter = null;
}
}
// Simple awareness implementation
class SimpleAwareness {
public readonly clientID: number;
private readonly _states: Map<number, any>;
private readonly _updateHandlers: any[];
private readonly _localState: any;
public constructor(public readonly doc: Y.Doc) {
// Yjs awareness expects clientID (not clientId) property
this.clientID = doc.clientID ? doc.clientID : Math.floor(Math.random() * 0xffffffff);
// Map from clientID (number) to state (object)
this._states = new Map(); // _states, not states; will make getStates() for compat
this._updateHandlers = [];
this._localState = {};
// As in Yjs Awareness, add our local state to the states map from the start:
this._states.set(this.clientID, this._localState);
}
public on(event: string, handler: any) {
if (event === 'change') this._updateHandlers.push(handler);
}
public off(event: string, handler: any) {
if (event === 'change') {
const i = this._updateHandlers.indexOf(handler);
if (i !== -1) this._updateHandlers.splice(i, 1);
}
}
public getLocalState() {
return this._states.get(this.clientID) || null;
}
public getStates() {
// Yjs returns a Map (clientID->state)
return this._states;
}
public setLocalStateField(field: string, value: any) {
let localState = this._states.get(this.clientID);
if (!localState) {
localState = {};
this._states.set(this.clientID, localState);
}
localState[field] = value;
// After updating, fire 'update' event to all handlers
for (const cb of this._updateHandlers) {
// Follows Yjs Awareness ({ added, updated, removed }, origin)
cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
}
}
public applyUpdate(update: Uint8Array, origin: string) {
// Very simple: Accepts a serialized JSON state for now as Uint8Array
try {
const str = new TextDecoder().decode(update);
const obj = JSON.parse(str);
// Should be a plain object: { clientID: state, ... }
for (const [k, v] of Object.entries(obj)) {
this._states.set(+k, v);
}
for (const cb of this._updateHandlers) {
cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
}
} catch (e) {
console.warn('SimpleAwareness: Could not decode update:', e);
}
}
public encodeUpdate(clients: number[]) {
// Encodes the states for the given clientIDs as Uint8Array (JSON)
const obj: Record<number, any> = {};
for (const id of clients || Array.from(this._states.keys())) {
const st = this._states.get(id);
if (st) obj[id] = st;
}
const json = JSON.stringify(obj);
return new TextEncoder().encode(json);
}
}

View file

@ -0,0 +1,513 @@
import { Plugin, PluginKey, NodeSelection } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
import { Fragment } from 'prosemirror-model';
export const listPointerDragKey = new PluginKey('listPointerDrag');
export function listDragHandlePlugin(options = {}) {
const {
itemTypeNames = ['listItem', 'taskItem', 'list_item'],
// Tiptap editor getter (required for indent/outdent)
getEditor = null,
// UI copy / classes
handleTitle = 'Drag to move',
handleInnerHTML = '⋮⋮',
classItemWithHandle = 'pm-li--with-handle',
classHandle = 'pm-list-drag-handle',
classDropBefore = 'pm-li-drop-before',
classDropAfter = 'pm-li-drop-after',
classDropInto = 'pm-li-drop-into',
classDropOutdent = 'pm-li-drop-outdent',
classDraggingGhost = 'pm-li-ghost',
// Behavior
dragThresholdPx = 2,
intoThresholdX = 28, // X ≥ this → treat as “into” (indent)
outdentThresholdX = 10 // X ≤ this → “outdent”
} = options;
const itemTypesSet = new Set(itemTypeNames);
const isListItem = (node) => node && itemTypesSet.has(node.type.name);
const listTypeNames = new Set([
'bulletList',
'orderedList',
'taskList',
'bullet_list',
'ordered_list'
]);
const isListNode = (node) => node && listTypeNames.has(node.type.name);
function listTypeToItemTypeName(listNode) {
const name = listNode?.type?.name;
if (!name) return null;
// Prefer tiptap names first, then ProseMirror snake_case
if (name === 'taskList') {
return itemTypesSet.has('taskItem') ? 'taskItem' : null;
}
if (name === 'orderedList' || name === 'bulletList') {
return itemTypesSet.has('listItem')
? 'listItem'
: itemTypesSet.has('list_item')
? 'list_item'
: null;
}
if (name === 'ordered_list' || name === 'bullet_list') {
return itemTypesSet.has('list_item')
? 'list_item'
: itemTypesSet.has('listItem')
? 'listItem'
: null;
}
return null;
}
// Find the nearest enclosing list container at/around a pos
function getEnclosingListAt(doc, pos) {
const $pos = doc.resolve(Math.max(1, Math.min(pos, doc.content.size - 1)));
for (let d = $pos.depth; d >= 0; d--) {
const n = $pos.node(d);
if (isListNode(n)) {
const start = $pos.before(d);
return { node: n, depth: d, start, end: start + n.nodeSize };
}
}
return null;
}
function normalizeItemForList(state, itemNode, targetListNodeOrType) {
const schema = state.schema;
const targetListNode = targetListNodeOrType;
const wantedItemTypeName =
typeof targetListNode === 'string'
? targetListNode // allow passing type name directly
: listTypeToItemTypeName(targetListNode);
if (!wantedItemTypeName) return itemNode;
const wantedType = schema.nodes[wantedItemTypeName];
if (!wantedType) return itemNode;
const wantedListType = schema.nodes[targetListNode.type.name];
if (!wantedListType) return itemNode;
// Deepnormalize children recursively
const normalizeNode = (node, parentTargetListNode) => {
console.log(
'Normalizing node',
node.type.name,
'for parent list',
parentTargetListNode?.type?.name
);
if (isListNode(node)) {
// Normalize each list item inside
const normalizedItems = [];
node.content.forEach((li) => {
normalizedItems.push(normalizeItemForList(state, li, parentTargetListNode));
});
return wantedListType.create(node.attrs, Fragment.from(normalizedItems), node.marks);
}
// Not a list node → but may contain lists deeper
if (node.content && node.content.size > 0) {
const nChildren = [];
node.content.forEach((ch) => {
nChildren.push(normalizeNode(ch, parentTargetListNode));
});
return node.type.create(node.attrs, Fragment.from(nChildren), node.marks);
}
// leaf
return node;
};
const normalizedContent = [];
itemNode.content.forEach((child) => {
normalizedContent.push(normalizeNode(child, targetListNode));
});
const newAttrs = {};
if (wantedType.attrs) {
for (const key in wantedType.attrs) {
if (Object.prototype.hasOwnProperty.call(itemNode.attrs || {}, key)) {
newAttrs[key] = itemNode.attrs[key];
} else {
const spec = wantedType.attrs[key];
newAttrs[key] = typeof spec?.default !== 'undefined' ? spec.default : null;
}
}
}
if (wantedItemTypeName !== itemNode.type.name) {
// If changing type, ensure no disallowed marks are kept
const allowed = wantedType.spec?.marks;
const marks = allowed ? itemNode.marks.filter((m) => allowed.includes(m.type.name)) : [];
console.log(normalizedContent);
return wantedType.create(newAttrs, Fragment.from(normalizedContent), marks);
}
try {
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
} catch {
// Fallback wrap content if schema requires a block
const para = schema.nodes.paragraph;
if (para) {
const wrapped =
itemNode.content.firstChild?.type === para
? Fragment.from(normalizedContent)
: Fragment.from([para.create(null, normalizedContent)]);
return wantedType.create(newAttrs, wrapped, itemNode.marks);
}
}
return wantedType.create(newAttrs, Fragment.from(normalizedContent), itemNode.marks);
}
// ---------- decorations ----------
function buildHandleDecos(doc) {
const decos = [];
doc.descendants((node, pos) => {
if (!isListItem(node)) return;
decos.push(Decoration.node(pos, pos + node.nodeSize, { class: classItemWithHandle }));
decos.push(
Decoration.widget(
pos + 1,
(view, getPos) => {
const el = document.createElement('span');
el.className = classHandle;
el.setAttribute('title', handleTitle);
el.setAttribute('role', 'button');
el.setAttribute('aria-label', 'Drag list item');
el.contentEditable = 'false';
el.innerHTML = handleInnerHTML;
el.pmGetPos = getPos;
return el;
},
{ side: -1, ignoreSelection: true }
)
);
});
return DecorationSet.create(doc, decos);
}
function findListItemAround($pos) {
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
if (isListItem(node)) {
const start = $pos.before(d);
return { depth: d, node, start, end: start + node.nodeSize };
}
}
return null;
}
function infoFromCoords(view, clientX, clientY) {
const result = view.posAtCoords({ left: clientX, top: clientY });
if (!result) return null;
const $pos = view.state.doc.resolve(result.pos);
const li = findListItemAround($pos);
if (!li) return null;
const dom = /** @type {Element} */ (view.nodeDOM(li.start));
if (!(dom instanceof Element)) return null;
const rect = dom.getBoundingClientRect();
const isRTL = getComputedStyle(dom).direction === 'rtl';
const xFromLeft = isRTL ? rect.right - clientX : clientX - rect.left;
const yInTopHalf = clientY - rect.top < rect.height / 2;
const mode =
xFromLeft <= outdentThresholdX
? 'outdent'
: xFromLeft >= intoThresholdX
? 'into'
: yInTopHalf
? 'before'
: 'after';
return { ...li, dom, mode };
}
// ---------- state ----------
const init = (state) => ({
decorations: buildHandleDecos(state.doc),
dragging: null, // {fromStart, startMouse:{x,y}, ghostEl, active}
dropTarget: null // {start, end, mode, toPos}
});
const apply = (tr, prev) => {
let decorations = tr.docChanged
? buildHandleDecos(tr.doc)
: prev.decorations.map(tr.mapping, tr.doc);
let next = { ...prev, decorations };
const meta = tr.getMeta(listPointerDragKey);
if (meta) {
if (meta.type === 'set-drag') next = { ...next, dragging: meta.dragging };
if (meta.type === 'set-drop') next = { ...next, dropTarget: meta.drop };
if (meta.type === 'clear') next = { ...next, dragging: null, dropTarget: null };
}
return next;
};
const decorationsProp = (state) => {
const ps = listPointerDragKey.getState(state);
if (!ps) return null;
let deco = ps.decorations;
if (ps.dropTarget) {
const { start, end, mode } = ps.dropTarget;
const cls =
mode === 'before'
? classDropBefore
: mode === 'after'
? classDropAfter
: mode === 'into'
? classDropInto
: classDropOutdent;
deco = deco.add(state.doc, [Decoration.node(start, end, { class: cls })]);
}
return deco;
};
// ---------- helpers ----------
const setDrag = (view, dragging) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drag', dragging }));
const setDrop = (view, drop) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'set-drop', drop }));
const clearAll = (view) =>
view.dispatch(view.state.tr.setMeta(listPointerDragKey, { type: 'clear' }));
function moveItem(view, fromStart, toPos) {
const { state, dispatch } = view;
const { doc } = state;
const orig = doc.nodeAt(fromStart);
if (!orig || !isListItem(orig)) return { ok: false };
// no-op if dropping into own range
if (toPos >= fromStart && toPos <= fromStart + orig.nodeSize)
return { ok: true, newStart: fromStart };
// find item depth
const $inside = doc.resolve(fromStart + 1);
let itemDepth = -1;
for (let d = $inside.depth; d > 0; d--) {
if ($inside.node(d) === orig) {
itemDepth = d;
break;
}
}
if (itemDepth < 0) return { ok: false };
const listDepth = itemDepth - 1;
const parentList = $inside.node(listDepth);
const parentListStart = $inside.before(listDepth);
// delete item (or entire list if only child)
const deleteFrom = parentList.childCount === 1 ? parentListStart : fromStart;
const deleteTo =
parentList.childCount === 1
? parentListStart + parentList.nodeSize
: fromStart + orig.nodeSize;
let tr = state.tr.delete(deleteFrom, deleteTo);
// Compute mapped drop point with right bias so "after" stays after
const mappedTo = tr.mapping.map(toPos, 1);
// Detect enclosing list at destination, then normalize the item type
const listAtDest = getEnclosingListAt(tr.doc, mappedTo);
const nodeToInsert = listAtDest ? normalizeItemForList(state, orig, listAtDest.node) : orig;
try {
tr = tr.insert(mappedTo, nodeToInsert);
} catch (e) {
console.log('Direct insert failed, trying to wrap in list', e);
// If direct insert fails (e.g., not inside a list), try wrapping in a list
const schema = state.schema;
const wrapName =
parentList.type.name === 'taskList'
? schema.nodes.taskList
? 'taskList'
: null
: parentList.type.name === 'orderedList' || parentList.type.name === 'ordered_list'
? schema.nodes.orderedList
? 'orderedList'
: schema.nodes.ordered_list
? 'ordered_list'
: null
: schema.nodes.bulletList
? 'bulletList'
: schema.nodes.bullet_list
? 'bullet_list'
: null;
if (wrapName) {
const wrapType = schema.nodes[wrapName];
if (wrapType) {
const frag = wrapType.create(null, normalizeItemForList(state, orig, wrapType));
tr = tr.insert(mappedTo, frag);
} else {
return { ok: false };
}
} else {
return { ok: false };
}
}
dispatch(tr.scrollIntoView());
return { ok: true, newStart: mappedTo };
}
function ensureGhost(view, fromStart) {
const el = document.createElement('div');
el.className = classDraggingGhost;
const dom = /** @type {Element} */ (view.nodeDOM(fromStart));
const rect = dom instanceof Element ? dom.getBoundingClientRect() : null;
if (rect) {
el.style.position = 'fixed';
el.style.left = rect.left + 'px';
el.style.top = rect.top + 'px';
el.style.width = rect.width + 'px';
el.style.pointerEvents = 'none';
el.style.opacity = '0.75';
el.textContent = dom.textContent?.trim().slice(0, 80) || '…';
}
document.body.appendChild(el);
return el;
}
const updateGhost = (ghost, dx, dy) => {
if (ghost) ghost.style.transform = `translate(${Math.round(dx)}px, ${Math.round(dy)}px)`;
};
// ---------- plugin ----------
return new Plugin({
key: listPointerDragKey,
state: { init: (_, state) => init(state), apply },
props: {
decorations: decorationsProp,
handleDOMEvents: {
mousedown(view, event) {
const t = /** @type {HTMLElement} */ (event.target);
const handle = t.closest?.(`.${classHandle}`);
if (!handle) return false;
event.preventDefault();
const getPos = handle.pmGetPos;
if (typeof getPos !== 'function') return true;
const posInside = getPos();
const fromStart = posInside - 1;
try {
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, fromStart))
);
} catch {}
const startMouse = { x: event.clientX, y: event.clientY };
const ghostEl = ensureGhost(view, fromStart);
setDrag(view, { fromStart, startMouse, ghostEl, active: false });
const onMove = (e) => {
const ps = listPointerDragKey.getState(view.state);
if (!ps?.dragging) return;
const dx = e.clientX - ps.dragging.startMouse.x;
const dy = e.clientY - ps.dragging.startMouse.y;
if (!ps.dragging.active && Math.hypot(dx, dy) > dragThresholdPx) {
setDrag(view, { ...ps.dragging, active: true });
}
updateGhost(ps.dragging.ghostEl, dx, dy);
const info = infoFromCoords(view, e.clientX, e.clientY);
if (!info) return setDrop(view, null);
// for before/after: obvious
// for into/outdent: we still insert AFTER target and then run sink/lift
const toPos =
info.mode === 'before' ? info.start : info.mode === 'after' ? info.end : info.end; // into/outdent insert after target
const prev = listPointerDragKey.getState(view.state)?.dropTarget;
if (
!prev ||
prev.start !== info.start ||
prev.end !== info.end ||
prev.mode !== info.mode
) {
setDrop(view, { start: info.start, end: info.end, mode: info.mode, toPos });
}
};
const endDrag = () => {
window.removeEventListener('mousemove', onMove, true);
window.removeEventListener('mouseup', endDrag, true);
const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
// Helper: figure out the list item node type name at/around a pos
const getListItemTypeNameAt = (doc, pos) => {
const direct = doc.nodeAt(pos);
if (direct && isListItem(direct)) return direct.type.name;
const $pos = doc.resolve(Math.min(pos + 1, doc.content.size));
for (let d = $pos.depth; d > 0; d--) {
const n = $pos.node(d);
if (isListItem(n)) return n.type.name;
}
const prefs = ['taskItem', 'listItem', 'list_item'];
for (const p of prefs) if (itemTypesSet.has(p)) return p;
return Array.from(itemTypesSet)[0];
};
if (ps?.dragging && ps?.dropTarget && ps.dragging.active) {
const { fromStart } = ps.dragging;
const { toPos, mode } = ps.dropTarget;
const res = moveItem(view, fromStart, toPos);
if (res.ok && typeof res.newStart === 'number' && getEditor) {
const editor = getEditor();
if (editor?.commands) {
// Select the moved node so sink/lift applies to it
editor.commands.setNodeSelection(res.newStart);
const typeName = getListItemTypeNameAt(view.state.doc, res.newStart);
const chain = editor.chain().focus();
if (mode === 'into') {
if (editor.can().sinkListItem?.(typeName)) chain.sinkListItem(typeName).run();
else chain.run();
} else {
chain.run(); // finalize focus/selection
}
}
}
}
clearAll(view);
};
window.addEventListener('mousemove', onMove, true);
window.addEventListener('mouseup', endDrag, true);
return true;
},
keydown(view, event) {
if (event.key === 'Escape') {
const ps = listPointerDragKey.getState(view.state);
if (ps?.dragging?.ghostEl) ps.dragging.ghostEl.remove();
clearAll(view);
return true;
}
return false;
}
}
}
});
}

View file

@ -49,7 +49,45 @@
<div bind:this={sceneParentElement} class="relative {className}"> <div bind:this={sceneParentElement} class="relative {className}">
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center"> <div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
{@html svg} {@html DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
WHOLE_DOCUMENT: false,
ADD_TAGS: ['style', 'foreignObject'], // include foreignObject if using HTML labels
ADD_ATTR: [
'class',
'style',
'id',
'data-*',
'viewBox',
'preserveAspectRatio',
// markers / arrows
'markerWidth',
'markerHeight',
'markerUnits',
'refX',
'refY',
'orient',
// hrefs (for gradients, markers, etc.)
'href',
'xlink:href',
// text positioning
'dominant-baseline',
'text-anchor',
// pattern / clip / mask units
'clipPathUnits',
'filterUnits',
'patternUnits',
'patternContentUnits',
'maskUnits',
// a11y niceties
'role',
'aria-label',
'aria-labelledby',
'aria-hidden',
'tabindex'
],
SANITIZE_DOM: true
})}
</div> </div>
{#if content} {#if content}

View file

@ -63,7 +63,7 @@
{/if} {/if}
<button <button
class=" cursor-pointer self-center p-0.5 flex h-fit items-center dark:hover:bg-gray-700 rounded-full transition border dark:border-gray-600 border-dashed" class=" cursor-pointer self-center p-0.5 flex h-fit items-center rounded-full transition border dark:border-gray-600 border-dashed"
type="button" type="button"
aria-label={$i18n.t('Add Tag')} aria-label={$i18n.t('Add Tag')}
on:click={() => { on:click={() => {
@ -76,7 +76,7 @@
viewBox="0 0 16 16" viewBox="0 0 16 16"
aria-hidden="true" aria-hidden="true"
fill="currentColor" fill="currentColor"
class="w-3 h-3 {showTagInput ? 'rotate-45' : ''} transition-all transform" class="size-2.5 {showTagInput ? 'rotate-45' : ''} transition-all transform"
> >
<path <path
d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"

View file

@ -0,0 +1,33 @@
<script>
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let tag;
export let onDelete = () => {};
</script>
{#if tag}
<Tooltip content={tag.name}>
<button
aria-label={$i18n.t('Remove this tag from list')}
class="relative group/tags px-1.5 py-[0.5px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-lg bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer"
on:click={() => {
onDelete();
}}
>
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit">
{tag.name}
</div>
<div class="hidden group-hover/tags:block transition">
<div class="rounded-full pl-[1px] backdrop-blur-sm h-full flex self-center cursor-pointer">
<XMark className="size-3" strokeWidth="2.5" />
</div>
</div>
</button>
</Tooltip>
{/if}

View file

@ -2,34 +2,18 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import Tooltip from '../Tooltip.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import TagItem from './TagItem.svelte';
import Badge from '../Badge.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
export let tags = []; export let tags = [];
</script> </script>
{#each tags as tag} {#each tags as tag}
<Tooltip content={tag.name}> <TagItem
<li {tag}
class="relative group/tags px-1.5 py-[0.2px] gap-0.5 flex justify-between h-fit max-h-fit w-fit items-center rounded-full bg-gray-500/20 text-gray-700 dark:text-gray-200 transition cursor-pointer" onDelete={() => {
> dispatch('delete', tag.name);
<div class=" text-[0.7rem] font-medium self-center line-clamp-1 w-fit"> }}
{tag.name} />
</div>
<div class="absolute invisible right-0.5 group-hover/tags:visible transition">
<button
class="rounded-full border bg-white dark:bg-gray-700 h-full flex self-center cursor-pointer"
on:click={() => {
dispatch('delete', tag.name);
}}
type="button"
aria-label={$i18n.t('Remove this tag from list')}
>
<XMark className="size-3" strokeWidth="2.5" />
</button>
</div>
</li>
</Tooltip>
{/each} {/each}

View file

@ -27,10 +27,19 @@
class="p-1 px-3 text-xs flex rounded-sm transition" class="p-1 px-3 text-xs flex rounded-sm transition"
type="button" type="button"
on:click={() => { on:click={() => {
valves[property] = const propertySpec = valvesSpec.properties[property] ?? {};
(valves[property] ?? null) === null
? (valvesSpec.properties[property]?.default ?? '') if ((valves[property] ?? null) === null) {
: null; // Initialize to custom value
if ((propertySpec?.type ?? null) === 'array') {
const defaultArray = propertySpec?.default ?? [];
valves[property] = Array.isArray(defaultArray) ? defaultArray.join(', ') : '';
} else {
valves[property] = propertySpec?.default ?? '';
}
} else {
valves[property] = null;
}
dispatch('change'); dispatch('change');
}} }}

View file

@ -39,6 +39,10 @@
} }
const searchHandler = async () => { const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) { if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout); clearTimeout(searchDebounceTimeout);
} }

View file

@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { getAdminDetails } from '$lib/apis/auths'; import { getAdminDetails } from '$lib/apis/auths';
import { onMount, tick, getContext } from 'svelte'; import { onMount, tick, getContext } from 'svelte';
import { config } from '$lib/stores'; import { config } from '$lib/stores';
@ -38,7 +41,11 @@
style="white-space: pre-wrap;" style="white-space: pre-wrap;"
> >
{#if ($config?.ui?.pending_user_overlay_content ?? '').trim() !== ''} {#if ($config?.ui?.pending_user_overlay_content ?? '').trim() !== ''}
{$config.ui.pending_user_overlay_content} {@html marked.parse(
DOMPurify.sanitize(
($config?.ui?.pending_user_overlay_content ?? '').replace(/\n/g, '<br>')
)
)}
{:else} {:else}
{$i18n.t('Your account status is currently pending activation.')}{'\n'}{$i18n.t( {$i18n.t('Your account status is currently pending activation.')}{'\n'}{$i18n.t(
'To access the WebUI, please reach out to the administrator. Admins can manage user statuses from the Admin Panel.' 'To access the WebUI, please reach out to the administrator. Admins can manage user statuses from the Admin Panel.'

View file

@ -12,14 +12,28 @@
import calendar from 'dayjs/plugin/calendar'; import calendar from 'dayjs/plugin/calendar';
import Loader from '../common/Loader.svelte'; import Loader from '../common/Loader.svelte';
import { createMessagesList } from '$lib/utils'; import { createMessagesList } from '$lib/utils';
import { user } from '$lib/stores'; import { config, user } from '$lib/stores';
import Messages from '../chat/Messages.svelte'; import Messages from '../chat/Messages.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import PencilSquare from '../icons/PencilSquare.svelte';
import PageEdit from '../icons/PageEdit.svelte';
dayjs.extend(calendar); dayjs.extend(calendar);
export let show = false; export let show = false;
export let onClose = () => {}; export let onClose = () => {};
let actions = [
{
label: 'Start a new conversation',
onClick: async () => {
await goto(`/${query ? `?q=${query}` : ''}`);
show = false;
onClose();
},
icon: PencilSquare
}
];
let query = ''; let query = '';
let page = 1; let page = 1;
@ -55,7 +69,13 @@
return; return;
} }
const chatId = chatList[selectedIdx].id; const selectedChatIdx = selectedIdx - actions.length;
if (selectedChatIdx < 0) {
selectedChat = null;
return;
}
const chatId = chatList[selectedChatIdx].id;
const chat = await getChatById(localStorage.token, chatId).catch(async (error) => { const chat = await getChatById(localStorage.token, chatId).catch(async (error) => {
return null; return null;
@ -91,6 +111,10 @@
}; };
const searchHandler = async () => { const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) { if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout); clearTimeout(searchDebounceTimeout);
} }
@ -145,9 +169,9 @@
chatListLoading = false; chatListLoading = false;
}; };
const init = () => { $: if (show) {
searchHandler(); searchHandler();
}; }
const onKeyDown = (e) => { const onKeyDown = (e) => {
const searchOptions = document.getElementById('search-options-container'); const searchOptions = document.getElementById('search-options-container');
@ -158,13 +182,13 @@
if (e.code === 'Escape') { if (e.code === 'Escape') {
show = false; show = false;
onClose(); onClose();
} else if (e.code === 'Enter' && (chatList ?? []).length > 0) { } else if (e.code === 'Enter') {
const item = document.querySelector(`[data-arrow-selected="true"]`); const item = document.querySelector(`[data-arrow-selected="true"]`);
if (item) { if (item) {
item?.click(); item?.click();
show = false;
} }
show = false;
return; return;
} else if (e.code === 'ArrowDown') { } else if (e.code === 'ArrowDown') {
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
@ -178,7 +202,7 @@
} }
} }
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1); selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length);
} else if (e.code === 'ArrowUp') { } else if (e.code === 'ArrowUp') {
if (selectedIdx === 0) { if (selectedIdx === 0) {
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
@ -201,7 +225,23 @@
}; };
onMount(() => { onMount(() => {
init(); actions = [
...actions,
...(($config?.features?.enable_notes ?? false) &&
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
? [
{
label: 'Create a new note',
onClick: async () => {
await goto(`/notes${query ? `?content=${query}` : ''}`);
show = false;
onClose();
},
icon: PageEdit
}
]
: [])
];
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
}); });
@ -215,8 +255,8 @@
</script> </script>
<Modal size="xl" bind:show> <Modal size="xl" bind:show>
<div class="py-2.5 dark:text-gray-300 text-gray-700"> <div class="py-3 dark:text-gray-300 text-gray-700">
<div class="px-3.5 pb-1.5"> <div class="px-4 pb-1.5">
<SearchInput <SearchInput
bind:value={query} bind:value={query}
on:input={searchHandler} on:input={searchHandler}
@ -238,7 +278,7 @@
show = false; show = false;
return; return;
} else if (e.code === 'ArrowDown') { } else if (e.code === 'ArrowDown') {
selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1); selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1 + actions.length);
} else if (e.code === 'ArrowUp') { } else if (e.code === 'ArrowUp') {
selectedIdx = Math.max(selectedIdx - 1, 0); selectedIdx = Math.max(selectedIdx - 1, 0);
} else { } else {
@ -253,13 +293,45 @@
<!-- <hr class="border-gray-50 dark:border-gray-850 my-1" /> --> <!-- <hr class="border-gray-50 dark:border-gray-850 my-1" /> -->
<div class="flex px-3 pb-1"> <div class="flex px-4 pb-1">
<div <div
class="flex flex-col overflow-y-auto h-96 md:h-[40rem] max-h-full scrollbar-hidden w-full flex-1" class="flex flex-col overflow-y-auto h-96 md:h-[40rem] max-h-full scrollbar-hidden w-full flex-1 pr-2"
> >
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2 px-2">
{$i18n.t('Actions')}
</div>
{#each actions as action, idx (action.label)}
<button
class=" w-full flex items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
idx
? 'bg-gray-50 dark:bg-gray-850'
: ''}"
data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
dragabble="false"
on:mouseenter={() => {
selectedIdx = idx;
}}
on:click={async () => {
await action.onClick();
}}
>
<div class="pr-2">
<svelte:component this={action.icon} />
</div>
<div class=" flex-1 text-left">
<div class="text-ellipsis line-clamp-1 w-full">
{$i18n.t(action.label)}
</div>
</div>
</button>
{/each}
{#if chatList} {#if chatList}
<hr class="border-gray-50 dark:border-gray-850 my-3" />
{#if chatList.length === 0} {#if chatList.length === 0}
<div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5"> <div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 py-4">
{$i18n.t('No results found')} {$i18n.t('No results found')}
</div> </div>
{/if} {/if}
@ -294,15 +366,15 @@
{/if} {/if}
<a <a
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx === class=" w-full flex justify-between items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
idx idx + actions.length
? 'bg-gray-50 dark:bg-gray-850' ? 'bg-gray-50 dark:bg-gray-850'
: ''}" : ''}"
href="/c/{chat.id}" href="/c/{chat.id}"
draggable="false" draggable="false"
data-arrow-selected={selectedIdx === idx ? 'true' : undefined} data-arrow-selected={selectedIdx === idx + actions.length ? 'true' : undefined}
on:mouseenter={() => { on:mouseenter={() => {
selectedIdx = idx; selectedIdx = idx + actions.length;
}} }}
on:click={async () => { on:click={async () => {
await goto(`/c/${chat.id}`); await goto(`/c/${chat.id}`);

View file

@ -65,6 +65,8 @@
const BREAKPOINT = 768; const BREAKPOINT = 768;
let scrollTop = 0;
let navElement; let navElement;
let shiftKey = false; let shiftKey = false;
@ -528,7 +530,7 @@
placement="right" placement="right"
> >
<button <button
class="flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group {isWindows class="flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group {isWindows
? 'cursor-pointer' ? 'cursor-pointer'
: 'cursor-[e-resize]'}" : 'cursor-[e-resize]'}"
aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')} aria-label={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')}
@ -551,7 +553,7 @@
<div class=""> <div class="">
<Tooltip content={$i18n.t('New Chat')} placement="right"> <Tooltip content={$i18n.t('New Chat')} placement="right">
<a <a
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group" class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/" href="/"
draggable="false" draggable="false"
on:click={async (e) => { on:click={async (e) => {
@ -573,7 +575,7 @@
<div class=""> <div class="">
<Tooltip content={$i18n.t('Search')} placement="right"> <Tooltip content={$i18n.t('Search')} placement="right">
<button <button
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group" class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
on:click={(e) => { on:click={(e) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.preventDefault(); e.preventDefault();
@ -594,7 +596,7 @@
<div class=""> <div class="">
<Tooltip content={$i18n.t('Notes')} placement="right"> <Tooltip content={$i18n.t('Notes')} placement="right">
<a <a
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group" class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/notes" href="/notes"
on:click={async (e) => { on:click={async (e) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@ -618,7 +620,7 @@
<div class=""> <div class="">
<Tooltip content={$i18n.t('Workspace')} placement="right"> <Tooltip content={$i18n.t('Workspace')} placement="right">
<a <a
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group" class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/workspace" href="/workspace"
on:click={async (e) => { on:click={async (e) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
@ -666,7 +668,7 @@
}} }}
> >
<div <div
class=" cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition group" class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
> >
<div class=" self-center flex items-center justify-center size-9"> <div class=" self-center flex items-center justify-center size-9">
<img <img
@ -704,10 +706,10 @@
: 'invisible'}" : 'invisible'}"
> >
<div <div
class="sidebar px-1.5 pt-2 pb-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 bg-gray-50 dark:bg-gray-950" class="sidebar px-2 pt-2 pb-1.5 flex justify-between space-x-1 text-gray-600 dark:text-gray-400 sticky top-0 z-10 -mb-3"
> >
<a <a
class="flex items-center rounded-lg p-1.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-850 transition no-drag-region" class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition no-drag-region"
href="/" href="/"
draggable="false" draggable="false"
on:click={newChatHandler} on:click={newChatHandler}
@ -730,7 +732,7 @@
placement="bottom" placement="bottom"
> >
<button <button
class="flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition {isWindows class="flex rounded-xl size-8.5 justify-center items-center hover:bg-gray-100/50 dark:hover:bg-gray-850/50 transition {isWindows
? 'cursor-pointer' ? 'cursor-pointer'
: 'cursor-[w-resize]'}" : 'cursor-[w-resize]'}"
on:click={() => { on:click={() => {
@ -743,102 +745,120 @@
</div> </div>
</button> </button>
</Tooltip> </Tooltip>
<div
class="{scrollTop > 0
? 'visible'
: 'invisible'} sidebar-bg-gradient-to-b bg-linear-to-b from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mb-6"
></div>
</div> </div>
<div class="pb-1.5"> <div
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200"> class="relative flex flex-col flex-1 overflow-y-auto scrollbar-hidden pt-3 pb-3"
<a on:scroll={(e) => {
id="sidebar-new-chat-button" if (e.target.scrollTop === 0) {
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none" scrollTop = 0;
href="/" } else {
draggable="false" scrollTop = e.target.scrollTop;
on:click={newChatHandler} }
aria-label={$i18n.t('New Chat')} }}
> >
<div class="self-center"> <div class="pb-1.5">
<PencilSquare className=" size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
</div>
</a>
</div>
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<button
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={() => {
showSearch.set(true);
}}
draggable="false"
aria-label={$i18n.t('Search')}
>
<div class="self-center">
<Search strokeWidth="2" className="size-4.5" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
</div>
</button>
</div>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200"> <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a <a
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition" id="sidebar-new-chat-button"
href="/notes" class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={itemClickHandler} href="/"
draggable="false" draggable="false"
aria-label={$i18n.t('Notes')} on:click={newChatHandler}
aria-label={$i18n.t('New Chat')}
> >
<div class="self-center"> <div class="self-center">
<Note className="size-4.5" strokeWidth="2" /> <PencilSquare className=" size-4.5" strokeWidth="2" />
</div> </div>
<div class="flex self-center translate-y-[0.5px]"> <div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div> <div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
</div> </div>
</a> </a>
</div> </div>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200"> <div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a <button
class="grow flex items-center space-x-3 rounded-lg px-2 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition" id="sidebar-search-button"
href="/workspace" class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
on:click={itemClickHandler} on:click={() => {
showSearch.set(true);
}}
draggable="false" draggable="false"
aria-label={$i18n.t('Workspace')} aria-label={$i18n.t('Search')}
> >
<div class="self-center"> <div class="self-center">
<svg <Search strokeWidth="2" className="size-4.5" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div> </div>
<div class="flex self-center translate-y-[0.5px]"> <div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div> <div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
</div> </div>
</a> </button>
</div> </div>
{/if}
</div>
<div class="relative flex flex-col flex-1"> {#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-notes-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/notes"
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t('Notes')}
>
<div class="self-center">
<Note className="size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div>
</div>
</a>
</div>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-workspace-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/workspace"
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t('Workspace')}
>
<div class="self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div>
</div>
</a>
</div>
{/if}
</div>
{#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0} {#if ($models ?? []).length > 0 && ($settings?.pinnedModels ?? []).length > 0}
<PinnedModelList bind:selectedChatId {shiftKey} /> <PinnedModelList bind:selectedChatId {shiftKey} />
{/if} {/if}
@ -1157,7 +1177,10 @@
</Folder> </Folder>
</div> </div>
<div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 bg-gray-50 dark:bg-gray-950 sidebar"> <div class="px-1.5 pt-1.5 pb-2 sticky bottom-0 z-10 -mt-3 sidebar">
<div
class=" sidebar-bg-gradient-to-t bg-linear-to-t from-gray-50 dark:from-gray-950 to-transparent from-50% pointer-events-none absolute inset-0 -z-10 -mt-6"
></div>
<div class="flex flex-col font-primary"> <div class="flex flex-col font-primary">
{#if $user !== undefined && $user !== null} {#if $user !== undefined && $user !== null}
<UserMenu <UserMenu
@ -1169,7 +1192,7 @@
}} }}
> >
<div <div
class=" flex items-center rounded-xl py-2 px-1.5 w-full hover:bg-gray-100 dark:hover:bg-gray-900 transition" class=" flex items-center rounded-2xl py-2 px-1.5 w-full hover:bg-gray-100/50 dark:hover:bg-gray-900/50 transition"
> >
<div class=" self-center mr-3"> <div class=" self-center mr-3">
<img <img
@ -1188,14 +1211,3 @@
</div> </div>
</div> </div>
{/if} {/if}
<style>
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
visibility: visible;
}
.scrollbar-hidden::-webkit-scrollbar-thumb {
visibility: hidden;
}
</style>

View file

@ -44,10 +44,11 @@
/> />
<div <div
id="sidebar-channel-item"
bind:this={itemElement} bind:this={itemElement}
class=" w-full {className} rounded-lg flex relative group hover:bg-gray-100 dark:hover:bg-gray-900 {$page class=" w-full {className} rounded-xl flex relative group hover:bg-gray-100 dark:hover:bg-gray-900 {$page
.url.pathname === `/channels/${channel.id}` .url.pathname === `/channels/${channel.id}`
? 'bg-gray-100 dark:bg-gray-900' ? 'bg-gray-100 dark:bg-gray-900 selected'
: ''} px-2.5 py-1" : ''} px-2.5 py-1"
> >
<a <a

View file

@ -44,8 +44,12 @@
accessControl = channel.access_control; accessControl = channel.access_control;
}; };
$: if (channel) { $: if (show) {
init(); if (channel) {
init();
}
} else {
resetHandler();
} }
let showDeleteConfirmDialog = false; let showDeleteConfirmDialog = false;
@ -68,6 +72,12 @@
show = false; show = false;
}; };
const resetHandler = () => {
name = '';
accessControl = {};
loading = false;
};
</script> </script>
<Modal size="sm" bind:show> <Modal size="sm" bind:show>
@ -115,8 +125,8 @@
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
<div class="my-2 -mx-2"> <div class="my-2 -mx-2">
<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg"> <div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl bind:accessControl /> <AccessControl bind:accessControl accessRoles={['read', 'write']} />
</div> </div>
</div> </div>

View file

@ -336,17 +336,19 @@
{/if} {/if}
<div <div
id="sidebar-chat-group"
bind:this={itemElement} bind:this={itemElement}
class=" w-full {className} relative group" class=" w-full {className} relative group"
draggable={draggable && !confirmEdit} draggable={draggable && !confirmEdit}
> >
{#if confirmEdit} {#if confirmEdit}
<div <div
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId || id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
confirmEdit confirmEdit
? 'bg-gray-100 dark:bg-gray-900' ? 'bg-gray-100 dark:bg-gray-900 selected'
: selected : selected
? 'bg-gray-100 dark:bg-gray-950' ? 'bg-gray-100 dark:bg-gray-950 selected'
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating : 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating
? 'cursor-not-allowed' ? 'cursor-not-allowed'
: ''}" : ''}"
@ -394,11 +396,12 @@
</div> </div>
{:else} {:else}
<a <a
class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId || id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
confirmEdit confirmEdit
? 'bg-gray-100 dark:bg-gray-900' ? 'bg-gray-100 dark:bg-gray-900 selected'
: selected : selected
? 'bg-gray-100 dark:bg-gray-950' ? 'bg-gray-100 dark:bg-gray-950 selected'
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis" : ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
href="/c/{id}" href="/c/{id}"
on:click={() => { on:click={() => {
@ -438,11 +441,12 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
id="sidebar-chat-item-menu"
class=" class="
{id === $chatId || confirmEdit {id === $chatId || confirmEdit
? 'from-gray-100 dark:from-gray-900' ? 'from-gray-100 dark:from-gray-900 selected'
: selected : selected
? 'from-gray-100 dark:from-gray-950' ? 'from-gray-100 dark:from-gray-950 selected'
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'} : 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute {className === 'pr-2' absolute {className === 'pr-2'
? 'right-[8px]' ? 'right-[8px]'

View file

@ -332,15 +332,13 @@
let isExpandedUpdateTimeout; let isExpandedUpdateTimeout;
const isExpandedUpdateDebounceHandler = (open) => { const isExpandedUpdateDebounceHandler = () => {
clearTimeout(isExpandedUpdateTimeout); clearTimeout(isExpandedUpdateTimeout);
isExpandedUpdateTimeout = setTimeout(() => { isExpandedUpdateTimeout = setTimeout(() => {
isExpandedUpdateHandler(); isExpandedUpdateHandler();
}, 500); }, 500);
}; };
$: isExpandedUpdateDebounceHandler(open);
const renameHandler = async () => { const renameHandler = async () => {
console.log('Edit'); console.log('Edit');
await tick(); await tick();
@ -431,9 +429,9 @@
<div class="w-full group"> <div class="w-full group">
<button <button
id="folder-{folderId}-button" id="folder-{folderId}-button"
class="relative w-full py-1 px-1.5 rounded-lg flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id === class="relative w-full py-1 px-1.5 rounded-xl flex items-center gap-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition {$selectedFolder?.id ===
folderId folderId
? 'bg-gray-100 dark:bg-gray-900' ? 'bg-gray-100 dark:bg-gray-900 selected'
: ''}" : ''}"
on:dblclick={(e) => { on:dblclick={(e) => {
if (clickTimer) { if (clickTimer) {
@ -469,6 +467,7 @@
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
open = !open; open = !open;
isExpandedUpdateDebounceHandler();
}} }}
> >
{#if folders[folderId]?.meta?.icon} {#if folders[folderId]?.meta?.icon}

View file

@ -130,7 +130,14 @@
name: 'false', name: 'false',
type: 'pinned' type: 'pinned'
} }
]; ].filter((item) => {
const pinnedValue = lastWord.slice(7);
if (pinnedValue) {
return item.id.startsWith(pinnedValue) && item.id !== pinnedValue;
} else {
return true;
}
});
} else if (lastWord.startsWith('shared:')) { } else if (lastWord.startsWith('shared:')) {
filteredItems = [ filteredItems = [
{ {
@ -143,7 +150,14 @@
name: 'false', name: 'false',
type: 'shared' type: 'shared'
} }
]; ].filter((item) => {
const sharedValue = lastWord.slice(7);
if (sharedValue) {
return item.id.startsWith(sharedValue) && item.id !== sharedValue;
} else {
return true;
}
});
} else if (lastWord.startsWith('archived:')) { } else if (lastWord.startsWith('archived:')) {
filteredItems = [ filteredItems = [
{ {
@ -156,7 +170,14 @@
name: 'false', name: 'false',
type: 'archived' type: 'archived'
} }
]; ].filter((item) => {
const archivedValue = lastWord.slice(9);
if (archivedValue) {
return item.id.startsWith(archivedValue) && item.id !== archivedValue;
} else {
return true;
}
});
} else { } else {
filteredItems = []; filteredItems = [];
} }
@ -192,11 +213,14 @@
on:input={() => { on:input={() => {
dispatch('input'); dispatch('input');
}} }}
on:focus={() => { on:click={() => {
onFocus(); if (!focused) {
hovering = false; onFocus();
focused = true; hovering = false;
initTags();
focused = true;
initTags();
}
}} }}
on:blur={() => { on:blur={() => {
if (!hovering) { if (!hovering) {
@ -239,6 +263,14 @@
} }
} else { } else {
// if the user types something, reset to the top selection. // if the user types something, reset to the top selection.
if (!focused) {
onFocus();
hovering = false;
focused = true;
initTags();
}
selectedIdx = 0; selectedIdx = 0;
} }
@ -266,7 +298,7 @@
{#if focused && (filteredOptions.length > 0 || filteredItems.length > 0)} {#if focused && (filteredOptions.length > 0 || filteredItems.length > 0)}
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="absolute top-0 mt-8 left-0 right-1 border border-gray-100 dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-lg z-10 shadow-lg" class="absolute top-0 mt-8 left-0 right-1 border border-gray-100 dark:border-gray-900 bg-gray-50 dark:bg-gray-950 rounded-2xl z-10 shadow-lg"
id="search-options-container" id="search-options-container"
in:fade={{ duration: 50 }} in:fade={{ duration: 50 }}
on:mouseenter={() => { on:mouseenter={() => {
@ -278,7 +310,7 @@
selectedIdx = 0; selectedIdx = 0;
}} }}
> >
<div class="px-2 py-2 text-xs group"> <div class="px-3 py-2.5 text-xs group">
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
<div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1 capitalize"> <div class="px-1 font-medium dark:text-gray-300 text-gray-700 mb-1 capitalize">
{selectedOption} {selectedOption}

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