This commit is contained in:
Andrew Baek 2025-09-21 00:52:28 +09:00
commit 2a1c1e34dc
225 changed files with 5134 additions and 2302 deletions

View file

@ -5,6 +5,76 @@ 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.30] - 2025-09-17
### Added
- 🔑 Microsoft Entra ID authentication type support was added for Azure OpenAI connections, enabling enhanced security and streamlined authentication workflows.
### Fixed
- ☁️ OneDrive integration was fixed after recent breakage, restoring reliable account connectivity and file access.
## [0.6.29] - 2025-09-17
### Added
- 🎨 The chat input menu has been completely overhauled with a revolutionary new design, consolidating attachments under a unified '+' button, organizing integrations into a streamlined options menu, and introducing powerful, interactive selectors for attaching chats, notes, and knowledge base items. [Commit](https://github.com/open-webui/open-webui/commit/a68342d5a887e36695e21f8c2aec593b159654ff), [Commit](https://github.com/open-webui/open-webui/commit/96b8aaf83ff341fef432649366bc5155bac6cf20), [Commit](https://github.com/open-webui/open-webui/commit/4977e6d50f7b931372c96dd5979ca635d58aeb78), [Commit](https://github.com/open-webui/open-webui/commit/d973db829f7ec98b8f8fe7d3b2822d588e79f94e), [Commit](https://github.com/open-webui/open-webui/commit/d4c628de09654df76653ad9bce9cb3263e2f27c8), [Commit](https://github.com/open-webui/open-webui/commit/cd740f436db4ea308dbede14ef7ff56e8126f51b), [Commit](https://github.com/open-webui/open-webui/commit/5c2db102d06b5c18beb248d795682ff422e9b6d1), [Commit](https://github.com/open-webui/open-webui/commit/031cf38655a1a2973194d2eaa0fbbd17aca8ee92), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3ed0a6d11fea1a054e0bc8aa8dfbe417c7c53e51), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/eadec9e86e01bc8f9fb90dfe7a7ae4fc3bfa6420), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c03ca7270e64e3a002d321237160c0ddaf2bb129), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b53ddfbd19aa94e9cbf7210acb31c3cfafafa5fe), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c923461882fcde30ae297a95e91176c95b9b72e1)
- 🤖 AI models can now be mentioned in channels to automatically generate responses, enabling multi-model conversations where mentioned models participate directly in threaded discussions with full context awareness. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/4fe97d8794ee18e087790caab9e5d82886006145)
- 💬 The Channels feature now utilizes the modern rich text editor, including support for '/', '@', and '#' command suggestions. [Commit](https://github.com/open-webui/open-webui/commit/06c1426e14ac0dfaf723485dbbc9723a4d89aba9), [Commit](https://github.com/open-webui/open-webui/commit/02f7c3258b62970ce79716f75d15467a96565054)
- 📎 Channel message input now supports direct paste functionality for images and files from the clipboard, streamlining content sharing workflows. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/6549fc839f86c40c26c2ef4dedcaf763a9304418)
- ⚙️ Models can now be configured with default features (Web Search, Image Generation) and filters that automatically activate when a user selects the model. [Commit](https://github.com/open-webui/open-webui/commit/9a555478273355a5177bfc7f7211c64778e4c8de), [Commit](https://github.com/open-webui/open-webui/commit/384a53b339820068e92f7eaea0d9f3e0536c19c2), [Commit](https://github.com/open-webui/open-webui/commit/d7f43bfc1a30c065def8c50d77c2579c1a3c5c67), [Commit](https://github.com/open-webui/open-webui/commit/6a67a2217cc5946ad771e479e3a37ac213210748)
- 💬 The ability to reference other chats as context within a conversation was added via the attachment menu. [Commit](https://github.com/open-webui/open-webui/commit/e097bbdf11ae4975c622e086df00d054291cdeb3), [Commit](https://github.com/open-webui/open-webui/commit/f3cd2ffb18e7dedbe88430f9ae7caa6b3cfd79d0), [Commit](https://github.com/open-webui/open-webui/commit/74263c872c5d574a9bb0944d7984f748dc772dba), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aa8ab349ed2fcb46d1cf994b9c0de2ec2ea35d0d), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/025eef754f0d46789981defd473d001e3b1d0ca2)
- 🎨 The command suggestion UI for prompts ('/'), models ('@'), and knowledge ('#') was completely overhauled with a more responsive and keyboard-navigable interface. [Commit](https://github.com/open-webui/open-webui/commit/6b69c4da0fb9329ccf7024483960e070cf52ccab), [Commit](https://github.com/open-webui/open-webui/commit/06a6855f844456eceaa4d410c93379460e208202), [Commit](https://github.com/open-webui/open-webui/commit/c55f5578280b936cf581a743df3703e3db1afd54), [Commit](https://github.com/open-webui/open-webui/commit/f68d1ba394d4423d369f827894cde99d760b2402)
- 👥 User and channel suggestions were added to the mention system, enabling '@' mentions for users and models, and '#' mentions for channels with searchable user lookup and clickable navigation. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/bbd1d2b58c89b35daea234f1fc9208f2af840899), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/aef1e06f0bb72065a25579c982dd49157e320268), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/779db74d7e9b7b00d099b7d65cfbc8a831e74690)
- 📁 Folder functionality was enhanced with custom background image support, improved drag-and-drop capabilities for moving folders to root level, and better menu interactions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2a234829f5dfdfde27fdfd30591caa908340efb4), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2b1ee8b0dc5f7c0caaafdd218f20705059fa72e2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b1e5bc8e490745f701909c19b6a444b67c04660e), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/3e584132686372dfeef187596a7c557aa5f48308)
- ☁️ OneDrive integration configuration now supports selecting between personal and work/school account types via ENABLE_ONEDRIVE_PERSONAL and ENABLE_ONEDRIVE_BUSINESS environment variables. [#17354](https://github.com/open-webui/open-webui/pull/17354), [Commit](https://github.com/open-webui/open-webui/commit/e1e3009a30f9808ce06582d81a60e391f5ca09ec), [Docs:#697](https://github.com/open-webui/docs/pull/697)
- ⚡ Mermaid.js is now dynamically loaded on demand, significantly reducing first-screen loading time and improving initial page performance. [#17476](https://github.com/open-webui/open-webui/issues/17476), [#17477](https://github.com/open-webui/open-webui/pull/17477)
- ⚡ Azure MSAL browser library is now dynamically loaded on demand, reducing initial bundle size by 730KB and improving first-screen loading speed. [#17479](https://github.com/open-webui/open-webui/pull/17479)
- ⚡ CodeEditor component is now dynamically loaded on demand, reducing initial bundle size by 1MB and improving first-screen loading speed. [#17498](https://github.com/open-webui/open-webui/pull/17498)
- ⚡ Hugging Face Transformers library is now dynamically loaded on demand, reducing initial bundle size by 1.9MB and improving first-screen loading speed. [#17499](https://github.com/open-webui/open-webui/pull/17499)
- ⚡ jsPDF and html2canvas-pro libraries are now dynamically loaded on demand, reducing initial bundle size by 980KB and improving first-screen loading speed. [#17502](https://github.com/open-webui/open-webui/pull/17502)
- ⚡ Leaflet mapping library is now dynamically loaded on demand, reducing initial bundle size by 454KB and improving first-screen loading speed. [#17503](https://github.com/open-webui/open-webui/pull/17503)
- 📊 OpenTelemetry metrics collection was enhanced to properly handle HTTP 500 errors and ensure metrics are recorded even during exceptions. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/b14617a653c6bdcfd3102c12f971924fd1faf572)
- 🔒 OAuth token retrieval logic was refactored, improving the reliability and consistency of authentication handling across the backend. [Commit](https://github.com/open-webui/open-webui/commit/6c0a5fa91cdbf6ffb74667ee61ca96bebfdfbc50)
- 💻 Code block output processing was improved to handle Python execution results more reliably, along with refined visual styling and button layouts. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0e5320c39e308ff97f2ca9e289618af12479eb6e)
- ⚡ Message input processing was optimized to skip unnecessary text variable handling when input is empty, improving performance. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e1386fe80b77126a12dabc4ad058abe9b024b275)
- 📄 Individual chat PDF export was added to the sidebar chat menu, allowing users to export single conversations as PDF documents with both stylized and plain text options. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d041d58bb619689cd04a391b4f8191b23941ca62)
- 🛠️ Function validation was enhanced with improved valve validation and better error handling during function loading and synchronization. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/e66e0526ed6a116323285f79f44237538b6c75e6), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8edfd29102e0a61777b23d3575eaa30be37b59a5)
- 🔔 Notification toast interaction was enhanced with drag detection to prevent accidental clicks and added keyboard support for accessibility. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/621e7679c427b6f0efa85f95235319238bf171ad)
- 🗓️ Improved date and time formatting dynamically adapts to the selected language, ensuring consistent localization across the UI. [#17409](https://github.com/open-webui/open-webui/pull/17409), [Commit](https://github.com/open-webui/open-webui/commit/2227f24bd6d861b1fad8d2cabacf7d62ce137d0c)
- 🔒 Feishu SSO integration was added, allowing users to authenticate via Feishu. [#17284](https://github.com/open-webui/open-webui/pull/17284), [Docs:#685](https://github.com/open-webui/docs/pull/685)
- 🔠 Toggle filters in the chat input options menu are now sorted alphabetically for easier navigation. [Commit](https://github.com/open-webui/open-webui/commit/ca853ca4656180487afcd84230d214f91db52533)
- 🎨 Long chat titles in the sidebar are now truncated to prevent text overflow and maintain a clean layout. [#17356](https://github.com/open-webui/open-webui/pull/17356)
- 🎨 Temporary chat interface design was refined with improved layout and visual consistency. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/67549dcadd670285d491bd41daf3d081a70fd094), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/2ca34217e68f3b439899c75881dfb050f49c9eb2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/fb02ec52a5df3f58b53db4ab3a995c15f83503cd)
- 🎨 Download icon consistency was improved across the entire interface by standardizing the icon component used in menus, functions, tools, and export features. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/596be451ece7e11b5cd25465d49670c27a1cb33f)
- 🎨 Settings interface was enhanced with improved iconography and reorganized the 'Chats' section into 'Data Controls' for better clarity. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/8bf0b40fdd978b5af6548a6e1fb3aabd90bcd5cd)
- 🔄 Various improvements were implemented across the frontend and backend to enhance performance, stability, and security.
- 🌐 Translations for Finnish, German, Kabyle, Portuguese (Brazil), Simplified Chinese, Spanish (Spain), and Traditional Chinese (Taiwan) were enhanced and expanded.
### Fixed
- 📚 Knowledge base permission logic was corrected to ensure private collection owners can access their own content when embedding bypass is enabled. [#17432](https://github.com/open-webui/open-webui/issues/17432), [Commit](https://github.com/open-webui/open-webui/commit/a51f0c30ec1472d71487eab3e15d0351a2716b12)
- ⚙️ Connection URL editing in Admin Settings now properly saves changes instead of reverting to original values, fixing issues with both Ollama and OpenAI-compatible endpoints. [#17435](https://github.com/open-webui/open-webui/issues/17435), [Commit](https://github.com/open-webui/open-webui/commit/e4c864de7eb0d577843a80688677ce3659d1f81f)
- 📊 Usage information collection from Google models was corrected to handle providers that send usage data alongside content chunks instead of separately. [#17421](https://github.com/open-webui/open-webui/pull/17421), [Commit](https://github.com/open-webui/open-webui/commit/c2f98a4cd29ed738f395fef09c42ab8e73cd46a0)
- ⚙️ Settings modal scrolling issue was resolved by moving image compression controls to a dedicated modal, preventing the main settings from becoming scrollable out of view. [#17474](https://github.com/open-webui/open-webui/issues/17474), [Commit](https://github.com/open-webui/open-webui/commit/fed5615c19b0045a55b0be426b468a57bfda4b66)
- 📁 Folder click behavior was improved to prevent accidental actions by implementing proper double-click detection and timing delays for folder expansion and selection. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/19e3214997170eea6ee92452e8c778e04a28e396)
- 🔐 Access control component reliability was improved with better null checking and error handling for group permissions and private access scenarios. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/c8780a7f934c5e49a21b438f2f30232f83cf75d2), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/32015c392dbc6b7367a6a91d9e173e675ea3402c)
- 🔗 The citation modal now correctly displays and links to external web page sources in addition to internal documents. [Commit](https://github.com/open-webui/open-webui/commit/9208a84185a7e59524f00a7576667d493c3ac7d4)
- 🔗 Web and YouTube attachment handling was fixed, ensuring their content is now reliably processed and included in the chat context for retrieval. [Commit](https://github.com/open-webui/open-webui/commit/210197fd438b52080cda5d6ce3d47b92cdc264c8)
- 📂 Large file upload failures are resolved by correcting the processing logic for scenarios where document embedding is bypassed. [Commit](https://github.com/open-webui/open-webui/commit/051b6daa8299fd332503bd584563556e2ae6adab)
- 🌐 Rich text input placeholder text now correctly updates when the interface language is switched, ensuring proper localization. [#17473](https://github.com/open-webui/open-webui/pull/17473), [Commit](https://github.com/open-webui/open-webui/commit/77358031f5077e6efe5cc08d8d4e5831c7cd1cd9)
- 📊 Llama.cpp server timing metrics are now correctly parsed and displayed by fixing a typo in the response handling. [#17350](https://github.com/open-webui/open-webui/issues/17350), [Commit](https://github.com/open-webui/open-webui/commit/cf72f5503f39834b9da44ebbb426a3674dad0caa)
- 🛠️ Filter functions with file_handler configuration now properly handle messages without file attachments, preventing runtime errors. [#17423](https://github.com/open-webui/open-webui/pull/17423)
- 🔔 Channel notification delivery was fixed to properly handle background task execution and user access checking. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/1077b2ac8b96e49c2ad2620e76eb65bbb2a3a1f3)
### Changed
- 📝 Prompt template variables are now optional by default instead of being forced as required, allowing flexible workflows with optional metadata fields. [#17447](https://github.com/open-webui/open-webui/issues/17447), [Commit](https://github.com/open-webui/open-webui/commit/d5824b1b495fcf86e57171769bcec2a0f698b070), [Docs:#696](https://github.com/open-webui/docs/pull/696)
- 🛠️ Direct external tool servers now require explicit user selection from the input interface instead of being automatically included in conversations, providing better control over tool usage. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/0f04227c34ca32746c43a9323e2df32299fcb6af), [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/99bba12de279dd55c55ded35b2e4f819af1c9ab5)
- 📺 Widescreen mode option was removed from Channels interface, with all channel layouts now using full-width display. [Commit](https://github.com/open-webui/open-webui/pull/17420/commits/d46b7b8f1b99a8054b55031fe935c8a16d5ec956)
- 🎛️ The plain textarea input option was deprecated, and the custom text editor is now the standard for all chat inputs. [Commit](https://github.com/open-webui/open-webui/commit/153afd832ccd12a1e5fd99b085008d080872c161)
## [0.6.28] - 2025-09-10 ## [0.6.28] - 2025-09-10
### Added ### Added

View file

@ -730,6 +730,7 @@ def load_oauth_providers():
} }
if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value: if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
def feishu_oauth_register(client: OAuth): def feishu_oauth_register(client: OAuth):
client.register( client.register(
name="feishu", name="feishu",
@ -2167,6 +2168,12 @@ 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 = (
os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
)
ENABLE_ONEDRIVE_BUSINESS = (
os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
)
ONEDRIVE_CLIENT_ID = PersistentConfig( ONEDRIVE_CLIENT_ID = PersistentConfig(
"ONEDRIVE_CLIENT_ID", "ONEDRIVE_CLIENT_ID",

View file

@ -19,6 +19,7 @@ from fastapi import (
from starlette.responses import Response, StreamingResponse from starlette.responses import Response, StreamingResponse
from open_webui.constants import ERROR_MESSAGES
from open_webui.socket.main import ( from open_webui.socket.main import (
get_event_call, get_event_call,
get_event_emitter, get_event_emitter,
@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str):
function_module, _, _ = get_function_module_from_cache(request, pipe_id) function_module, _, _ = get_function_module_from_cache(request, pipe_id)
if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
Valves = function_module.Valves
valves = Functions.get_function_valves_by_id(pipe_id) valves = Functions.get_function_valves_by_id(pipe_id)
function_module.valves = function_module.Valves(**(valves if valves else {}))
if valves:
try:
function_module.valves = Valves(
**{k: v for k, v in valves.items() if v is not None}
)
except Exception as e:
log.exception(f"Error loading valves for function {pipe_id}: {e}")
raise e
else:
function_module.valves = Valves()
return function_module return function_module
@ -70,6 +83,7 @@ async def get_function_models(request):
pipe_models = [] pipe_models = []
for pipe in pipes: for pipe in pipes:
try:
function_module = get_function_module_by_id(request, pipe.id) function_module = get_function_module_by_id(request, pipe.id)
# Check if function is a manifold # Check if function is a manifold
@ -129,6 +143,9 @@ async def get_function_models(request):
"pipe": pipe_flag, "pipe": pipe_flag,
} }
) )
except Exception as e:
log.exception(e)
continue
return pipe_models return pipe_models
@ -222,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

@ -110,9 +110,6 @@ from open_webui.config import (
OLLAMA_API_CONFIGS, OLLAMA_API_CONFIGS,
# OpenAI # OpenAI
ENABLE_OPENAI_API, ENABLE_OPENAI_API,
ONEDRIVE_CLIENT_ID,
ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID,
OPENAI_API_BASE_URLS, OPENAI_API_BASE_URLS,
OPENAI_API_KEYS, OPENAI_API_KEYS,
OPENAI_API_CONFIGS, OPENAI_API_CONFIGS,
@ -303,14 +300,16 @@ from open_webui.config import (
GOOGLE_PSE_ENGINE_ID, GOOGLE_PSE_ENGINE_ID,
GOOGLE_DRIVE_CLIENT_ID, GOOGLE_DRIVE_CLIENT_ID,
GOOGLE_DRIVE_API_KEY, GOOGLE_DRIVE_API_KEY,
ENABLE_ONEDRIVE_INTEGRATION,
ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_ID,
ONEDRIVE_SHAREPOINT_URL, ONEDRIVE_SHAREPOINT_URL,
ONEDRIVE_SHAREPOINT_TENANT_ID, ONEDRIVE_SHAREPOINT_TENANT_ID,
ENABLE_ONEDRIVE_PERSONAL,
ENABLE_ONEDRIVE_BUSINESS,
ENABLE_RAG_HYBRID_SEARCH, ENABLE_RAG_HYBRID_SEARCH,
ENABLE_RAG_LOCAL_WEB_FETCH, ENABLE_RAG_LOCAL_WEB_FETCH,
ENABLE_WEB_LOADER_SSL_VERIFICATION, ENABLE_WEB_LOADER_SSL_VERIFICATION,
ENABLE_GOOGLE_DRIVE_INTEGRATION, ENABLE_GOOGLE_DRIVE_INTEGRATION,
ENABLE_ONEDRIVE_INTEGRATION,
UPLOAD_DIR, UPLOAD_DIR,
EXTERNAL_WEB_SEARCH_URL, EXTERNAL_WEB_SEARCH_URL,
EXTERNAL_WEB_SEARCH_API_KEY, EXTERNAL_WEB_SEARCH_API_KEY,
@ -448,6 +447,7 @@ from open_webui.utils.models import (
get_all_models, get_all_models,
get_all_base_models, get_all_base_models,
check_model_access, check_model_access,
get_filtered_models,
) )
from open_webui.utils.chat import ( from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler, generate_chat_completion as chat_completion_handler,
@ -1291,33 +1291,6 @@ if audit_level != AuditLevel.NONE:
async def get_models( async def get_models(
request: Request, refresh: bool = False, user=Depends(get_verified_user) request: Request, refresh: bool = False, user=Depends(get_verified_user)
): ):
def get_filtered_models(models, user):
filtered_models = []
for model in models:
if model.get("arena"):
if has_access(
user.id,
type="read",
access_control=model.get("info", {})
.get("meta", {})
.get("access_control", {}),
):
filtered_models.append(model)
continue
model_info = Models.get_model_by_id(model["id"])
if model_info:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.user_id
or has_access(
user.id, type="read", access_control=model_info.access_control
)
):
filtered_models.append(model)
return filtered_models
all_models = await get_all_models(request, refresh=refresh, user=user) all_models = await get_all_models(request, refresh=refresh, user=user)
models = [] models = []
@ -1353,11 +1326,6 @@ async def get_models(
) )
) )
# Filter out models that the user does not have access to
if (
user.role == "user"
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
) and not BYPASS_MODEL_ACCESS_CONTROL:
models = get_filtered_models(models, user) models = get_filtered_models(models, user)
log.debug( log.debug(
@ -1730,6 +1698,14 @@ async def get_app_config(request: Request):
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
**(
{
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
"enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
}
if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
else {}
),
} }
if user is not None if user is not None
else {} else {}

View file

@ -201,8 +201,14 @@ class MessageTable:
with get_db() as db: with get_db() as db:
message = db.get(Message, id) message = db.get(Message, id)
message.content = form_data.content message.content = form_data.content
message.data = form_data.data message.data = {
message.meta = form_data.meta **(message.data if message.data else {}),
**(form_data.data if form_data.data else {}),
}
message.meta = {
**(message.meta if message.meta else {}),
**(form_data.meta if form_data.meta else {}),
}
message.updated_at = int(time.time_ns()) message.updated_at = int(time.time_ns())
db.commit() db.commit()
db.refresh(message) db.refresh(message)

View file

@ -107,11 +107,21 @@ class UserInfoResponse(BaseModel):
role: str role: str
class UserIdNameResponse(BaseModel):
id: str
name: str
class UserInfoListResponse(BaseModel): class UserInfoListResponse(BaseModel):
users: list[UserInfoResponse] users: list[UserInfoResponse]
total: int total: int
class UserIdNameListResponse(BaseModel):
users: list[UserIdNameResponse]
total: int
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: str id: str
name: str name: str
@ -210,7 +220,7 @@ class UsersTable:
filter: Optional[dict] = None, filter: Optional[dict] = None,
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> UserListResponse: ) -> dict:
with get_db() as db: with get_db() as db:
query = db.query(User) query = db.query(User)

View file

@ -621,6 +621,7 @@ def get_sources_from_items(
if knowledge_base and ( if knowledge_base and (
user.role == "admin" user.role == "admin"
or knowledge_base.user_id == user.id
or has_access(user.id, "read", knowledge_base.access_control) or has_access(user.id, "read", knowledge_base.access_control)
): ):

View file

@ -550,7 +550,7 @@ def transcription_handler(request, file_path, metadata):
metadata = metadata or {} metadata = metadata or {}
languages = [ languages = [
metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE, metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
None, # Always fallback to None in case transcription fails None, # Always fallback to None in case transcription fails
] ]

View file

@ -24,9 +24,17 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.utils.models import (
get_all_models,
get_filtered_models,
)
from open_webui.utils.chat import generate_chat_completion
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, get_users_with_access from open_webui.utils.access_control import has_access, get_users_with_access
from open_webui.utils.webhook import post_webhook from open_webui.utils.webhook import post_webhook
from open_webui.utils.channels import extract_mentions, replace_mentions
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"]) log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -200,14 +208,11 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
users = get_users_with_access("read", channel.access_control) users = get_users_with_access("read", channel.access_control)
for user in users: for user in users:
if user.id in active_user_ids: if user.id not in active_user_ids:
continue
else:
if user.settings: if user.settings:
webhook_url = user.settings.ui.get("notifications", {}).get( webhook_url = user.settings.ui.get("notifications", {}).get(
"webhook_url", None "webhook_url", None
) )
if webhook_url: if webhook_url:
await post_webhook( await post_webhook(
name, name,
@ -221,14 +226,134 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
}, },
) )
return True
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
async def post_new_message( async def model_response_handler(request, channel, message, user):
request: Request, MODELS = {
id: str, model["id"]: model
form_data: MessageForm, for model in get_filtered_models(await get_all_models(request, user=user), user)
background_tasks: BackgroundTasks, }
user=Depends(get_verified_user),
mentions = extract_mentions(message.content)
message_content = replace_mentions(message.content)
# check if any of the mentions are models
model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
if not model_mentions:
return False
for mention in model_mentions:
model_id = mention["id"]
model = MODELS.get(model_id, None)
if model:
try:
# reverse to get in chronological order
thread_messages = Messages.get_messages_by_parent_id(
channel.id,
message.parent_id if message.parent_id else message.id,
)[::-1]
response_message, channel = await new_message_handler(
request,
channel.id,
MessageForm(
**{
"parent_id": (
message.parent_id if message.parent_id else message.id
),
"content": f"",
"data": {},
"meta": {
"model_id": model_id,
"model_name": model.get("name", model_id),
},
}
),
user,
)
thread_history = []
message_users = {}
for thread_message in thread_messages:
message_user = None
if thread_message.user_id not in message_users:
message_user = Users.get_user_by_id(thread_message.user_id)
message_users[thread_message.user_id] = message_user
else:
message_user = message_users[thread_message.user_id]
if thread_message.meta and thread_message.meta.get(
"model_id", None
):
# If the message was sent by a model, use the model name
message_model_id = thread_message.meta.get("model_id", None)
message_model = MODELS.get(message_model_id, None)
username = (
message_model.get("name", message_model_id)
if message_model
else message_model_id
)
else:
username = message_user.name if message_user else "Unknown"
thread_history.append(
f"{username}: {replace_mentions(thread_message.content)}"
)
system_message = {
"role": "system",
"content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
+ (
f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
if thread_history
else ""
),
}
form_data = {
"model": model_id,
"messages": [
system_message,
{
"role": "user",
"content": f"{user.name if user else 'User'}: {message_content}",
},
],
"stream": False,
}
res = await generate_chat_completion(
request,
form_data=form_data,
user=user,
)
if res:
await update_message_by_id(
channel.id,
response_message.id,
MessageForm(
**{
"content": res["choices"][0]["message"]["content"],
"meta": {
"done": True,
},
}
),
user,
)
except Exception as e:
log.info(e)
pass
return True
async def new_message_handler(
request: Request, id: str, form_data: MessageForm, 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:
@ -302,11 +427,30 @@ async def post_new_message(
}, },
to=f"channel:{channel.id}", to=f"channel:{channel.id}",
) )
return MessageModel(**message.model_dump()), channel
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
async def post_new_message(
request: Request,
id: str,
form_data: MessageForm,
background_tasks: BackgroundTasks,
user=Depends(get_verified_user),
):
try:
message, channel = await new_message_handler(request, id, form_data, user)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
background_tasks.add_task( async def background_handler():
send_notification, await model_response_handler(request, channel, message, user)
await send_notification(
request.app.state.WEBUI_NAME, request.app.state.WEBUI_NAME,
request.app.state.config.WEBUI_URL, request.app.state.config.WEBUI_URL,
channel, channel,
@ -314,7 +458,12 @@ async def post_new_message(
active_user_ids, active_user_ids,
) )
return MessageModel(**message.model_dump()) background_tasks.add_task(background_handler)
return message
except HTTPException as e:
raise e
except Exception as e: except Exception as e:
log.exception(e) log.exception(e)
raise HTTPException( raise HTTPException(

View file

@ -166,7 +166,7 @@ async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)
@router.get("/search", response_model=list[ChatTitleIdResponse]) @router.get("/search", response_model=list[ChatTitleIdResponse])
async def search_user_chats( def search_user_chats(
text: str, page: Optional[int] = None, user=Depends(get_verified_user) text: str, page: Optional[int] = None, user=Depends(get_verified_user)
): ):
if page is None: if page is None:

View file

@ -120,11 +120,6 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
f"File type {file.content_type} is not provided, but trying to process anyway" f"File type {file.content_type} is not provided, but trying to process anyway"
) )
process_file(request, ProcessFileForm(file_id=file_item.id), user=user) process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
Files.update_file_data_by_id(
file_item.id,
{"status": "completed"},
)
except Exception as e: except Exception as e:
log.error(f"Error processing file: {file_item.id}") log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id( Files.update_file_data_by_id(

View file

@ -148,6 +148,18 @@ async def sync_functions(
content=function.content, content=function.content,
) )
if hasattr(function_module, "Valves") and function.valves:
Valves = function_module.Valves
try:
Valves(
**{k: v for k, v in function.valves.items() if v is not None}
)
except Exception as e:
log.exception(
f"Error validating valves for function {function.id}: {e}"
)
raise e
return Functions.sync_functions(user.id, form_data.functions) return Functions.sync_functions(user.id, form_data.functions)
except Exception as e: except Exception as e:
log.exception(f"Failed to load a function: {e}") log.exception(f"Failed to load a function: {e}")

View file

@ -1,4 +1,6 @@
from typing import Optional from typing import Optional
import io
import base64
from open_webui.models.models import ( from open_webui.models.models import (
ModelForm, ModelForm,
@ -10,12 +12,13 @@ from open_webui.models.models import (
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access, has_permission from open_webui.utils.access_control import has_access, has_permission
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
router = APIRouter() router = APIRouter()
@ -129,6 +132,39 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
) )
###########################
# GetModelById
###########################
@router.get("/model/profile/image")
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
model = Models.get_model_by_id(id)
if model:
if model.meta.profile_image_url:
if model.meta.profile_image_url.startswith("http"):
return Response(
status_code=status.HTTP_302_FOUND,
headers={"Location": model.meta.profile_image_url},
)
elif model.meta.profile_image_url.startswith("data:image"):
try:
header, base64_data = model.meta.profile_image_url.split(",", 1)
image_data = base64.b64decode(base64_data)
image_buffer = io.BytesIO(image_data)
return StreamingResponse(
image_buffer,
media_type="image/png",
headers={"Content-Disposition": "inline; filename=image.png"},
)
except Exception as e:
pass
return FileResponse(f"{STATIC_DIR}/favicon.png")
else:
return FileResponse(f"{STATIC_DIR}/favicon.png")
############################ ############################
# ToggleModelById # ToggleModelById
############################ ############################

View file

@ -1694,13 +1694,15 @@ 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()
with open(file_path, "rb") as file:
chunk_size = 1024 * 1024 * 2 chunk_size = 1024 * 1024 * 2
hashed = calculate_sha256(file, chunk_size) hashed = calculate_sha256(file, chunk_size)
file.seek(0)
url = f"{ollama_url}/api/blobs/sha256:{hashed}" url = f"{ollama_url}/api/blobs/sha256:{hashed}"
response = requests.post(url, data=file) with requests.Session() as session:
response = session.post(url, data=file, timeout=30)
if response.ok: if response.ok:
res = { res = {

View file

@ -9,6 +9,8 @@ from aiocache import cached
import requests import requests
from urllib.parse import quote from urllib.parse import quote
from azure.identity import DefaultAzureCredential, get_bearer_token_provider
from fastapi import Depends, HTTPException, Request, APIRouter from fastapi import Depends, HTTPException, Request, APIRouter
from fastapi.responses import ( from fastapi.responses import (
FileResponse, FileResponse,
@ -119,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,
@ -172,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),
) )
@ -182,12 +184,30 @@ def get_headers_and_cookies(
if oauth_token: if oauth_token:
token = f"{oauth_token.get('access_token', '')}" token = f"{oauth_token.get('access_token', '')}"
elif auth_type in ("azure_ad", "microsoft_entra_id"):
token = get_microsoft_entra_id_access_token()
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
return headers, cookies return headers, cookies
def get_microsoft_entra_id_access_token():
"""
Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI.
Returns the token string or None if authentication fails.
"""
try:
token_provider = get_bearer_token_provider(
DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
)
return token_provider()
except Exception as e:
log.error(f"Error getting Microsoft Entra ID access token: {e}")
return None
########################################## ##########################################
# #
# API routes # API routes
@ -285,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
) )
@ -550,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
) )
@ -636,14 +656,17 @@ 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
) )
if api_config.get("azure", False): if api_config.get("azure", False):
# Only set api-key header if not using Azure Entra ID authentication
auth_type = api_config.get("auth_type", "bearer")
if auth_type not in ("azure_ad", "microsoft_entra_id"):
headers["api-key"] = key headers["api-key"] = key
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
api_version = api_config.get("api_version", "") or "2023-03-15-preview"
async with session.get( async with session.get(
url=f"{url}/openai/models?api-version={api_version}", url=f"{url}/openai/models?api-version={api_version}",
headers=headers, headers=headers,
@ -878,14 +901,19 @@ 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
) )
if api_config.get("azure", False): if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview") api_version = api_config.get("api_version", "2023-03-15-preview")
request_url, payload = convert_to_azure_payload(url, payload, api_version) request_url, payload = convert_to_azure_payload(url, payload, api_version)
# Only set api-key header if not using Azure Entra ID authentication
auth_type = api_config.get("auth_type", "bearer")
if auth_type not in ("azure_ad", "microsoft_entra_id"):
headers["api-key"] = key headers["api-key"] = key
headers["api-version"] = api_version headers["api-version"] = api_version
request_url = f"{request_url}/chat/completions?api-version={api_version}" request_url = f"{request_url}/chat/completions?api-version={api_version}"
else: else:
@ -982,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(
@ -1052,13 +1082,18 @@ 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
) )
if api_config.get("azure", False): if api_config.get("azure", False):
api_version = api_config.get("api_version", "2023-03-15-preview") api_version = api_config.get("api_version", "2023-03-15-preview")
# Only set api-key header if not using Azure Entra ID authentication
auth_type = api_config.get("auth_type", "bearer")
if auth_type not in ("azure_ad", "microsoft_entra_id"):
headers["api-key"] = key headers["api-key"] = key
headers["api-version"] = api_version headers["api-version"] = api_version
payload = json.loads(body) payload = json.loads(body)

View file

@ -1584,12 +1584,19 @@ def process_file(
}, },
) )
Files.update_file_data_by_id(
file.id,
{"status": "completed"},
)
return { return {
"status": True, "status": True,
"collection_name": collection_name, "collection_name": collection_name,
"filename": file.filename, "filename": file.filename,
"content": text_content, "content": text_content,
} }
else:
raise Exception("Error saving document to vector database")
except Exception as e: except Exception as e:
raise e raise e

View file

@ -18,6 +18,7 @@ from open_webui.models.users import (
UserModel, UserModel,
UserListResponse, UserListResponse,
UserInfoListResponse, UserInfoListResponse,
UserIdNameListResponse,
UserRoleUpdateForm, UserRoleUpdateForm,
Users, Users,
UserSettings, UserSettings,
@ -100,6 +101,23 @@ async def get_all_users(
return Users.get_users() return Users.get_users()
@router.get("/search", response_model=UserIdNameListResponse)
async def search_users(
query: Optional[str] = None,
user=Depends(get_verified_user),
):
limit = PAGE_ITEM_COUNT
page = 1 # Always return the first page for search
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
return Users.get_users(filter=filter, skip=skip, limit=limit)
############################ ############################
# User Groups # User Groups
############################ ############################

View file

@ -130,9 +130,10 @@ def has_access(
# Get all users with access to a resource # Get all users with access to a resource
def get_users_with_access( def get_users_with_access(
type: str = "write", access_control: Optional[dict] = None type: str = "write", access_control: Optional[dict] = None
) -> List[UserModel]: ) -> list[UserModel]:
if access_control is None: if access_control is None:
return Users.get_users() result = Users.get_users()
return result.get("users", [])
permission_access = access_control.get(type, {}) permission_access = access_control.get(type, {})
permitted_group_ids = permission_access.get("group_ids", []) permitted_group_ids = permission_access.get("group_ids", [])

View file

@ -0,0 +1,31 @@
import re
def extract_mentions(message: str, triggerChar: str = "@"):
# Escape triggerChar in case it's a regex special character
triggerChar = re.escape(triggerChar)
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
matches = re.findall(pattern, message)
return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
"""
Replace mentions in the message with either their label (after the pipe `|`)
or their id if no label exists.
Example:
"<@M:gpt-4.1|GPT-4>" -> "GPT-4" (if use_label=True)
"<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False)
"""
# Escape triggerChar
triggerChar = re.escape(triggerChar)
def replacer(match):
id_type, id_value, label = match.groups()
return label if use_label and label else id_value
# Regex captures: idType, id, optional label
pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
return re.sub(pattern, replacer, message)

View file

@ -20,6 +20,7 @@ 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
@ -818,7 +819,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),
) )
@ -1498,7 +1499,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 +1582,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'
@ -2031,6 +2033,20 @@ async def process_chat_response(
) )
else: else:
choices = data.get("choices", []) choices = data.get("choices", [])
# 17421
usage = data.get("usage", {}) or {}
usage.update(data.get("timings", {})) # llama.cpp
if usage:
await event_emitter(
{
"type": "chat:completion",
"data": {
"usage": usage,
},
}
)
if not choices: if not choices:
error = data.get("error", {}) error = data.get("error", {})
if error: if error:
@ -2042,20 +2058,6 @@ async def process_chat_response(
}, },
} }
) )
usage = data.get("usage", {})
usage.update(
data.get("timings", {})
) # llama.cpp
if usage:
await event_emitter(
{
"type": "chat:completion",
"data": {
"usage": usage,
},
}
)
continue continue
delta = choices[0].get("delta", {}) delta = choices[0].get("delta", {})
@ -2402,6 +2404,75 @@ 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
):
tool_result, tool_response_headers = tool_result
if tool_response_headers:
content_disposition = tool_response_headers.get(
"Content-Disposition", ""
)
if "inline" in content_disposition:
content_type = tool_response_headers.get(
"Content-Type", ""
)
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:
@ -2426,6 +2497,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 {}
),
} }
) )

View file

@ -22,10 +22,11 @@ from open_webui.utils.access_control import has_access
from open_webui.config import ( from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
DEFAULT_ARENA_MODEL, DEFAULT_ARENA_MODEL,
) )
from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
from open_webui.models.users import UserModel from open_webui.models.users import UserModel
@ -332,3 +333,40 @@ def check_model_access(user, model):
) )
): ):
raise Exception("Model not found") raise Exception("Model not found")
def get_filtered_models(models, user):
# Filter out models that the user does not have access to
if (
user.role == "user"
or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
) and not BYPASS_MODEL_ACCESS_CONTROL:
filtered_models = []
for model in models:
if model.get("arena"):
if has_access(
user.id,
type="read",
access_control=model.get("info", {})
.get("meta", {})
.get("access_control", {}),
):
filtered_models.append(model)
continue
model_info = Models.get_model_by_id(model["id"])
if model_info:
if (
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model_info.user_id
or has_access(
user.id,
type="read",
access_control=model_info.access_control,
)
):
filtered_models.append(model)
return filtered_models
else:
return models

View file

@ -157,7 +157,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,7 +186,7 @@ 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:
@ -602,7 +602,11 @@ class OAuthManager:
or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
): ):
user_data: UserInfo = await client.userinfo(token=token) user_data: UserInfo = await client.userinfo(token=token)
if provider == "feishu" and isinstance(user_data, dict) and "data" in user_data: if (
provider == "feishu"
and isinstance(user_data, dict)
and "data" in user_data
):
user_data = user_data["data"] user_data = user_data["data"]
if not user_data: if not user_data:
log.warning(f"OAuth callback failed, user data is missing: {token}") log.warning(f"OAuth callback failed, user data is missing: {token}")

View file

@ -163,7 +163,16 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
@app.middleware("http") @app.middleware("http")
async def _metrics_middleware(request: Request, call_next): async def _metrics_middleware(request: Request, call_next):
start_time = time.perf_counter() start_time = time.perf_counter()
status_code = None
try:
response = await call_next(request) response = await call_next(request)
status_code = getattr(response, "status_code", 500)
return response
except Exception:
status_code = 500
raise
finally:
elapsed_ms = (time.perf_counter() - start_time) * 1000.0 elapsed_ms = (time.perf_counter() - start_time) * 1000.0
# Route template e.g. "/items/{item_id}" instead of real path. # Route template e.g. "/items/{item_id}" instead of real path.
@ -173,10 +182,8 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
attrs: Dict[str, str | int] = { attrs: Dict[str, str | int] = {
"http.method": request.method, "http.method": request.method,
"http.route": route_path, "http.route": route_path,
"http.status_code": response.status_code, "http.status_code": status_code,
} }
request_counter.add(1, attrs) request_counter.add(1, attrs)
duration_histogram.record(elapsed_ms, attrs) duration_histogram.record(elapsed_ms, attrs)
return response

View file

@ -171,6 +171,8 @@ async def get_tools(
"tool_id": tool_id, "tool_id": tool_id,
"callable": callable, "callable": callable,
"spec": spec, "spec": spec,
# Misc info
"type": "external",
} }
# Handle function name collisions # Handle function name collisions
@ -646,7 +648,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 +720,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 +731,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 +750,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,6 +2,7 @@ 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

29
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "open-webui", "name": "open-webui",
"version": "0.6.29", "version": "0.6.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-webui", "name": "open-webui",
"version": "0.6.29", "version": "0.6.30",
"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",
@ -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",
@ -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.29", "version": "0.6.30",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run pyodide:fetch && vite dev --host", "dev": "npm run pyodide:fetch && vite dev --host",
@ -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,6 +10,7 @@ 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",

View file

@ -70,23 +70,23 @@ textarea::placeholder {
} }
.input-prose { .input-prose {
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line; @apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
} }
.input-prose-sm { .input-prose-sm {
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm; @apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
} }
.markdown-prose { .markdown-prose {
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown-prose-sm { .markdown-prose-sm {
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown-prose-xs { .markdown-prose-xs {
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line; @apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
} }
.markdown a { .markdown a {
@ -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;
} }
@ -413,7 +413,7 @@ input[type='number'] {
border-radius: 0.4rem; border-radius: 0.4rem;
box-decoration-break: clone; box-decoration-break: clone;
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
@apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20; @apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
} }
.mention::after { .mention::after {
@ -424,7 +424,7 @@ input[type='number'] {
border-radius: 0.4rem; border-radius: 0.4rem;
box-decoration-break: clone; box-decoration-break: clone;
padding: 0.1rem 0.3rem; padding: 0.1rem 0.3rem;
@apply bg-purple-100/20 text-purple-900 dark:bg-purple-500/20 dark:text-purple-100; @apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
} }
.tiptap .suggestion::after { .tiptap .suggestion::after {

View file

@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
return res; return res;
}; };
export const searchUsers = async (token: string, query: string) => {
let error = null;
let res = null;
res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getUserSettings = async (token: string) => { export const getUserSettings = async (token: string) => {
let error = null; let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, { const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {

View file

@ -122,7 +122,7 @@
return; return;
} }
if (!key) { if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
loading = false; loading = false;
toast.error($i18n.t('Key is required')); toast.error($i18n.t('Key is required'));
@ -331,6 +331,9 @@
<option value="session">{$i18n.t('Session')}</option> <option value="session">{$i18n.t('Session')}</option>
{#if !direct} {#if !direct}
<option value="system_oauth">{$i18n.t('OAuth')}</option> <option value="system_oauth">{$i18n.t('OAuth')}</option>
{#if azure}
<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
{/if}
{/if} {/if}
{/if} {/if}
</select> </select>
@ -361,6 +364,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 ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
<div
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -443,7 +452,7 @@
</div> </div>
{/if} {/if}
<div class="flex flex-col w-full"> <div class="flex flex-col w-full mt-2">
<div class="mb-1 flex justify-between"> <div class="mb-1 flex justify-between">
<div <div
class={`mb-0.5 text-xs text-gray-500 class={`mb-0.5 text-xs text-gray-500
@ -499,8 +508,6 @@
{/if} {/if}
</div> </div>
<hr class=" border-gray-100 dark:border-gray-700/10 my-1.5 w-full" />
<div class="flex items-center"> <div class="flex items-center">
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label> <label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
<input <input
@ -528,9 +535,7 @@
</div> </div>
</div> </div>
<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" /> <div class="flex gap-2 mt-2">
<div class="flex gap-2">
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<div <div
class={`mb-0.5 text-xs text-gray-500 class={`mb-0.5 text-xs text-gray-500

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

@ -12,6 +12,43 @@
export let title: string = 'HI'; export let title: string = 'HI';
export let content: string; export let content: string;
let startX = 0,
startY = 0;
let moved = false;
const DRAG_THRESHOLD_PX = 6;
const clickHandler = () => {
onClick();
dispatch('closeToast');
};
function onPointerDown(e: PointerEvent) {
startX = e.clientX;
startY = e.clientY;
moved = false;
// Ensure we continue to get events even if the toast moves under the pointer.
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (moved) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (dx * dx + dy * dy > DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
moved = true;
}
}
function onPointerUp(e: PointerEvent) {
// Release capture if taken
(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
// Only treat as a click if there wasn't a drag
if (!moved) {
clickHandler();
}
}
onMount(() => { onMount(() => {
if (!navigator.userActivation.hasBeenActive) { if (!navigator.userActivation.hasBeenActive) {
return; return;
@ -31,24 +68,33 @@
}); });
</script> </script>
<button <!-- svelte-ignore a11y-click-events-have-key-events -->
class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-850 rounded-xl px-3.5 py-3.5" <!-- svelte-ignore a11y-no-static-element-interactions -->
on:click={() => { <div
onClick(); class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
dispatch('closeToast'); on:dragstart|preventDefault
on:pointerdown={onPointerDown}
on:pointermove={onPointerMove}
on:pointerup={onPointerUp}
on:pointercancel={() => (moved = true)}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clickHandler();
}
}} }}
> >
<div class="shrink-0 self-top -translate-y-0.5"> <div class="shrink-0 self-top -translate-y-0.5">
<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" /> <img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
</div> </div>
<div> <div>
{#if title} {#if title}
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1 capitalize">{title}</div> <div class=" text-[13px] font-medium mb-0.5 line-clamp-1">{title}</div>
{/if} {/if}
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal"> <div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
{@html DOMPurify.sanitize(marked(content))} {@html DOMPurify.sanitize(marked(content))}
</div> </div>
</div> </div>
</button> </div>

View file

@ -56,7 +56,7 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4"> <div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div <div
id="users-tabs-container" id="users-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none" class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
> >
<button <button
id="leaderboard" id="leaderboard"
@ -113,7 +113,7 @@
</button> </button>
</div> </div>
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll"> <div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
{#if selectedTab === 'leaderboard'} {#if selectedTab === 'leaderboard'}
<Leaderboard {feedbacks} /> <Leaderboard {feedbacks} />
{:else if selectedTab === 'feedbacks'} {:else if selectedTab === 'feedbacks'}

View file

@ -13,7 +13,7 @@
import GarbageBin from '$lib/components/icons/GarbageBin.svelte'; import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Pencil from '$lib/components/icons/Pencil.svelte'; import Pencil from '$lib/components/icons/Pencil.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
let show = false; let show = false;
</script> </script>
@ -25,7 +25,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[150px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg" class="w-full max-w-[150px] rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="start" align="start"

View file

@ -13,7 +13,7 @@
import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations'; import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte'; import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte';
import Pagination from '$lib/components/common/Pagination.svelte'; import Pagination from '$lib/components/common/Pagination.svelte';
@ -169,7 +169,7 @@
<FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} /> <FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} />
<div class="mt-0.5 mb-2 gap-1 flex flex-row justify-between"> <div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
<div class="flex md:self-center text-lg font-medium px-0.5"> <div class="flex md:self-center text-lg font-medium px-0.5">
{$i18n.t('Feedback History')} {$i18n.t('Feedback History')}
@ -187,31 +187,25 @@
exportHandler(); exportHandler();
}} }}
> >
<ArrowDownTray className="size-3" /> <Download className="size-3" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
{/if} {/if}
</div> </div>
<div <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
{#if (feedbacks ?? []).length === 0} {#if (feedbacks ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1"> <div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedbacks found')} {$i18n.t('No feedbacks found')}
</div> </div>
{:else} {:else}
<table <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm" <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">
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none w-3" class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('user')} on:click={() => setSortKey('user')}
> >
<div class="flex gap-1.5 items-center justify-end"> <div class="flex gap-1.5 items-center justify-end">
@ -234,7 +228,7 @@
<th <th
scope="col" scope="col"
class="px-3 pr-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('model_id')} on:click={() => setSortKey('model_id')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -257,7 +251,7 @@
<th <th
scope="col" scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-fit" class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')} on:click={() => setSortKey('rating')}
> >
<div class="flex gap-1.5 items-center justify-end"> <div class="flex gap-1.5 items-center justify-end">
@ -280,7 +274,7 @@
<th <th
scope="col" scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-0" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')} on:click={() => setSortKey('updated_at')}
> >
<div class="flex gap-1.5 items-center justify-end"> <div class="flex gap-1.5 items-center justify-end">
@ -301,7 +295,7 @@
</div> </div>
</th> </th>
<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0"> </th> <th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
</tr> </tr>
</thead> </thead>
<tbody class=""> <tbody class="">

View file

@ -1,9 +1,4 @@
<script lang="ts"> <script lang="ts">
import * as ort from 'onnxruntime-web';
import { env, AutoModel, AutoTokenizer } from '@huggingface/transformers';
env.backends.onnx.wasm.wasmPaths = '/wasm/';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores'; import { models } from '$lib/stores';
@ -237,6 +232,11 @@
////////////////////// //////////////////////
const loadEmbeddingModel = async () => { const loadEmbeddingModel = async () => {
const { env, AutoModel, AutoTokenizer } = await import('@huggingface/transformers');
if (env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = '/wasm/';
}
// Check if the tokenizer and model are already loaded and stored in the window object // Check if the tokenizer and model are already loaded and stored in the window object
if (!window.tokenizer) { if (!window.tokenizer) {
window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL); window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL);
@ -337,7 +337,7 @@
/> />
<div <div
class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900" class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
> >
<div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center"> <div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
<div class=" gap-1"> <div class=" gap-1">
@ -370,9 +370,7 @@
</div> </div>
</div> </div>
<div <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm">
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
>
{#if loadingLeaderboard} {#if loadingLeaderboard}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex"> <div class=" absolute top-0 bottom-0 left-0 right-0 flex">
<div class="m-auto"> <div class="m-auto">
@ -386,17 +384,15 @@
</div> </div>
{:else} {:else}
<table <table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded {loadingLeaderboard class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full {loadingLeaderboard
? 'opacity-20' ? 'opacity-20'
: ''}" : ''}"
> >
<thead <thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5" <tr class=" border-b-2 border-gray-100 dark:border-gray-800">
>
<tr class="">
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none w-3" class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('rating')} on:click={() => setSortKey('rating')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -418,7 +414,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('name')} on:click={() => setSortKey('name')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -440,7 +436,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-fit" class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')} on:click={() => setSortKey('rating')}
> >
<div class="flex gap-1.5 items-center justify-end"> <div class="flex gap-1.5 items-center justify-end">
@ -462,7 +458,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-5" class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
on:click={() => setSortKey('won')} on:click={() => setSortKey('won')}
> >
<div class="flex gap-1.5 items-center justify-end"> <div class="flex gap-1.5 items-center justify-end">
@ -484,7 +480,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 text-right cursor-pointer select-none w-5" class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
on:click={() => setSortKey('lost')} on:click={() => setSortKey('lost')}
> >
<div class="flex gap-1.5 items-center justify-end"> <div class="flex gap-1.5 items-center justify-end">

View file

@ -18,7 +18,7 @@
toggleGlobalById toggleGlobalById
} from '$lib/apis/functions'; } from '$lib/apis/functions';
import ArrowDownTray from '../icons/ArrowDownTray.svelte'; import Download from '../icons/Download.svelte';
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte'; import ConfirmDialog from '../common/ConfirmDialog.svelte';
import { getModels } from '$lib/apis'; import { getModels } from '$lib/apis';
@ -222,7 +222,7 @@
}} }}
/> />
<div class="flex flex-col mt-1.5 mb-0.5"> <div class="flex flex-col mt-1.5 mb-0.5 px-[16px]">
<div class="flex justify-between items-center mb-1"> <div class="flex justify-between items-center mb-1">
<div class="flex md:self-center text-xl items-center font-medium px-0.5"> <div class="flex md:self-center text-xl items-center font-medium px-0.5">
{$i18n.t('Functions')} {$i18n.t('Functions')}
@ -317,7 +317,7 @@
</div> </div>
</div> </div>
<div class="mb-5"> <div class="mb-5 px-[16px]">
{#each filteredItems as func (func.id)} {#each filteredItems as func (func.id)}
<div <div
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl" class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
@ -330,14 +330,14 @@
<div class=" flex-1 self-center pl-1"> <div class=" flex-1 self-center pl-1">
<div class=" font-semibold flex items-center gap-1.5"> <div class=" font-semibold flex items-center gap-1.5">
<div <div
class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200" class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
> >
{func.type} {func.type}
</div> </div>
{#if func?.meta?.manifest?.version} {#if func?.meta?.manifest?.version}
<div <div
class="text-xs font-bold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200" class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
> >
v{func?.meta?.manifest?.version ?? ''} v{func?.meta?.manifest?.version ?? ''}
</div> </div>
@ -482,7 +482,7 @@
)} )}
</div> --> </div> -->
<div class=" flex justify-end w-full mb-2"> <div class=" flex justify-end w-full mb-2 px-[16px]">
<div class="flex space-x-2"> <div class="flex space-x-2">
<input <input
id="documents-import-input" id="documents-import-input"
@ -562,7 +562,7 @@
</div> </div>
{#if $config?.features.enable_community_sharing} {#if $config?.features.enable_community_sharing}
<div class=" my-16"> <div class=" my-16 px-[16px]">
<div class=" text-xl font-medium mb-1 line-clamp-1"> <div class=" text-xl font-medium mb-1 line-clamp-1">
{$i18n.t('Made by Open WebUI Community')} {$i18n.t('Made by Open WebUI Community')}
</div> </div>

View file

@ -8,7 +8,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte'; import Share from '$lib/components/icons/Share.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte'; import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Github from '$lib/components/icons/Github.svelte'; import Github from '$lib/components/icons/Github.svelte';
@ -41,7 +41,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary" class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="start" align="start"

View file

@ -4,7 +4,6 @@
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';
@ -367,6 +366,7 @@ 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}
@ -381,6 +381,7 @@ class Pipe:
} }
}} }}
/> />
{/await}
</div> </div>
<div class="pb-3 flex justify-between"> <div class="pb-3 flex justify-between">

View file

@ -8,7 +8,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Share from '$lib/components/icons/Share.svelte'; import Share from '$lib/components/icons/Share.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte'; import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte'; import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
@ -42,7 +42,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-[180px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="start" align="start"
@ -63,7 +63,7 @@
</div> </div>
</div> </div>
<hr class="border-gray-100 dark:border-gray-850 my-1" /> <hr class="border-gray-50 dark:border-gray-850 my-1" />
{/if} {/if}
<DropdownMenu.Item <DropdownMenu.Item
@ -117,12 +117,12 @@
exportHandler(); exportHandler();
}} }}
> >
<ArrowDownTray /> <Download />
<div class="flex items-center">{$i18n.t('Export')}</div> <div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
<hr class="border-gray-100 dark:border-gray-850 my-1" /> <hr class="border-gray-50 dark:border-gray-850 my-1" />
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"

View file

@ -83,7 +83,7 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4"> <div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div <div
id="admin-settings-tabs-container" id="admin-settings-tabs-container"
class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none" class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
> >
<button <button
id="general" id="general"
@ -433,7 +433,9 @@
</button> </button>
</div> </div>
<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll pr-1 scrollbar-hidden"> <div
class="flex-1 mt-3 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll scrollbar-hidden"
>
{#if selectedTab === 'general'} {#if selectedTab === 'general'}
<General <General
saveHandler={async () => { saveHandler={async () => {

View file

@ -261,7 +261,7 @@
<div class="flex flex-col gap-1.5 mt-1.5"> <div class="flex flex-col gap-1.5 mt-1.5">
{#each OPENAI_API_BASE_URLS as url, idx} {#each OPENAI_API_BASE_URLS as url, idx}
<OpenAIConnection <OpenAIConnection
{url} bind:url={OPENAI_API_BASE_URLS[idx]}
bind:key={OPENAI_API_KEYS[idx]} bind:key={OPENAI_API_KEYS[idx]}
bind:config={OPENAI_API_CONFIGS[idx]} bind:config={OPENAI_API_CONFIGS[idx]}
pipeline={pipelineUrls[url] ? true : false} pipeline={pipelineUrls[url] ? true : false}
@ -326,7 +326,7 @@
<div class="flex-1 flex flex-col gap-1.5 mt-1.5"> <div class="flex-1 flex flex-col gap-1.5 mt-1.5">
{#each OLLAMA_BASE_URLS as url, idx} {#each OLLAMA_BASE_URLS as url, idx}
<OllamaConnection <OllamaConnection
{url} bind:url={OLLAMA_BASE_URLS[idx]}
bind:config={OLLAMA_API_CONFIGS[idx]} bind:config={OLLAMA_API_CONFIGS[idx]}
{idx} {idx}
onSubmit={() => { onSubmit={() => {

View file

@ -10,7 +10,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte'; import Wrench from '$lib/components/icons/Wrench.svelte';
import ManageOllamaModal from './ManageOllamaModal.svelte'; import ManageOllamaModal from './ManageOllamaModal.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
export let onDelete = () => {}; export let onDelete = () => {};
export let onSubmit = () => {}; export let onSubmit = () => {};
@ -84,7 +84,7 @@
}} }}
type="button" type="button"
> >
<ArrowDownTray /> <Download />
</button> </button>
</Tooltip> </Tooltip>

View file

@ -143,7 +143,7 @@
</div> </div>
</button> </button>
<hr class="border-gray-100 dark:border-gray-850 my-1" /> <hr class="border-gray-50 dark:border-gray-850 my-1" />
{#if $config?.features.enable_admin_export ?? true} {#if $config?.features.enable_admin_export ?? true}
<div class=" flex w-full justify-between"> <div class=" flex w-full 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

@ -30,7 +30,7 @@
import Cog6 from '$lib/components/icons/Cog6.svelte'; import Cog6 from '$lib/components/icons/Cog6.svelte';
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte'; import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte'; import Wrench from '$lib/components/icons/Wrench.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
import ManageModelsModal from './Models/ManageModelsModal.svelte'; import ManageModelsModal from './Models/ManageModelsModal.svelte';
import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte'; import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
@ -265,7 +265,7 @@
showManageModal = true; showManageModal = true;
}} }}
> >
<ArrowDownTray /> <Download />
</button> </button>
</Tooltip> </Tooltip>

View file

@ -11,7 +11,7 @@
import Share from '$lib/components/icons/Share.svelte'; import Share from '$lib/components/icons/Share.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte'; import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte'; import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte'; import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
import { config } from '$lib/stores'; import { config } from '$lib/stores';
@ -45,7 +45,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[170px] rounded-xl px-1 py-1.5 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-[170px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="start" align="start"
@ -120,7 +120,7 @@
exportHandler(); exportHandler();
}} }}
> >
<ArrowDownTray /> <Download />
<div class="flex items-center">{$i18n.t('Export')}</div> <div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>

View file

@ -58,7 +58,7 @@
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4"> <div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
<div <div
id="users-tabs-container" id="users-tabs-container"
class=" flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none" class="mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
> >
<button <button
id="overview" id="overview"
@ -111,7 +111,7 @@
</button> </button>
</div> </div>
<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll"> <div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
{#if selectedTab === 'overview'} {#if selectedTab === 'overview'}
<UserList /> <UserList />
{:else if selectedTab === 'groups'} {:else if selectedTab === 'groups'}

View file

@ -216,7 +216,7 @@
</div> </div>
{:else} {:else}
<div> <div>
<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold"> <div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-semibold">
<div class="w-full basis-3/5">{$i18n.t('Group')}</div> <div class="w-full basis-3/5">{$i18n.t('Group')}</div>
<div class="w-full basis-2/5 text-right">{$i18n.t('Users')}</div> <div class="w-full basis-2/5 text-right">{$i18n.t('Users')}</div>

View file

@ -154,7 +154,7 @@
</div> </div>
{:else} {:else}
<div <div
class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900" class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
> >
<div class="flex md:self-center text-lg font-medium px-0.5"> <div class="flex md:self-center text-lg font-medium px-0.5">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@ -219,19 +219,13 @@
</div> </div>
</div> </div>
<div <div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5" <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">
<table <tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
>
<thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
>
<tr class="">
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('role')} on:click={() => setSortKey('role')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -254,7 +248,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('name')} on:click={() => setSortKey('name')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -277,7 +271,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('email')} on:click={() => setSortKey('email')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -301,7 +295,7 @@
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('last_active_at')} on:click={() => setSortKey('last_active_at')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -324,7 +318,7 @@
</th> </th>
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('created_at')} on:click={() => setSortKey('created_at')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -347,7 +341,7 @@
<th <th
scope="col" scope="col"
class="px-3 py-1.5 cursor-pointer select-none" class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('oauth_sub')} on:click={() => setSortKey('oauth_sub')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@ -369,7 +363,7 @@
</div> </div>
</th> </th>
<th scope="col" class="px-3 py-2 text-right" /> <th scope="col" class="px-2.5 py-2 text-right" />
</tr> </tr>
</thead> </thead>
<tbody class=""> <tbody class="">
@ -508,11 +502,11 @@
> [!NOTE] > [!NOTE]
> # **Hey there! 👋** > # **Hey there! 👋**
> >
> It looks like you have over 50 users that usually falls under organizational usage. > It looks like you have over 50 users, that usually falls under organizational usage.
> >
> Open WebUI is proudly open source and completely free, with no hidden limits — and we'd love to keep it that way. 🌱 > Open WebUI is completely free to use as-is, with no restrictions or hidden limits, and we'd love to keep it that way. 🌱
> >
> By supporting the project through sponsorship or an enterprise license, youre not only helping us stay independent, youre also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more all at a fraction of what it would cost to build and maintain internally. > By supporting the project through sponsorship or an enterprise license, youre not only helping us stay independent, youre also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more, all at a fraction of what it would cost to build and maintain internally.
> >
> Your support helps us stay independent and continue building great tools for everyone. 💛 > Your support helps us stay independent and continue building great tools for everyone. 💛
> >

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

@ -250,6 +250,8 @@
<MessageInput <MessageInput
id="root" id="root"
{typingUsers} {typingUsers}
userSuggestions={true}
channelSuggestions={true}
{onChange} {onChange}
onSubmit={submitHandler} onSubmit={submitHandler}
{scrollToBottom} {scrollToBottom}
@ -279,11 +281,12 @@
{/if} {/if}
{:else if threadId !== null} {:else if threadId !== null}
<PaneResizer <PaneResizer
class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850" class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer"
> >
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs"> <div
<EllipsisVertical className="size-4 invisible group-hover:visible" /> class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
</div> />
</PaneResizer> </PaneResizer>
<Pane defaultSize={50} minSize={30} class="h-full w-full"> <Pane defaultSize={50} minSize={30} class="h-full w-full">

View file

@ -36,6 +36,7 @@
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions'; import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte'; import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
import MentionList from './MessageInput/MentionList.svelte'; import MentionList from './MessageInput/MentionList.svelte';
import Skeleton from '../chat/Messages/Skeleton.svelte';
export let placeholder = $i18n.t('Send a Message'); export let placeholder = $i18n.t('Send a Message');
@ -55,6 +56,11 @@
export let acceptFiles = true; export let acceptFiles = true;
export let showFormattingToolbar = true; export let showFormattingToolbar = true;
export let userSuggestions = false;
export let channelSuggestions = false;
export let typingUsersClassName = 'from-white dark:from-gray-900';
let loaded = false; let loaded = false;
let draggedOver = false; let draggedOver = false;
@ -223,12 +229,17 @@
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
if (chatInput) { if (chatInput) {
if (text !== '') {
text = await textVariableHandler(text || ''); text = await textVariableHandler(text || '');
}
chatInputElement?.setText(text); chatInputElement?.setText(text);
chatInputElement?.focus(); chatInputElement?.focus();
if (text !== '') {
text = await inputVariableHandler(text); text = await inputVariableHandler(text);
}
await tick(); await tick();
if (cb) await cb(text); if (cb) await cb(text);
} }
@ -555,9 +566,24 @@
{ {
char: '@', char: '@',
render: getSuggestionRenderer(MentionList, { render: getSuggestionRenderer(MentionList, {
i18n i18n,
triggerChar: '@',
modelSuggestions: true,
userSuggestions
}) })
}, },
...(channelSuggestions
? [
{
char: '#',
render: getSuggestionRenderer(MentionList, {
i18n,
triggerChar: '#',
channelSuggestions
})
}
]
: []),
{ {
char: '/', char: '/',
render: getSuggestionRenderer(CommandSuggestionList, { render: getSuggestionRenderer(CommandSuggestionList, {
@ -652,11 +678,7 @@
/> />
<div class="bg-transparent"> <div class="bg-transparent">
<div <div class="max-w-full mx-auto inset-x-0 relative">
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-6xl'} mx-auto inset-x-0 relative"
>
<div <div
class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center" class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center"
> >
@ -690,20 +712,24 @@
{/if} {/if}
</div> </div>
<div class="relative">
<div class=" -mt-5">
{#if typingUsers.length > 0} {#if typingUsers.length > 0}
<div class=" text-xs px-4 mb-1"> <div
class=" -mt-7 pb-2.5 bg-gradient-to-t to-transparent {typingUsersClassName} pointer-events-none select-none"
>
<div class=" text-xs px-1 mt-1.5 flex items-center gap-1.5">
<Skeleton size="xs" />
<div>
<span class=" font-normal text-black dark:text-white"> <span class=" font-normal text-black dark:text-white">
{typingUsers.map((user) => user.name).join(', ')} {typingUsers.map((user) => user.name).join(', ')}
</span> </span>
{$i18n.t('is typing...')} {$i18n.t('is typing...')}
</div> </div>
</div>
</div>
{/if} {/if}
</div> </div>
</div> </div>
</div>
</div>
<div class=""> <div class="">
{#if recording} {#if recording}
@ -802,7 +828,7 @@
<div class="px-2.5"> <div class="px-2.5">
<div <div
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-80 overflow-auto" class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-96 overflow-auto"
> >
{#key $settings?.richTextInput} {#key $settings?.richTextInput}
<RichTextInput <RichTextInput
@ -811,7 +837,7 @@
json={true} json={true}
messageInput={true} messageInput={true}
richText={$settings?.richTextInput ?? true} richText={$settings?.richTextInput ?? true}
{showFormattingToolbar} showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
shiftEnter={!($settings?.ctrlEnterToSend ?? false) && shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile || (!$mobile ||
!( !(
@ -862,7 +888,37 @@
}} }}
on:paste={async (e) => { on:paste={async (e) => {
e = e.detail.event; e = e.detail.event;
console.info(e); console.log(e);
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const reader = new FileReader();
reader.onload = function (e) {
files = [
...files,
{
type: 'image',
url: `${e.target.result}`
}
];
};
reader.readAsDataURL(blob);
} else if (item?.kind === 'file') {
const file = item.getAsFile();
if (file) {
const _files = [file];
await inputFilesHandler(_files);
e.preventDefault();
}
}
}
}
}} }}
/> />
{/key} {/key}

View file

@ -46,7 +46,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-850 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition" class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-999 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={4} sideOffset={4}
alignOffset={-6} alignOffset={-6}
side="bottom" side="bottom"
@ -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 font-medium 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();
}} }}
@ -63,9 +63,8 @@
<div class="line-clamp-1">{$i18n.t('Upload Files')}</div> <div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{#if !$mobile}
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-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();
}} }}
@ -73,7 +72,6 @@
<Camera /> <Camera />
<div class=" line-clamp-1">{$i18n.t('Capture')}</div> <div class=" line-clamp-1">{$i18n.t('Capture')}</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{/if}
</DropdownMenu.Content> </DropdownMenu.Content>
</div> </div>
</Dropdown> </Dropdown>

View file

@ -1,41 +1,88 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte'; import { getContext, onDestroy, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { models } from '$lib/stores'; import { channels, models, user } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Hashtag from '$lib/components/icons/Hashtag.svelte';
import Lock from '$lib/components/icons/Lock.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { searchUsers } from '$lib/apis/users';
export let query = ''; export let query = '';
export let command: (payload: { id: string; label: string }) => void; export let command: (payload: { id: string; label: string }) => void;
export let selectedIndex = 0; export let selectedIndex = 0;
let items = []; export let label = '';
export let triggerChar = '@';
$: filteredItems = $models.filter((u) => u.name.toLowerCase().includes(query.toLowerCase())); export let modelSuggestions = false;
export let userSuggestions = false;
export let channelSuggestions = false;
let _models = [];
let _users = [];
let _channels = [];
$: filteredItems = [..._users, ..._models, ..._channels].filter(
(u) =>
u.label.toLowerCase().includes(query.toLowerCase()) ||
u.id.toLowerCase().includes(query.toLowerCase())
);
const getUserList = async () => {
const res = await searchUsers(localStorage.token, query).catch((error) => {
console.error('Error searching users:', error);
return null;
});
if (res) {
_users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) =>
a.label.localeCompare(b.label)
);
}
};
$: if (query !== null && userSuggestions) {
getUserList();
}
const select = (index: number) => { const select = (index: number) => {
const item = filteredItems[index]; const item = filteredItems[index];
// Add the "A:" prefix to the id to indicate it's an agent/assistant/ai model if (!item) return;
if (item) command({ id: `A:${item.id}|${item.name}`, label: item.name });
// Add the "U:", "M:" or "C:" prefix to the id
// and also append the label after a pipe |
// so that the mention renderer can show the label
if (item)
command({
id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : 'C'}:${item.id}|${item.label}`,
label: item.label
});
}; };
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false; if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
selectedIndex = (selectedIndex + filteredItems.length - 1) % filteredItems.length; selectedIndex = Math.max(0, selectedIndex - 1);
const item = document.querySelector(`[data-selected="true"]`); const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true; return true;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
selectedIndex = (selectedIndex + 1) % filteredItems.length; selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
const item = document.querySelector(`[data-selected="true"]`); const item = document.querySelector(`[data-selected="true"]`);
item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
return true; return true;
} }
if (event.key === 'Enter' || event.key === 'Tab') { if (event.key === 'Enter' || event.key === 'Tab') {
select(selectedIndex); select(selectedIndex);
if (event.key === 'Enter') {
event.preventDefault();
}
return true; return true;
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
@ -50,18 +97,57 @@
export function _onKeyDown(event: KeyboardEvent) { export function _onKeyDown(event: KeyboardEvent) {
return onKeyDown(event); return onKeyDown(event);
} }
const keydownListener = (e) => {
// required to prevent the default enter behavior
if (e.key === 'Enter') {
e.preventDefault();
select(selectedIndex);
}
};
onMount(async () => {
window.addEventListener('keydown', keydownListener);
if (channelSuggestions) {
// Add a dummy channel item
_channels = [
...$channels.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
];
} else {
if (userSuggestions) {
await getUserList();
}
if (modelSuggestions) {
_models = [...$models.map((m) => ({ type: 'model', id: m.id, label: m.name, data: m }))];
}
}
});
onDestroy(() => {
window.removeEventListener('keydown', keydownListener);
});
</script> </script>
{#if filteredItems.length} {#if filteredItems.length}
<div <div
class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-60 p-1" class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
id="suggestions-container" id="suggestions-container"
> >
<div class="overflow-y-auto scrollbar-thin max-h-60"> <div class="overflow-y-auto scrollbar-thin max-h-60">
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Models')}
</div>
{#each filteredItems as item, i} {#each filteredItems as item, i}
{#if i === 0 || item?.type !== filteredItems[i - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === 'user'}
{$i18n.t('Users')}
{:else if item?.type === 'model'}
{$i18n.t('Models')}
{:else if item?.type === 'channel'}
{$i18n.t('Channels')}
{/if}
</div>
{/if}
<Tooltip content={item?.id} placement="top-start"> <Tooltip content={item?.id} placement="top-start">
<button <button
type="button" type="button"
@ -69,13 +155,47 @@
on:mousemove={() => { on:mousemove={() => {
selectedIndex = i; selectedIndex = i;
}} }}
class="px-2.5 py-1.5 rounded-xl w-full text-left {i === selectedIndex class="flex items-center justify-between px-2.5 py-1.5 rounded-xl w-full text-left {i ===
selectedIndex
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button' ? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}" : ''}"
data-selected={i === selectedIndex} data-selected={i === selectedIndex}
> >
<div class="truncate"> {#if item.type === 'channel'}
@{item.name} <div class=" size-4 justify-center flex items-center mr-0.5">
{#if item?.data?.access_control === null}
<Hashtag className="size-3" strokeWidth="2.5" />
{:else}
<Lock className="size-[15px]" strokeWidth="2" />
{/if}
</div>
{:else if item.type === 'model'}
<img
src={item?.data?.info?.meta?.profile_image_url ??
`${WEBUI_BASE_URL}/static/favicon.png`}
alt={item?.data?.name ?? item.id}
class="rounded-full size-5 items-center mr-2"
/>
{:else if item.type === 'user'}
<img
src={`${WEBUI_API_BASE_URL}/users/${item.id}/profile/image`}
alt={item?.label ?? item.id}
class="rounded-full size-5 items-center mr-2"
/>
{/if}
<div class="truncate flex-1 pr-2">
{item.label}
</div>
<div class="shrink-0 text-xs text-gray-500">
{#if item.type === 'user'}
{$i18n.t('User')}
{:else if item.type === 'model'}
{$i18n.t('Model')}
{:else if item.type === 'channel'}
{$i18n.t('Channel')}
{/if}
</div> </div>
</button> </button>
</Tooltip> </Tooltip>

View file

@ -63,11 +63,7 @@
</div> </div>
</Loader> </Loader>
{:else if !thread} {:else if !thread}
<div <div class="px-5 max-w-full mx-auto">
class="px-5
{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
>
{#if channel} {#if channel}
<div class="flex flex-col gap-1.5 pb-5 pt-10"> <div class="flex flex-col gap-1.5 pb-5 pt-10">
<div class="text-2xl font-medium capitalize">{channel.name}</div> <div class="text-2xl font-medium capitalize">{channel.name}</div>
@ -99,7 +95,8 @@
{message} {message}
{thread} {thread}
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}
onDelete={() => { onDelete={() => {
messages = messages.filter((m) => m.id !== message.id); messages = messages.filter((m) => m.id !== message.id);

View file

@ -15,7 +15,7 @@
import { settings, user, shortCodesToEmojis } from '$lib/stores'; import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte'; import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
@ -34,6 +34,8 @@
import ChevronRight from '$lib/components/icons/ChevronRight.svelte'; import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import { formatDate } from '$lib/utils'; import { formatDate } from '$lib/utils';
import Emoji from '$lib/components/common/Emoji.svelte'; import Emoji from '$lib/components/common/Emoji.svelte';
import { t } from 'i18next';
import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
export let message; export let message;
export let showUserProfile = true; export let showUserProfile = true;
@ -64,9 +66,7 @@
<div <div
class="flex flex-col justify-between px-5 {showUserProfile class="flex flex-col justify-between px-5 {showUserProfile
? 'pt-1.5 pb-0.5' ? 'pt-1.5 pb-0.5'
: ''} w-full {($settings?.widescreenMode ?? null) : ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
? 'max-w-full'
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
> >
{#if !edit} {#if !edit}
<div <div
@ -140,15 +140,20 @@
> >
<div class={`shrink-0 mr-3 w-9`}> <div class={`shrink-0 mr-3 w-9`}>
{#if showUserProfile} {#if showUserProfile}
{#if message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
alt={message.meta.model_name ?? message.meta.model_id}
class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
/>
{:else}
<ProfilePreview user={message.user}> <ProfilePreview user={message.user}>
<ProfileImage <ProfileImage
src={message.user?.profile_image_url ?? src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
($i18n.language === 'dg-DG'
? `${WEBUI_BASE_URL}/doge.png`
: `${WEBUI_BASE_URL}/static/favicon.png`)}
className={'size-8 translate-y-1 ml-0.5'} className={'size-8 translate-y-1 ml-0.5'}
/> />
</ProfilePreview> </ProfilePreview>
{/if}
{:else} {:else}
<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> --> <!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
@ -168,7 +173,11 @@
{#if showUserProfile} {#if showUserProfile}
<Name> <Name>
<div class=" self-end text-base shrink-0 font-medium truncate"> <div class=" self-end text-base shrink-0 font-medium truncate">
{#if message?.meta?.model_id}
{message?.meta?.model_name ?? message?.meta?.model_id}
{:else}
{message?.user?.name} {message?.user?.name}
{/if}
</div> </div>
{#if message.created_at} {#if message.created_at}
@ -256,12 +265,16 @@
</div> </div>
{:else} {:else}
<div class=" min-w-full markdown-prose"> <div class=" min-w-full markdown-prose">
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
<Skeleton />
{:else}
<Markdown <Markdown
id={message.id} id={message.id}
content={message.content} content={message.content}
/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]" />{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
>(edited)</span class="text-gray-500 text-[10px]">({$i18n.t('edited')})</span
>{/if} >{/if}
{/if}
</div> </div>
{#if (message?.reactions ?? []).length > 0} {#if (message?.reactions ?? []).length > 0}

View file

@ -1,101 +1,18 @@
<script lang="ts"> <script lang="ts">
import { DropdownMenu } from 'bits-ui'; import { LinkPreview } from 'bits-ui';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import UserStatus from './UserStatus.svelte';
import { flyAndScale } from '$lib/utils/transitions'; import UserStatusLinkPreview from './UserStatusLinkPreview.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getUserActiveStatusById } from '$lib/apis/users';
export let side = 'right';
export let align = 'top';
export let user = null; export let user = null;
let show = false;
let active = false;
const getActiveStatus = async () => {
const res = await getUserActiveStatusById(localStorage.token, user.id).catch((error) => {
console.error('Error fetching user active status:', error);
});
if (res) {
active = res.active;
} else {
active = false;
}
};
$: if (show) {
getActiveStatus();
}
</script> </script>
<DropdownMenu.Root <LinkPreview.Root openDelay={0} closeDelay={0}>
bind:open={show} <LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
closeFocus={false}
onOpenChange={(state) => {}}
typeahead={false}
>
<DropdownMenu.Trigger>
<slot /> <slot />
</DropdownMenu.Trigger> </LinkPreview.Trigger>
<slot name="content"> <UserStatusLinkPreview id={user?.id} side="right" align="center" sideOffset={8} />
<DropdownMenu.Content </LinkPreview.Root>
class="max-w-full w-[240px] rounded-lg z-9999 bg-white dark:bg-black dark:text-white shadow-lg"
sideOffset={8}
{side}
{align}
transition={flyAndScale}
>
{#if user}
<div class=" flex flex-col gap-2 w-full rounded-lg">
<div class="py-8 relative bg-gray-900 rounded-t-lg">
<img
crossorigin="anonymous"
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
class=" absolute -bottom-5 left-3 size-12 ml-0.5 object-cover rounded-full -translate-y-[1px]"
alt="profile"
/>
</div>
<div class=" flex flex-col pt-4 pb-2.5 px-4">
<div class=" -mb-1">
<span class="font-medium text-sm line-clamp-1"> {user.name} </span>
</div>
<div class=" flex items-center gap-2">
{#if active}
<div>
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<div class=" -translate-y-[1px]">
<span class="text-xs"> {$i18n.t('Active')} </span>
</div>
{:else}
<div>
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
</span>
</div>
<div class=" -translate-y-[1px]">
<span class="text-xs"> {$i18n.t('Away')} </span>
</div>
{/if}
</div>
</div>
</div>
{/if}
</DropdownMenu.Content>
</slot>
</DropdownMenu.Root>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_BASE_URL } from '$lib/constants';
export let user = null;
</script>
{#if user}
<div class=" flex gap-3.5 w-full py-3 px-3 items-center">
<div class=" items-center flex shrink-0">
<img
crossorigin="anonymous"
src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
class=" size-12 object-cover rounded-xl"
alt="profile"
/>
</div>
<div class=" flex flex-col w-full flex-1">
<div class="mb-0.5 font-medium line-clamp-1 pr-2">
{user.name}
</div>
<div class=" flex items-center gap-2">
{#if user?.active}
<div>
<span class="relative flex size-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
/>
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<span class="text-xs"> {$i18n.t('Active')} </span>
{:else}
<div>
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
</span>
</div>
<span class="text-xs"> {$i18n.t('Away')} </span>
{/if}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { LinkPreview } from 'bits-ui';
const i18n = getContext('i18n');
import { getUserById } from '$lib/apis/users';
import UserStatus from './UserStatus.svelte';
export let id = null;
export let side = 'top';
export let align = 'start';
export let sideOffset = 6;
let user = null;
onMount(async () => {
if (id) {
user = await getUserById(localStorage.token, id).catch((error) => {
console.error('Error fetching user by ID:', error);
return null;
});
}
});
</script>
{#if user}
<LinkPreview.Content
class="w-full max-w-[260px] rounded-2xl border border-gray-100 dark:border-gray-800 z-999 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
{side}
{align}
{sideOffset}
>
<UserStatus {user} />
</LinkPreview.Content>
{/if}

View file

@ -159,7 +159,7 @@
{#if channel} {#if channel}
<div class="flex flex-col w-full h-full bg-gray-50 dark:bg-gray-850"> <div class="flex flex-col w-full h-full bg-gray-50 dark:bg-gray-850">
<div class="flex items-center justify-between px-3.5 pt-3"> <div class="sticky top-0 flex items-center justify-between px-3.5 py-3">
<div class=" font-medium text-lg">{$i18n.t('Thread')}</div> <div class=" font-medium text-lg">{$i18n.t('Thread')}</div>
<div> <div>
@ -174,7 +174,7 @@
</div> </div>
</div> </div>
<div class=" max-h-full w-full overflow-y-auto pt-3" bind:this={messagesContainerElement}> <div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}>
<Messages <Messages
id={threadId} id={threadId}
{channel} {channel}
@ -198,8 +198,16 @@
}} }}
/> />
<div class=" pb-[1rem] px-2.5"> <div class=" pb-[1rem] px-2.5 w-full">
<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} /> <MessageInput
id={threadId}
typingUsersClassName="from-gray-50 dark:from-gray-850"
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
{onChange}
onSubmit={submitHandler}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -12,7 +12,7 @@
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import SvgPanZoom from '../common/SVGPanZoom.svelte'; import SvgPanZoom from '../common/SVGPanZoom.svelte';
import ArrowLeft from '../icons/ArrowLeft.svelte'; import ArrowLeft from '../icons/ArrowLeft.svelte';
import ArrowDownTray from '../icons/ArrowDownTray.svelte'; import Download from '../icons/Download.svelte';
export let overlay = false; export let overlay = false;
export let history; export let history;
@ -205,7 +205,7 @@
</script> </script>
<div <div
class=" w-full h-full relative flex flex-col bg-gray-50 dark:bg-gray-850" class=" w-full h-full relative flex flex-col bg-white dark:bg-gray-850"
id="artifacts-container" id="artifacts-container"
> >
<div class="w-full h-full flex flex-col flex-1 relative"> <div class="w-full h-full flex flex-col flex-1 relative">
@ -213,15 +213,6 @@
<div <div
class="pointer-events-auto z-20 flex justify-between items-center p-2.5 font-primar text-gray-900 dark:text-white" class="pointer-events-auto z-20 flex justify-between items-center p-2.5 font-primar text-gray-900 dark:text-white"
> >
<button
class="self-center pointer-events-auto p-1 rounded-full bg-white dark:bg-gray-850"
on:click={() => {
showArtifacts.set(false);
}}
>
<ArrowLeft className="size-3.5 text-gray-900 dark:text-white" />
</button>
<div class="flex-1 flex items-center justify-between pr-1"> <div class="flex-1 flex items-center justify-between pr-1">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr"> <div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
@ -294,7 +285,7 @@
class=" bg-none border-none text-xs bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md p-0.5" class=" bg-none border-none text-xs bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md p-0.5"
on:click={downloadArtifact} on:click={downloadArtifact}
> >
<ArrowDownTray className="size-3.5" /> <Download className="size-3.5" />
</button> </button>
</Tooltip> </Tooltip>

View file

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import mermaid from 'mermaid';
import { PaneGroup, Pane, PaneResizer } from 'paneforge'; import { PaneGroup, Pane, PaneResizer } from 'paneforge';
import { getContext, onDestroy, onMount, tick } from 'svelte'; import { getContext, onDestroy, onMount, tick } from 'svelte';
@ -90,6 +89,7 @@
import Tooltip from '../common/Tooltip.svelte'; import Tooltip from '../common/Tooltip.svelte';
import Sidebar from '../icons/Sidebar.svelte'; import Sidebar from '../icons/Sidebar.svelte';
import { getFunctions } from '$lib/apis/functions'; import { getFunctions } from '$lib/apis/functions';
import Image from '../common/Image.svelte';
export let chatIdProp = ''; export let chatIdProp = '';
@ -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);
@ -1788,6 +1788,23 @@
})) }))
.filter((message) => message?.role === 'user' || message?.content?.trim()); .filter((message) => message?.role === 'user' || message?.content?.trim());
const toolIds = [];
const toolServerIds = [];
for (const toolId of selectedToolIds) {
if (toolId.startsWith('direct_server:')) {
let serverId = toolId.replace('direct_server:', '');
// Check if serverId is a number
if (!isNaN(parseInt(serverId))) {
toolServerIds.push(parseInt(serverId));
} else {
toolServerIds.push(serverId);
}
} else {
toolIds.push(toolId);
}
}
const res = await generateOpenAIChatCompletion( const res = await generateOpenAIChatCompletion(
localStorage.token, localStorage.token,
{ {
@ -1808,8 +1825,10 @@
files: (files?.length ?? 0) > 0 ? files : undefined, files: (files?.length ?? 0) > 0 ? files : undefined,
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined, tool_ids: toolIds.length > 0 ? toolIds : undefined,
tool_servers: $toolServers, tool_servers: ($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
),
features: getFeatures(), features: getFeatures(),
variables: { variables: {
...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined) ...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined)
@ -2244,7 +2263,18 @@
> >
{#if !loading} {#if !loading}
<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col"> <div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
{#if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null} {#if $selectedFolder && $selectedFolder?.meta?.background_image_url}
<div
class="absolute {$showSidebar
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
style="background-image: url({$selectedFolder?.meta?.background_image_url}) "
/>
<div
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
/>
{:else if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null}
<div <div
class="absolute {$showSidebar class="absolute {$showSidebar
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]' ? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
@ -2259,7 +2289,7 @@
{/if} {/if}
<PaneGroup direction="horizontal" class="w-full h-full"> <PaneGroup direction="horizontal" class="w-full h-full">
<Pane defaultSize={50} class="h-full flex relative max-w-full flex-col"> <Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
<Navbar <Navbar
bind:this={navbarElement} bind:this={navbarElement}
chat={{ chat={{

View file

@ -205,12 +205,12 @@
{#if $showControls} {#if $showControls}
<PaneResizer <PaneResizer
class="relative flex w-2 items-center justify-center bg-background group" class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="controls-resizer" id="controls-resizer"
> >
<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs"> <div
<EllipsisVertical className="size-4 invisible group-hover:visible" /> class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
</div> />
</PaneResizer> </PaneResizer>
{/if} {/if}
@ -236,14 +236,14 @@
showControls.set(false); showControls.set(false);
}} }}
collapsible={true} collapsible={true}
class=" z-10 " class=" z-10 bg-white dark:bg-gray-850"
> >
{#if $showControls} {#if $showControls}
<div class="flex max-h-full min-h-full"> <div class="flex max-h-full min-h-full">
<div <div
class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
? ' ' ? ' '
: 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 border border-gray-100 dark:border-gray-850'} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden" : 'px-4 py-4 bg-white dark:shadow-lg dark:bg-gray-850 '} z-40 pointer-events-auto overflow-y-auto scrollbar-hidden"
id="controls-container" id="controls-container"
> >
{#if $showCallOverlay} {#if $showCallOverlay}

View file

@ -74,14 +74,14 @@
className="w-full flex justify-start mb-0.5" className="w-full flex justify-start mb-0.5"
placement="top" placement="top"
> >
<div class="flex items-center gap-2 text-gray-500 font-medium text-lg mt-2 w-fit"> <div class="flex items-center gap-2 text-gray-500 text-lg mt-2 w-fit">
<EyeSlash strokeWidth="2.5" className="size-5" />{$i18n.t('Temporary Chat')} <EyeSlash strokeWidth="2.5" className="size-5" />{$i18n.t('Temporary Chat')}
</div> </div>
</Tooltip> </Tooltip>
{/if} {/if}
<div <div
class=" mt-2 mb-4 text-3xl text-gray-800 dark:text-gray-100 font-medium text-left flex items-center gap-4 font-primary" class=" mt-2 mb-4 text-3xl text-gray-800 dark:text-gray-100 text-left flex items-center gap-4 font-primary"
> >
<div> <div>
<div class=" capitalize line-clamp-1" in:fade={{ duration: 200 }}> <div class=" capitalize line-clamp-1" in:fade={{ duration: 200 }}>
@ -120,7 +120,7 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<div class=" font-medium text-gray-400 dark:text-gray-500 line-clamp-1 font-p"> <div class=" text-gray-400 dark:text-gray-500 line-clamp-1 font-p">
{$i18n.t('How can I help you today?')} {$i18n.t('How can I help you today?')}
</div> </div>
{/if} {/if}

View file

@ -172,7 +172,7 @@
>{$i18n.t('Select a tool')}</option >{$i18n.t('Select a tool')}</option
> >
{#each $tools as tool, toolIdx} {#each $tools.filter((tool) => !tool?.id?.startsWith('server:')) as tool, toolIdx}
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option> <option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option>
{/each} {/each}
{:else if tab === 'functions'} {:else if tab === 'functions'}

View file

@ -15,11 +15,11 @@
type Model, type Model,
mobile, mobile,
settings, settings,
showSidebar,
models, models,
config, config,
showCallOverlay, showCallOverlay,
tools, tools,
toolServers,
user as _user, user as _user,
showControls, showControls,
TTSWorker, TTSWorker,
@ -45,6 +45,7 @@
import { generateAutoCompletion } from '$lib/apis'; import { generateAutoCompletion } from '$lib/apis';
import { deleteFileById } from '$lib/apis/files'; import { deleteFileById } from '$lib/apis/files';
import { getSessionUser } from '$lib/apis/auths'; import { getSessionUser } from '$lib/apis/auths';
import { getTools } from '$lib/apis/tools';
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
@ -99,8 +100,6 @@
export let prompt = ''; export let prompt = '';
export let files = []; export let files = [];
export let toolServers = [];
export let selectedToolIds = []; export let selectedToolIds = [];
export let selectedFilterIds = []; export let selectedFilterIds = [];
@ -284,12 +283,17 @@
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
if (chatInput) { if (chatInput) {
if (text !== '') {
text = await textVariableHandler(text || ''); text = await textVariableHandler(text || '');
}
chatInputElement?.setText(text); chatInputElement?.setText(text);
chatInputElement?.focus(); chatInputElement?.focus();
if (text !== '') {
text = await inputVariableHandler(text); text = await inputVariableHandler(text);
}
await tick(); await tick();
if (cb) await cb(text); if (cb) await cb(text);
} }
@ -437,7 +441,7 @@
.reduce((acc, filters) => acc.filter((f1) => filters.some((f2) => f2.id === f1.id))); .reduce((acc, filters) => acc.filter((f1) => filters.some((f2) => f2.id === f1.id)));
let showToolsButton = false; let showToolsButton = false;
$: showToolsButton = toolServers.length + selectedToolIds.length > 0; $: showToolsButton = ($tools ?? []).length > 0 || ($toolServers ?? []).length > 0;
let showWebSearchButton = false; let showWebSearchButton = false;
$: showWebSearchButton = $: showWebSearchButton =
@ -897,6 +901,8 @@
dropzoneElement?.addEventListener('dragover', onDragOver); dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop); dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave); dropzoneElement?.addEventListener('dragleave', onDragLeave);
await tools.set(await getTools(localStorage.token));
}); });
onDestroy(() => { onDestroy(() => {
@ -1024,7 +1030,9 @@
}} }}
> >
<div <div
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 {$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-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'}
id="message-input-container" id="message-input-container"
> >
@ -1152,7 +1160,7 @@
<div class="px-2.5"> <div class="px-2.5">
<div <div
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pb-1 px-1 resize-none h-fit max-h-80 overflow-auto {files.length === class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pb-1 px-1 resize-none h-fit max-h-96 overflow-auto {files.length ===
0 0
? atSelectedModel !== undefined ? atSelectedModel !== undefined
? 'pt-1.5' ? 'pt-1.5'
@ -1451,10 +1459,10 @@
{/if} {/if}
<div class="ml-1 flex gap-1.5"> <div class="ml-1 flex gap-1.5">
{#if showToolsButton} {#if (selectedToolIds ?? []).length > 0}
<Tooltip <Tooltip
content={$i18n.t('{{COUNT}} Available Tools', { content={$i18n.t('{{COUNT}} Available Tools', {
COUNT: toolServers.length + selectedToolIds.length COUNT: selectedToolIds.length
})} })}
> >
<button <button
@ -1468,7 +1476,7 @@
<Wrench className="size-4" strokeWidth="1.75" /> <Wrench className="size-4" strokeWidth="1.75" />
<span class="text-sm"> <span class="text-sm">
{toolServers.length + selectedToolIds.length} {selectedToolIds.length}
</span> </span>
</button> </button>
</Tooltip> </Tooltip>

View file

@ -26,7 +26,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[180px] rounded-lg px-1 py-1.5 border border-gray-100 dark:border-gray-800 z-9999 bg-white dark:bg-gray-900 dark:text-white shadow-xs" class="w-full max-w-[180px] rounded-lg p-1 border border-gray-100 dark:border-gray-800 z-9999 bg-white dark:bg-gray-900 dark:text-white shadow-xs"
sideOffset={6} sideOffset={6}
side="top" side="top"
align="start" align="start"

View file

@ -120,7 +120,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 +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={() => {
@ -176,7 +176,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={() => {
@ -207,7 +207,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={() => {
@ -238,7 +238,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,7 +263,7 @@
{#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();
}} }}
@ -298,10 +298,10 @@
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
{#if $config?.features?.enable_onedrive_integration} {#if $config?.features?.enable_onedrive_integration && ($config?.features?.enable_onedrive_personal || $config?.features?.enable_onedrive_business)}
<DropdownMenu.Sub> <DropdownMenu.Sub>
<DropdownMenu.SubTrigger <DropdownMenu.SubTrigger
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" 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 w-full"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -396,25 +396,34 @@
sideOffset={$mobile ? 5 : 0} sideOffset={$mobile ? 5 : 0}
alignOffset={$mobile ? 0 : -8} alignOffset={$mobile ? 0 : -8}
> >
{#if $config?.features?.enable_onedrive_personal}
<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={() => {
uploadOneDriveHandler('personal'); uploadOneDriveHandler('personal');
}} }}
> >
<div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div> <div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</div>
</div>
</DropdownMenu.Item> </DropdownMenu.Item>
{/if}
{#if $config?.features?.enable_onedrive_business}
<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={() => {
uploadOneDriveHandler('organizations'); uploadOneDriveHandler('organizations');
}} }}
> >
<div class="flex flex-col"> <div class="flex flex-col">
<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (work/school)')}</div> <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 class="text-xs text-gray-500">{$i18n.t('Includes SharePoint')}</div>
</div> </div>
</DropdownMenu.Item> </DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
{/if} {/if}
@ -423,7 +432,7 @@
{: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 = '';
}} }}
@ -442,7 +451,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 = '';
}} }}
@ -461,7 +470,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 = '';
}} }}

View file

@ -84,7 +84,7 @@
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{variable} {variable}
{#if variables[variable]?.required ?? true} {#if variables[variable]?.required ?? false}
<span class=" text-gray-500">*{$i18n.t('required')}</span> <span class=" text-gray-500">*{$i18n.t('required')}</span>
{/if} {/if}
</div> </div>
@ -134,7 +134,7 @@
placeholder={$i18n.t('Enter value (true/false)')} placeholder={$i18n.t('Enter value (true/false)')}
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
required required={variables[variable]?.required ?? false}
/> />
</div> </div>
{:else if variables[variable]?.type === 'color'} {:else if variables[variable]?.type === 'color'}
@ -159,7 +159,7 @@
placeholder={$i18n.t('Enter hex color (e.g. #FF0000)')} placeholder={$i18n.t('Enter hex color (e.g. #FF0000)')}
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
required required={variables[variable]?.required ?? false}
/> />
</div> </div>
{:else if variables[variable]?.type === 'date'} {:else if variables[variable]?.type === 'date'}
@ -170,7 +170,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'datetime-local'} {:else if variables[variable]?.type === 'datetime-local'}
@ -181,7 +181,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'email'} {:else if variables[variable]?.type === 'email'}
@ -192,7 +192,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'month'} {:else if variables[variable]?.type === 'month'}
@ -203,7 +203,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'number'} {:else if variables[variable]?.type === 'number'}
@ -214,7 +214,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'range'} {:else if variables[variable]?.type === 'range'}
@ -235,7 +235,7 @@
placeholder={$i18n.t('Enter value')} placeholder={$i18n.t('Enter value')}
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
required required={variables[variable]?.required ?? false}
/> />
</div> </div>
@ -256,7 +256,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'text'} {:else if variables[variable]?.type === 'text'}
@ -267,7 +267,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'time'} {:else if variables[variable]?.type === 'time'}
@ -278,7 +278,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'url'} {:else if variables[variable]?.type === 'url'}
@ -289,7 +289,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
{...variableAttributes} {...variableAttributes}
/> />
{:else if variables[variable]?.type === 'map'} {:else if variables[variable]?.type === 'map'}
@ -311,7 +311,7 @@
placeholder={$i18n.t('Enter coordinates (e.g. 51.505, -0.09)')} placeholder={$i18n.t('Enter coordinates (e.g. 51.505, -0.09)')}
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
required required={variables[variable]?.required ?? false}
/> />
</div> </div>
{:else} {:else}
@ -321,7 +321,7 @@
bind:value={variableValues[variable]} bind:value={variableValues[variable]}
autocomplete="off" autocomplete="off"
id="input-variable-{idx}" id="input-variable-{idx}"
required required={variables[variable]?.required ?? false}
/> />
{/if} {/if}
</div> </div>

View file

@ -4,7 +4,7 @@
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { flyAndScale } from '$lib/utils/transitions'; import { flyAndScale } from '$lib/utils/transitions';
import { config, user, tools as _tools, mobile, settings } from '$lib/stores'; import { config, user, tools as _tools, mobile, settings, toolServers } from '$lib/stores';
import { getTools } from '$lib/apis/tools'; import { getTools } from '$lib/apis/tools';
@ -55,7 +55,10 @@
($user?.role === 'admin' || $user?.permissions?.chat?.file_upload); ($user?.role === 'admin' || $user?.permissions?.chat?.file_upload);
const init = async () => { const init = async () => {
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token)); await _tools.set(await getTools(localStorage.token));
}
if ($_tools) { if ($_tools) {
tools = $_tools.reduce((a, tool, i, arr) => { tools = $_tools.reduce((a, tool, i, arr) => {
a[tool.id] = { a[tool.id] = {
@ -65,8 +68,22 @@
}; };
return a; return a;
}, {}); }, {});
selectedToolIds = selectedToolIds.filter((id) => $_tools?.some((tool) => tool.id === id));
} }
if ($toolServers) {
for (const serverIdx in $toolServers) {
const server = $toolServers[serverIdx];
if (server.info) {
tools[`direct_server:${serverIdx}`] = {
name: server?.info?.title ?? server.url,
description: server.info.description ?? '',
enabled: selectedToolIds.includes(`direct_server:${serverIdx}`)
};
}
}
}
selectedToolIds = selectedToolIds.filter((id) => Object.keys(tools).includes(id));
}; };
</script> </script>
@ -95,7 +112,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';
}} }}
@ -124,7 +141,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);
@ -173,7 +190,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;
}} }}
@ -204,7 +221,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;
}} }}
@ -235,7 +252,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')
@ -270,7 +287,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 = '';
}} }}
@ -287,7 +304,7 @@
{#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="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={() => {
tools[toolId].enabled = !tools[toolId].enabled; tools[toolId].enabled = !tools[toolId].enabled;
}} }}

View file

@ -454,7 +454,7 @@
{/each} {/each}
</ul> </ul>
</section> </section>
<div class="pb-12" /> <div class="pb-18" />
{#if bottomPadding} {#if bottomPadding}
<div class=" pb-6" /> <div class=" pb-6" />
{/if} {/if}

View file

@ -152,7 +152,7 @@
{#each citations as citation, idx} {#each citations as citation, idx}
<button <button
id={`source-${id}-${idx + 1}`} id={`source-${id}-${idx + 1}`}
class="no-toggle outline-hidden flex dark:text-gray-300 bg-white dark:bg-gray-900 text-gray-600 rounded-xl gap-1.5 items-center" class="no-toggle outline-hidden flex dark:text-gray-300 bg-transparent text-gray-600 rounded-xl gap-1.5 items-center"
on:click={() => { on:click={() => {
showCitationModal = true; showCitationModal = true;
selectedCitation = citation; selectedCitation = citation;

View file

@ -1,17 +1,12 @@
<script lang="ts"> <script lang="ts">
import hljs from 'highlight.js'; import hljs from 'highlight.js';
import mermaid from 'mermaid';
import { v4 as uuidv4 } from 'uuid';
import { getContext, onMount, tick, onDestroy } from 'svelte'; import { getContext, onMount, tick, onDestroy } from 'svelte';
import { copyToClipboard } 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 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 { config } from '$lib/stores';
import { executeCode } from '$lib/apis/utils'; import { executeCode } from '$lib/apis/utils';
@ -40,7 +35,7 @@
export let code = ''; export let code = '';
export let attributes = {}; export let attributes = {};
export let className = 'my-2'; export let className = 'mb-2';
export let editorClassName = ''; export let editorClassName = '';
export let stickyButtonsClassName = 'top-0'; export let stickyButtonsClassName = 'top-0';
@ -167,9 +162,9 @@
]; ];
} }
if (stdout.startsWith(`${line}\n`)) { if (stdout.includes(`${line}\n`)) {
stdout = stdout.replace(`${line}\n`, ``); stdout = stdout.replace(`${line}\n`, ``);
} else if (stdout.startsWith(`${line}`)) { } else if (stdout.includes(`${line}`)) {
stdout = stdout.replace(`${line}`, ``); stdout = stdout.replace(`${line}`, ``);
} }
} }
@ -196,9 +191,9 @@
]; ];
} }
if (result.startsWith(`${line}\n`)) { if (result.includes(`${line}\n`)) {
result = result.replace(`${line}\n`, ``); result = result.replace(`${line}\n`, ``);
} else if (result.startsWith(`${line}`)) { } else if (result.includes(`${line}`)) {
result = result.replace(`${line}`, ``); result = result.replace(`${line}`, ``);
} }
} }
@ -275,9 +270,9 @@
]; ];
} }
if (stdout.startsWith(`${line}\n`)) { if (stdout.includes(`${line}\n`)) {
stdout = stdout.replace(`${line}\n`, ``); stdout = stdout.replace(`${line}\n`, ``);
} else if (stdout.startsWith(`${line}`)) { } else if (stdout.includes(`${line}`)) {
stdout = stdout.replace(`${line}`, ``); stdout = stdout.replace(`${line}`, ``);
} }
} }
@ -325,27 +320,11 @@
}; };
}; };
let debounceTimeout;
const drawMermaidDiagram = async () => {
try {
if (await mermaid.parse(code)) {
const { svg } = await mermaid.render(`mermaid-${uuidv4()}`, code);
mermaidHtml = svg;
}
} catch (error) {
console.log('Error:', error);
}
};
const render = async () => { const render = async () => {
if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
(async () => {
await drawMermaidDiagram();
})();
}
onUpdate(token); onUpdate(token);
if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
mermaidHtml = await renderMermaidDiagram(code);
}
}; };
$: if (token) { $: if (token) {
@ -392,20 +371,6 @@
if (token) { if (token) {
onUpdate(token); onUpdate(token);
} }
if (document.documentElement.classList.contains('dark')) {
mermaid.initialize({
startOnLoad: true,
theme: 'dark',
securityLevel: 'loose'
});
} else {
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
});
}
}); });
onDestroy(() => { onDestroy(() => {
@ -416,11 +381,14 @@
</script> </script>
<div> <div>
<div class="relative {className} flex flex-col rounded-xl pt-2" dir="ltr"> <div
class="relative {className} flex flex-col rounded-3xl border border-gray-100 dark:border-gray-850 my-0.5"
dir="ltr"
>
{#if lang === 'mermaid'} {#if lang === 'mermaid'}
{#if mermaidHtml} {#if mermaidHtml}
<SvgPanZoom <SvgPanZoom
className=" border border-gray-100 dark:border-gray-850 rounded-xl max-h-fit overflow-hidden" className=" rounded-3xl max-h-fit overflow-hidden"
svg={mermaidHtml} svg={mermaidHtml}
content={_token.text} content={_token.text}
/> />
@ -428,16 +396,18 @@
<pre class="mermaid">{code}</pre> <pre class="mermaid">{code}</pre>
{/if} {/if}
{:else} {:else}
<div class="text-text-300 absolute pl-4 text-xs font-medium dark:text-white"> <div
class="absolute left-0 right-0 py-2.5 pr-3 text-text-300 pl-4.5 text-xs font-medium dark:text-white"
>
{lang} {lang}
</div> </div>
<div <div
class="sticky {stickyButtonsClassName} mb-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white" class="sticky {stickyButtonsClassName} left-0 right-0 py-2 pr-3 flex items-center justify-end w-full z-10 text-xs text-black dark:text-white"
> >
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
class="flex gap-1 items-center bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5" class="flex gap-1 items-center bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
on:click={collapseCodeBlock} on:click={collapseCodeBlock}
> >
<div class=" -translate-y-[0.5px]"> <div class=" -translate-y-[0.5px]">
@ -449,39 +419,22 @@
</div> </div>
</button> </button>
{#if preview && ['html', 'svg'].includes(lang)}
<button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5"
on:click={previewCode}
>
<div class=" -translate-y-[0.5px]">
<Cube className="size-3" />
</div>
<div>
{$i18n.t('Preview')}
</div>
</button>
{/if}
{#if ($config?.features?.enable_code_execution ?? true) && (lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code)))} {#if ($config?.features?.enable_code_execution ?? true) && (lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code)))}
{#if executing} {#if executing}
<div class="run-code-button bg-none border-none p-1 cursor-not-allowed"> <div
class="run-code-button bg-none border-none p-0.5 cursor-not-allowed bg-white dark:bg-black"
>
{$i18n.t('Running')} {$i18n.t('Running')}
</div> </div>
{:else if run} {:else if run}
<button <button
class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5" class="flex gap-1 items-center run-code-button bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
on:click={async () => { on:click={async () => {
code = _code; code = _code;
await tick(); await tick();
executePython(code); executePython(code);
}} }}
> >
<div class=" -translate-y-[0.5px]">
<CommandLine className="size-3" />
</div>
<div> <div>
{$i18n.t('Run')} {$i18n.t('Run')}
</div> </div>
@ -491,7 +444,7 @@
{#if save} {#if save}
<button <button
class="save-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5" class="save-code-button bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
on:click={saveCode} on:click={saveCode}
> >
{saved ? $i18n.t('Saved') : $i18n.t('Save')} {saved ? $i18n.t('Saved') : $i18n.t('Save')}
@ -499,23 +452,35 @@
{/if} {/if}
<button <button
class="copy-code-button bg-none border-none bg-gray-50 dark:bg-black transition rounded-md px-1.5 py-0.5" class="copy-code-button bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button
> >
{#if preview && ['html', 'svg'].includes(lang)}
<button
class="flex gap-1 items-center run-code-button bg-none border-none transition rounded-md px-1.5 py-0.5 bg-white dark:bg-black"
on:click={previewCode}
>
<div>
{$i18n.t('Preview')}
</div>
</button>
{/if}
</div> </div>
</div> </div>
<div <div
class="language-{lang} rounded-t-xl -mt-8 {editorClassName class="language-{lang} rounded-t-3xl -mt-9 {editorClassName
? editorClassName ? editorClassName
: executing || stdout || stderr || result : executing || stdout || stderr || result
? '' ? ''
: 'rounded-b-xl'} overflow-hidden" : 'rounded-b-3xl'} overflow-hidden"
> >
<div class=" pt-8 bg-gray-50 dark:bg-black"></div> <div class=" pt-8 bg-white dark:bg-black"></div>
{#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}
@ -527,6 +492,7 @@
_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"
@ -542,7 +508,7 @@
{/if} {/if}
{:else} {:else}
<div <div
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-xl! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs" class="bg-white dark:bg-black dark:text-white rounded-b-3xl! pt-0.5 pb-3 px-4 flex flex-col gap-2 text-xs"
> >
<span class="text-gray-500 italic"> <span class="text-gray-500 italic">
{$i18n.t('{{COUNT}} hidden lines', { {$i18n.t('{{COUNT}} hidden lines', {
@ -561,7 +527,7 @@
{#if executing || stdout || stderr || result || files} {#if executing || stdout || stderr || result || files}
<div <div
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-xl! py-4 px-4 flex flex-col gap-2" class="bg-gray-50 dark:bg-black dark:text-white rounded-b-3xl! py-4 px-4 flex flex-col gap-2"
> >
{#if executing} {#if executing}
<div class=" "> <div class=" ">

View file

@ -38,7 +38,9 @@
marked.use(markedKatexExtension(options)); marked.use(markedKatexExtension(options));
marked.use(markedExtension(options)); marked.use(markedExtension(options));
marked.use({ extensions: [mentionExtension({ triggerChar: '@' })] }); marked.use({
extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })]
});
$: (async () => { $: (async () => {
if (content) { if (content) {

View file

@ -1,10 +1,22 @@
<script lang="ts"> <script lang="ts">
import katex from 'katex'; import type { renderToString as katexRenderToString } from 'katex';
import 'katex/contrib/mhchem'; import { onMount } from 'svelte';
import 'katex/dist/katex.min.css';
export let content: string; export let content: string;
export let displayMode: boolean = false; export let displayMode: boolean = false;
let renderToString: typeof katexRenderToString | null = null;
onMount(async () => {
const [katex] = await Promise.all([
import('katex'),
import('katex/contrib/mhchem'),
import('katex/dist/katex.min.css')
]);
renderToString = katex.renderToString;
});
</script> </script>
{@html katex.renderToString(content, { displayMode, throwOnError: false })} {#if renderToString}
{@html renderToString(content, { displayMode, throwOnError: false })}
{/if}

View file

@ -1,10 +1,108 @@
<script lang="ts"> <script lang="ts">
import type { Token } from 'marked'; import type { Token } from 'marked';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import { LinkPreview } from 'bits-ui';
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { channels, models } from '$lib/stores';
import UserStatus from '$lib/components/channel/Messages/Message/UserStatus.svelte';
import UserStatusLinkPreview from '$lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte';
const i18n = getContext('i18n');
export let token: Token; export let token: Token;
let triggerChar = '';
let label = '';
let idType = null;
let id = '';
$: if (token) {
init();
}
const init = () => {
const _id = token?.id;
// split by : and take first part as idType and second part as id
const parts = _id?.split(':');
if (parts) {
idType = parts[0];
id = parts.slice(1).join(':'); // in case id contains ':'
} else {
idType = null;
id = _id;
}
label = token?.label ?? id;
triggerChar = token?.triggerChar ?? '@';
if (triggerChar === '#') {
if (idType === 'C') {
// Channel
const channel = $channels.find((c) => c.id === id);
if (channel) {
label = channel.name;
} else {
label = $i18n.t('Unknown');
}
} else if (idType === 'T') {
// Thread
}
} else if (triggerChar === '@') {
if (idType === 'U') {
// User
} else if (idType === 'M') {
// Model
const model = $models.find((m) => m.id === id);
if (model) {
label = model.name;
} else {
label = $i18n.t('Unknown');
}
}
}
};
</script> </script>
<Tooltip as="span" className="mention" content={token.id} placement="top"> <LinkPreview.Root openDelay={0} closeDelay={0}>
{token?.triggerChar ?? '@'}{token?.label ?? token?.id} <LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
</Tooltip> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="mention"
on:click={async () => {
if (triggerChar === '@') {
if (idType === 'U') {
// Open user profile
console.log('Clicked user mention', id);
} else if (idType === 'M') {
console.log('Clicked model mention', id);
await goto(`/?model=${id}`);
}
} else if (triggerChar === '#') {
if (idType === 'C') {
// Open channel
if ($channels.find((c) => c.id === id)) {
await goto(`/channels/${id}`);
}
} else if (idType === 'T') {
// Open thread
}
} else {
// Unknown trigger char, just log
console.log('Clicked mention', id);
}
}}
>
{triggerChar}{label}
</span>
</LinkPreview.Trigger>
{#if triggerChar === '@' && idType === 'U'}
<UserStatusLinkPreview {id} />
{/if}
</LinkPreview.Root>

View file

@ -17,7 +17,7 @@
import AlertRenderer, { alertComponent } from './AlertRenderer.svelte'; import AlertRenderer, { alertComponent } from './AlertRenderer.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte'; import Collapsible from '$lib/components/common/Collapsible.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte'; import Download from '$lib/components/icons/Download.svelte';
import Source from './Source.svelte'; import Source from './Source.svelte';
import { settings } from '$lib/stores'; import { settings } from '$lib/stores';
@ -109,7 +109,7 @@
{save} {save}
{preview} {preview}
edit={editCodeBlock} edit={editCodeBlock}
stickyButtonsClassName={topPadding ? 'top-8' : 'top-0'} stickyButtonsClassName={topPadding ? 'top-7' : 'top-0'}
onSave={(value) => { onSave={(value) => {
onSave({ onSave({
raw: token.raw, raw: token.raw,
@ -124,19 +124,19 @@
{token.text} {token.text}
{/if} {/if}
{:else if token.type === 'table'} {:else if token.type === 'table'}
<div class="relative w-full group"> <div class="relative w-full group mb-2">
<div class="scrollbar-hidden relative overflow-x-auto max-w-full rounded-lg"> <div class="scrollbar-hidden relative overflow-x-auto max-w-full">
<table <table
class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl" class=" w-full text-sm text-left text-gray-500 dark:text-gray-400 max-w-full rounded-xl"
> >
<thead <thead
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 border-none" class="text-xs text-gray-700 uppercase bg-white dark:bg-gray-900 dark:text-gray-400 border-none"
> >
<tr class=""> <tr class="">
{#each token.header as header, headerIdx} {#each token.header as header, headerIdx}
<th <th
scope="col" scope="col"
class="px-3! py-1.5! cursor-pointer border border-gray-100 dark:border-gray-850" class="px-2.5! py-2! cursor-pointer border-b border-gray-100! dark:border-gray-800!"
style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`} style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
> >
<div class="gap-1.5 text-left"> <div class="gap-1.5 text-left">
@ -155,10 +155,14 @@
</thead> </thead>
<tbody> <tbody>
{#each token.rows as row, rowIdx} {#each token.rows as row, rowIdx}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs"> <tr class="bg-white dark:bg-gray-900 text-xs">
{#each row ?? [] as cell, cellIdx} {#each row ?? [] as cell, cellIdx}
<td <td
class="px-3! py-1.5! text-gray-900 dark:text-white w-max border border-gray-100 dark:border-gray-850" class="px-3! py-2! text-gray-900 dark:text-white w-max {token.rows.length -
1 ===
rowIdx
? ''
: 'border-b border-gray-50! dark:border-gray-850!'}"
style={token.align[cellIdx] ? `text-align: ${token.align[cellIdx]}` : ''} style={token.align[cellIdx] ? `text-align: ${token.align[cellIdx]}` : ''}
> >
<div class="break-normal"> <div class="break-normal">
@ -186,7 +190,7 @@
exportTableToCSVHandler(token, tokenIdx); exportTableToCSVHandler(token, tokenIdx);
}} }}
> >
<ArrowDownTray className=" size-3.5" strokeWidth="1.5" /> <Download className=" size-3.5" strokeWidth="1.5" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>

View file

@ -29,7 +29,7 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[190px] rounded-xl px-1 py-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg" class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-850 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="start" align="start"
@ -78,7 +78,7 @@
</div> </div>
<hr class="border-gray-50 dark:border-gray-800 my-1 mx-2" /> <hr class="border-gray-50 dark:border-gray-800 my-1 mx-2" />
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg" 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={() => { on:click={() => {
onRegenerate(); onRegenerate();
show = false; show = false;
@ -103,7 +103,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg" 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={() => { on:click={() => {
onRegenerate($i18n.t('Add Details')); onRegenerate($i18n.t('Add Details'));
}} }}
@ -113,7 +113,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg" 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={() => { on:click={() => {
onRegenerate($i18n.t('More Concise')); onRegenerate($i18n.t('More Concise'));
}} }}

View file

@ -2,13 +2,21 @@
export let size = 'md'; export let size = 'md';
</script> </script>
<span class="relative flex {size === 'md' ? 'size-3 my-2' : 'size-2 my-1'} mx-1"> <span
class="relative flex {size === 'md'
? 'size-3 my-2'
: size === 'xs'
? 'size-1.5 my-1'
: 'size-2 my-1'} mx-1"
>
<span <span
class="absolute inline-flex h-full w-full animate-pulse rounded-full bg-gray-700 dark:bg-gray-200 opacity-75" class="absolute inline-flex h-full w-full animate-pulse rounded-full bg-gray-700 dark:bg-gray-200 opacity-75"
></span> ></span>
<span <span
class="relative inline-flex {size === 'md' class="relative inline-flex {size === 'md'
? 'size-3' ? 'size-3'
: size === 'xs'
? 'size-1.5'
: 'size-2'} rounded-full bg-black dark:bg-white animate-size" : 'size-2'} rounded-full bg-black dark:bg-white animate-size"
></span> ></span>
</span> </span>

View file

@ -46,7 +46,7 @@
<button <button
aria-roledescription="model-item" aria-roledescription="model-item"
aria-label={item.label} aria-label={item.label}
class="flex group/item w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted {index === class="flex group/item w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted {index ===
selectedModelIdx selectedModelIdx
? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent' ? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent'
: ''}" : ''}"
@ -64,7 +64,7 @@
{#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag} {#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag}
<Tooltip content={tag.name} className="flex-shrink-0"> <Tooltip content={tag.name} className="flex-shrink-0">
<div <div
class=" text-xs font-bold px-1 rounded-sm uppercase bg-gray-500/20 text-gray-700 dark:text-gray-200" class=" text-xs font-semibold px-1 rounded-sm uppercase bg-gray-500/20 text-gray-700 dark:text-gray-200"
> >
{tag.name} {tag.name}
</div> </div>
@ -144,7 +144,7 @@
<div slot="tooltip" id="tags-{item.model.id}"> <div slot="tooltip" id="tags-{item.model.id}">
{#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag} {#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag}
<Tooltip content={tag.name} className="flex-shrink-0"> <Tooltip content={tag.name} className="flex-shrink-0">
<div class=" text-xs font-semibold rounded-sm uppercase text-white"> <div class=" text-xs font-medium rounded-sm uppercase text-white">
{tag.name} {tag.name}
</div> </div>
</Tooltip> </Tooltip>

View file

@ -44,7 +44,7 @@
<DropdownMenu.Content <DropdownMenu.Content
strategy="fixed" strategy="fixed"
class="w-full max-w-[180px] text-sm rounded-2xl px-1 py-1.5 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800" class="w-full max-w-[180px] text-sm rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
sideOffset={-2} sideOffset={-2}
side="bottom" side="bottom"
align="end" align="end"

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) {
@ -344,7 +350,7 @@
closeFocus={false} closeFocus={false}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger
class="relative w-full font-primary {($settings?.highContrastMode ?? false) class="relative w-full {($settings?.highContrastMode ?? false)
? '' ? ''
: 'outline-hidden focus:outline-hidden'}" : 'outline-hidden focus:outline-hidden'}"
aria-label={placeholder} aria-label={placeholder}
@ -376,14 +382,15 @@
<DropdownMenu.Content <DropdownMenu.Content
class=" z-40 {$mobile class=" z-40 {$mobile
? `w-full` ? `w-full`
: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden" : `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-2xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
transition={flyAndScale} transition={flyAndScale}
side={$mobile ? 'bottom' : 'bottom-start'} side={$mobile ? 'bottom' : 'bottom-start'}
sideOffset={3} sideOffset={2}
alignOffset={-1}
> >
<slot> <slot>
{#if searchEnabled} {#if searchEnabled}
<div class="flex items-center gap-2.5 px-4 mt-3.5 mb-1.5"> <div class="flex items-center gap-2.5 px-4.5 mt-3.5 mb-1.5">
<Search className="size-4" strokeWidth="2.5" /> <Search className="size-4" strokeWidth="2.5" />
<input <input
@ -419,7 +426,7 @@
<div class="px-2"> <div class="px-2">
{#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0} {#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
<div <div
class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none mb-0.5" class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none font-[450] mb-0.5"
on:wheel={(e) => { on:wheel={(e) => {
if (e.deltaY !== 0) { if (e.deltaY !== 0) {
e.preventDefault(); e.preventDefault();
@ -428,12 +435,12 @@
}} }}
> >
<div <div
class="flex gap-1 w-fit text-center text-sm font-medium rounded-full bg-transparent px-1.5 pb-0.5" class="flex gap-1 w-fit text-center text-sm rounded-full bg-transparent px-1.5"
bind:this={tagsContainerElement} bind:this={tagsContainerElement}
> >
{#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0} {#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
<button <button
class="min-w-fit outline-none px-1.5 {selectedTag === '' && class="min-w-fit outline-none px-1.5 py-0.5 {selectedTag === '' &&
selectedConnectionType === '' selectedConnectionType === ''
? '' ? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize" : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
@ -511,7 +518,7 @@
{/if} {/if}
</div> </div>
<div class="px-2 max-h-64 overflow-y-auto group relative"> <div class="px-2.5 max-h-64 overflow-y-auto group relative">
{#each filteredItems as item, index} {#each filteredItems as item, index}
<ModelItem <ModelItem
{selectedModelIdx} {selectedModelIdx}
@ -543,7 +550,7 @@
placement="top-start" placement="top-start"
> >
<button <button
class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted" class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-xl cursor-pointer data-highlighted:bg-muted"
on:click={() => { on:click={() => {
pullModelHandler(); pullModelHandler();
}} }}
@ -557,10 +564,10 @@
{#each Object.keys($MODEL_DOWNLOAD_POOL) as model} {#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
<div <div
class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-lg cursor-pointer data-highlighted:bg-muted" class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 rounded-xl cursor-pointer data-highlighted:bg-muted"
> >
<div class="flex"> <div class="flex">
<div class="-ml-2 mr-2.5 translate-y-0.5"> <div class="mr-2.5 translate-y-0.5">
<Spinner /> <Spinner />
</div> </div>
@ -617,7 +624,7 @@
{/each} {/each}
</div> </div>
<div class="mb-3"></div> <div class="mb-2.5"></div>
<div class="hidden w-[42rem]" /> <div class="hidden w-[42rem]" />
<div class="hidden w-[32rem]" /> <div class="hidden w-[32rem]" />

View file

@ -36,11 +36,11 @@
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import ChatPlus from '../icons/ChatPlus.svelte'; import ChatPlus from '../icons/ChatPlus.svelte';
import ChatCheck from '../icons/ChatCheck.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let initNewChat: Function; export let initNewChat: Function;
export let title: string = $WEBUI_NAME;
export let shareEnabled: boolean = false; export let shareEnabled: boolean = false;
export let chat; export let chat;
@ -72,7 +72,7 @@
<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region"> <nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
<div class="flex items-center w-full pl-1.5 pr-1"> <div class="flex items-center w-full pl-1.5 pr-1">
<div <div
class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]" class=" bg-linear-to-b via-40% to-97% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
></div> ></div>
<div class=" flex max-w-full w-full mx-auto px-1.5 md:px-2 pt-0.5 bg-transparent"> <div class=" flex max-w-full w-full mx-auto px-1.5 md:px-2 pt-0.5 bg-transparent">
@ -152,13 +152,31 @@
}} }}
> >
<div class=" m-auto self-center"> <div class=" m-auto self-center">
<ChatPlus className=" size-4.5" strokeWidth="1.5" /> <ChatCheck className=" size-4.5" strokeWidth="1.5" />
</div> </div>
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
{/if} {/if}
{#if $mobile && !$temporaryChatEnabled && chat && chat.id}
<Tooltip content={$i18n.t('New Chat')}>
<button
class=" flex {$showSidebar
? 'md:hidden'
: ''} cursor-pointer px-2 py-2 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
initNewChat();
}}
aria-label="New Chat"
>
<div class=" m-auto self-center">
<ChatPlus className=" size-4.5" strokeWidth="1.5" />
</div>
</button>
</Tooltip>
{/if}
{#if shareEnabled && chat && (chat.id || $temporaryChatEnabled)} {#if shareEnabled && chat && (chat.id || $temporaryChatEnabled)}
<Menu <Menu
{chat} {chat}
@ -198,24 +216,6 @@
</Tooltip> </Tooltip>
{/if} {/if}
{#if $mobile}
<Tooltip content={$i18n.t('New Chat')}>
<button
class=" flex {$showSidebar
? 'md:hidden'
: ''} cursor-pointer px-2 py-2 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
on:click={() => {
initNewChat();
}}
aria-label="New Chat"
>
<div class=" m-auto self-center">
<PencilSquare className=" size-5" strokeWidth="2" />
</div>
</button>
</Tooltip>
{/if}
{#if $user !== undefined && $user !== null} {#if $user !== undefined && $user !== null}
<UserMenu <UserMenu
className="max-w-[240px]" className="max-w-[240px]"
@ -255,7 +255,7 @@
<div class="absolute top-[100%] left-0 right-0 h-fit"> <div class="absolute top-[100%] left-0 right-0 h-fit">
{#if !history.currentId && !$chatId && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))} {#if !history.currentId && !$chatId && ($banners.length > 0 || ($config?.license_metadata?.type ?? null) === 'trial' || (($config?.license_metadata?.seats ?? null) !== null && $config?.user_count > $config?.license_metadata?.seats))}
<div class=" w-full z-30 mt-5"> <div class=" w-full z-30 mt-4">
<div class=" flex flex-col gap-1 w-full"> <div class=" flex flex-col gap-1 w-full">
{#if ($config?.license_metadata?.type ?? null) === 'trial'} {#if ($config?.license_metadata?.type ?? null) === 'trial'}
<Banner <Banner

View file

@ -77,7 +77,7 @@
className="w-full flex justify-center mb-0.5" className="w-full flex justify-center mb-0.5"
placement="top" placement="top"
> >
<div class="flex items-center gap-2 text-gray-500 font-medium text-base my-2 w-fit"> <div class="flex items-center gap-2 text-gray-500 text-base my-2 w-fit">
<EyeSlash strokeWidth="2.5" className="size-4" />{$i18n.t('Temporary Chat')} <EyeSlash strokeWidth="2.5" className="size-4" />{$i18n.t('Temporary Chat')}
</div> </div>
</Tooltip> </Tooltip>

View file

@ -32,7 +32,7 @@
let showFolderModal = false; let showFolderModal = false;
let showDeleteConfirm = false; let showDeleteConfirm = false;
const updateHandler = async ({ name, data }) => { const updateHandler = async ({ name, meta, data }) => {
if (name === '') { if (name === '') {
toast.error($i18n.t('Folder name cannot be empty.')); toast.error($i18n.t('Folder name cannot be empty.'));
return; return;
@ -45,6 +45,7 @@
const res = await updateFolderById(localStorage.token, folder.id, { const res = await updateFolderById(localStorage.token, folder.id, {
name, name,
...(meta ? { meta } : {}),
...(data ? { data } : {}) ...(data ? { data } : {})
}).catch((error) => { }).catch((error) => {
toast.error(`${error}`); toast.error(`${error}`);

View file

@ -6,7 +6,6 @@
import { getVoices as _getVoices } from '$lib/apis/audio'; import { getVoices as _getVoices } from '$lib/apis/audio';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import { round } from '@huggingface/transformers';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View file

@ -9,6 +9,7 @@
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import Switch from '$lib/components/common/Switch.svelte'; import Switch from '$lib/components/common/Switch.svelte';
import ManageFloatingActionButtonsModal from './Interface/ManageFloatingActionButtonsModal.svelte'; import ManageFloatingActionButtonsModal from './Interface/ManageFloatingActionButtonsModal.svelte';
import ManageImageCompressionModal from './Interface/ManageImageCompressionModal.svelte';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -93,6 +94,7 @@
let iframeSandboxAllowForms = false; let iframeSandboxAllowForms = false;
let showManageFloatingActionButtonsModal = false; let showManageFloatingActionButtonsModal = false;
let showManageImageCompressionModal = false;
const toggleLandingPageMode = async () => { const toggleLandingPageMode = async () => {
landingPageMode = landingPageMode === '' ? 'chat' : ''; landingPageMode = landingPageMode === '' ? 'chat' : '';
@ -260,6 +262,14 @@
}} }}
/> />
<ManageImageCompressionModal
bind:show={showManageImageCompressionModal}
size={imageCompressionSize}
onSave={(size) => {
saveSettings({ imageCompressionSize: size });
}}
/>
<form <form
id="tab-interface" id="tab-interface"
class="flex flex-col h-full justify-between space-y-3 text-sm" class="flex flex-col h-full justify-between space-y-3 text-sm"
@ -1154,7 +1164,20 @@
{$i18n.t('Image Compression')} {$i18n.t('Image Compression')}
</div> </div>
<div class="flex items-center gap-2 p-1"> <div class="flex items-center gap-3 p-1">
{#if imageCompression}
<button
class="text-xs text-gray-700 dark:text-gray-400 underline"
type="button"
aria-label={$i18n.t('Open Modal To Manage Image Compression')}
on:click={() => {
showManageImageCompressionModal = true;
}}
>
{$i18n.t('Manage')}
</button>
{/if}
<Switch <Switch
ariaLabelledbyId="image-compression-label" ariaLabelledbyId="image-compression-label"
tooltip={true} tooltip={true}
@ -1168,39 +1191,6 @@
</div> </div>
{#if imageCompression} {#if imageCompression}
<div>
<div class=" py-0.5 flex w-full justify-between text-xs">
<div id="image-compression-size-label" class=" self-center text-xs">
{$i18n.t('Image Max Compression Size')}
</div>
<div class="p-1">
<label class="sr-only" for="image-comp-width"
>{$i18n.t('Image Max Compression Size width')}</label
>
<input
bind:value={imageCompressionSize.width}
type="number"
aria-labelledby="image-comp-width"
class="w-20 bg-transparent outline-hidden text-center"
min="0"
placeholder={$i18n.t('Width')}
/>x
<label class="sr-only" for="image-comp-height"
>{$i18n.t('Image Max Compression Size height')}</label
>
<input
bind:value={imageCompressionSize.height}
type="number"
aria-labelledby="image-comp-height"
class="w-20 bg-transparent outline-hidden text-center"
min="0"
placeholder={$i18n.t('Height')}
/>
</div>
</div>
</div>
<div> <div>
<div class=" py-0.5 flex w-full justify-between"> <div class=" py-0.5 flex w-full justify-between">
<div id="image-compression-in-channels-label" class=" self-center text-xs"> <div id="image-compression-in-channels-label" class=" self-center text-xs">

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Minus from '$lib/components/icons/Minus.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let show = false;
export let size = null;
export let onSave = () => {};
const submitHandler = async () => {
onSave(size);
show = false;
};
onMount(() => {});
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-1.5">
<h1 class="text-lg font-medium self-center font-primary">
{$i18n.t('Manage')}
</h1>
<button
class="self-center"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full px-1"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div>
<div>
<div class=" py-0.5 flex flex-col w-full text-sm">
<div id="image-compression-size-label" class=" text-xs mb-2">
{$i18n.t('Image Max Compression Size')}
</div>
<div class="p-1 flex-1 flex gap-2">
<div class=" flex-1">
<label class="sr-only" for="image-comp-width"
>{$i18n.t('Image Max Compression Size width')}</label
>
<input
bind:value={size.width}
type="number"
aria-labelledby="image-comp-width"
class="w-full bg-transparent outline-hidden text-center"
min="0"
placeholder={$i18n.t('Width')}
/>
</div>
<div class="self-center text-gray-500 dark:text-gray-400">
<XMark />
</div>
<div class="flex-1">
<label class="sr-only" for="image-comp-height"
>{$i18n.t('Image Max Compression Size height')}</label
>
<input
bind:value={size.height}
type="number"
aria-labelledby="image-comp-height"
class="w-full bg-transparent outline-hidden text-center"
min="0"
placeholder={$i18n.t('Height')}
/>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View file

@ -12,13 +12,22 @@
import General from './Settings/General.svelte'; import General from './Settings/General.svelte';
import Interface from './Settings/Interface.svelte'; import Interface from './Settings/Interface.svelte';
import Audio from './Settings/Audio.svelte'; import Audio from './Settings/Audio.svelte';
import Chats from './Settings/Chats.svelte'; import DataControls from './Settings/DataControls.svelte';
import User from '../icons/User.svelte';
import Personalization from './Settings/Personalization.svelte'; import Personalization from './Settings/Personalization.svelte';
import Search from '../icons/Search.svelte'; import Search from '../icons/Search.svelte';
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import Connections from './Settings/Connections.svelte'; import Connections from './Settings/Connections.svelte';
import Tools from './Settings/Tools.svelte'; import Tools from './Settings/Tools.svelte';
import DatabaseSettings from '../icons/DatabaseSettings.svelte';
import SettingsAlt from '../icons/SettingsAlt.svelte';
import Link from '../icons/Link.svelte';
import UserCircle from '../icons/UserCircle.svelte';
import SoundHigh from '../icons/SoundHigh.svelte';
import InfoCircle from '../icons/InfoCircle.svelte';
import WrenchAlt from '../icons/WrenchAlt.svelte';
import Face from '../icons/Face.svelte';
import AppNotification from '../icons/AppNotification.svelte';
import UserBadgeCheck from '../icons/UserBadgeCheck.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -323,8 +332,8 @@
] ]
}, },
{ {
id: 'chats', id: 'data_controls',
title: 'Chats', title: 'Data Controls',
keywords: [ keywords: [
'archive all chats', 'archive all chats',
'archive chats', 'archive chats',
@ -560,9 +569,9 @@
}); });
</script> </script>
<Modal size="lg" bind:show> <Modal size="xl" bind:show>
<div class="text-gray-700 dark:text-gray-100"> <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')}
@ -575,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 font-medium 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-full px-2.5 gap-2 bg-gray-50 dark:bg-gray-850 my-1 mb-1.5"
id="settings-search"
> >
<div class="hidden md:flex w-full rounded-xl -mb-1 px-0.5 gap-2" 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"
@ -590,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"
@ -605,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)
@ -620,19 +632,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <SettingsAlt strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8.34 1.804A1 1 0 019.32 1h1.36a1 1 0 01.98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 011.262.125l.962.962a1 1 0 01.125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.294a1 1 0 01.804.98v1.361a1 1 0 01-.804.98l-1.473.295a6.95 6.95 0 01-.587 1.416l.834 1.25a1 1 0 01-.125 1.262l-.962.962a1 1 0 01-1.262.125l-1.25-.834a6.953 6.953 0 01-1.416.587l-.294 1.473a1 1 0 01-.98.804H9.32a1 1 0 01-.98-.804l-.295-1.473a6.957 6.957 0 01-1.416-.587l-1.25.834a1 1 0 01-1.262-.125l-.962-.962a1 1 0 01-.125-1.262l.834-1.25a6.957 6.957 0 01-.587-1.416l-1.473-.294A1 1 0 011 10.68V9.32a1 1 0 01.804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 01.125-1.262l.962-.962A1 1 0 015.38 3.03l1.25.834a6.957 6.957 0 011.416-.587l.294-1.473zM13 10a3 3 0 11-6 0 3 3 0 016 0z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('General')}</div> <div class=" self-center">{$i18n.t('General')}</div>
</button> </button>
@ -641,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)
@ -656,19 +656,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <AppNotification strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M2 4.25A2.25 2.25 0 0 1 4.25 2h7.5A2.25 2.25 0 0 1 14 4.25v5.5A2.25 2.25 0 0 1 11.75 12h-1.312c.1.128.21.248.328.36a.75.75 0 0 1 .234.545v.345a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1-.75-.75v-.345a.75.75 0 0 1 .234-.545c.118-.111.228-.232.328-.36H4.25A2.25 2.25 0 0 1 2 9.75v-5.5Zm2.25-.75a.75.75 0 0 0-.75.75v4.5c0 .414.336.75.75.75h7.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-7.5Z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('Interface')}</div> <div class=" self-center">{$i18n.t('Interface')}</div>
</button> </button>
@ -678,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)
@ -693,17 +681,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <Link strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M1 9.5A3.5 3.5 0 0 0 4.5 13H12a3 3 0 0 0 .917-5.857 2.503 2.503 0 0 0-3.198-3.019 3.5 3.5 0 0 0-6.628 2.171A3.5 3.5 0 0 0 1 9.5Z"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('Connections')}</div> <div class=" self-center">{$i18n.t('Connections')}</div>
</button> </button>
@ -714,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)
@ -729,19 +707,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <WrenchAlt strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('External Tools')}</div> <div class=" self-center">{$i18n.t('External Tools')}</div>
</button> </button>
@ -751,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)
@ -766,7 +732,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<User /> <Face strokeWidth="2" />
</div> </div>
<div class=" self-center">{$i18n.t('Personalization')}</div> <div class=" self-center">{$i18n.t('Personalization')}</div>
</button> </button>
@ -775,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)
@ -790,31 +756,18 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <SoundHigh strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M7.557 2.066A.75.75 0 0 1 8 2.75v10.5a.75.75 0 0 1-1.248.56L3.59 11H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.59l3.162-2.81a.75.75 0 0 1 .805-.124ZM12.95 3.05a.75.75 0 1 0-1.06 1.06 5.5 5.5 0 0 1 0 7.78.75.75 0 1 0 1.06 1.06 7 7 0 0 0 0-9.9Z"
/>
<path
d="M10.828 5.172a.75.75 0 1 0-1.06 1.06 2.5 2.5 0 0 1 0 3.536.75.75 0 1 0 1.06 1.06 4 4 0 0 0 0-5.656Z"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('Audio')}</div> <div class=" self-center">{$i18n.t('Audio')}</div>
</button> </button>
{:else if tabId === 'chats'} {:else if tabId === 'data_controls'}
<button <button
role="tab" role="tab"
aria-controls="tab-chats" aria-controls="tab-data-controls"
aria-selected={selectedTab === 'chats'} 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 === 'chats' selectedTab === 'data_controls'
? ($settings?.highContrastMode ?? false) ? ($settings?.highContrastMode ?? false)
? 'dark:bg-gray-800 bg-gray-200' ? 'dark:bg-gray-800 bg-gray-200'
: '' : ''
@ -823,32 +776,20 @@
: '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={() => { on:click={() => {
selectedTab = 'chats'; selectedTab = 'data_controls';
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <DatabaseSettings strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M8 2C4.262 2 1 4.57 1 8c0 1.86.98 3.486 2.455 4.566a3.472 3.472 0 0 1-.469 1.26.75.75 0 0 0 .713 1.14 6.961 6.961 0 0 0 3.06-1.06c.403.062.818.094 1.241.094 3.738 0 7-2.57 7-6s-3.262-6-7-6ZM5 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Zm7-1a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM8 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('Chats')}</div> <div class=" self-center">{$i18n.t('Data Controls')}</div>
</button> </button>
{:else if tabId === 'account'} {:else if tabId === 'account'}
<button <button
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)
@ -863,19 +804,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <UserCircle strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0Zm-5-2a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8 9c-1.825 0-3.422.977-4.295 2.437A5.49 5.49 0 0 0 8 13.5a5.49 5.49 0 0 0 4.294-2.063A4.997 4.997 0 0 0 8 9Z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('Account')}</div> <div class=" self-center">{$i18n.t('Account')}</div>
</button> </button>
@ -884,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)
@ -899,19 +828,7 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <InfoCircle strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('About')}</div> <div class=" self-center">{$i18n.t('About')}</div>
</button> </button>
@ -925,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) => {
@ -935,25 +852,13 @@
}} }}
> >
<div class=" self-center mr-2"> <div class=" self-center mr-2">
<svg <UserBadgeCheck strokeWidth="2" />
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M4.5 3.75a3 3 0 0 0-3 3v10.5a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3V6.75a3 3 0 0 0-3-3h-15Zm4.125 3a2.25 2.25 0 1 0 0 4.5 2.25 2.25 0 0 0 0-4.5Zm-3.873 8.703a4.126 4.126 0 0 1 7.746 0 .75.75 0 0 1-.351.92 7.47 7.47 0 0 1-3.522.877 7.47 7.47 0 0 1-3.522-.877.75.75 0 0 1-.351-.92ZM15 8.25a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 0-1.5H15ZM14.25 12a.75.75 0 0 1 .75-.75h3.75a.75.75 0 0 1 0 1.5H15a.75.75 0 0 1-.75-.75Zm.75 2.25a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 0-1.5H15Z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
<div class=" self-center">{$i18n.t('Admin Settings')}</div> <div class=" self-center">{$i18n.t('Admin Settings')}</div>
</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}
@ -997,8 +902,8 @@
toast.success($i18n.t('Settings saved successfully!')); toast.success($i18n.t('Settings saved successfully!'));
}} }}
/> />
{:else if selectedTab === 'chats'} {:else if selectedTab === 'data_controls'}
<Chats {saveSettings} /> <DataControls {saveSettings} />
{:else if selectedTab === 'account'} {:else if selectedTab === 'account'}
<Account <Account
{saveSettings} {saveSettings}

View file

@ -12,7 +12,7 @@
</script> </script>
<div <div
class=" text-xs font-bold {classNames[type] ?? class=" text-xs font-semibold {classNames[type] ??
classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5" classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5"
> >
{content} {content}

View file

@ -52,7 +52,7 @@
<div class=" flex flex-col md:flex-row md:items-center flex-1 text-sm w-fit gap-1.5"> <div class=" flex flex-col md:flex-row md:items-center flex-1 text-sm w-fit gap-1.5">
<div class="flex justify-between self-start"> <div class="flex justify-between self-start">
<div <div
class=" text-xs font-bold {classNames[banner.type] ?? class=" text-xs font-semibold {classNames[banner.type] ??
classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5" classNames['info']} w-fit px-2 rounded-sm uppercase line-clamp-1 mr-0.5"
> >
{#if banner.type.toLowerCase() === 'info'} {#if banner.type.toLowerCase() === 'info'}

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,6 +89,125 @@
</script> </script>
<div {id} class={className}> <div {id} class={className}>
{#if attributes?.type === 'tool_calls'}
{@const args = decode(attributes?.arguments)}
{@const result = decode(attributes?.result ?? '')}
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
{@const embeds = parseJSONString(decode(attributes?.embeds ?? ''))}
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
<div class="py-1 w-full cursor-pointer">
<div class=" w-full text-xs text-gray-500">
<div class="">
{attributes.name}
</div>
</div>
{#each embeds as embed, idx}
<div class="my-2" id={`${collapsibleId}-tool-calls-${attributes?.id}-embed-${idx}`}>
<FullHeightIframe
src={embed}
{args}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{:else}
<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?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/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>
{#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(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
{/if}
{:else}
<slot name="content" />
{/if}
</div>
{/if}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{/each}
{/if}
{/if}
{/if}
{/if}
{:else}
{#if title !== null} {#if title !== null}
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
@ -134,22 +255,6 @@
{:else} {:else}
{$i18n.t('Analyzing...')} {$i18n.t('Analyzing...')}
{/if} {/if}
{:else if attributes?.type === 'tool_calls'}
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/if}
{:else} {:else}
{title} {title}
{/if} {/if}
@ -209,56 +314,12 @@
</div> </div>
{/if} {/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 !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(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
{/if}
{:else}
<slot name="content" />
{/if}
</div>
{/if}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{/each}
{/if}
{/if}
{/if}
{:else if !grow}
{#if open && !hide} {#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}> <div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
<slot name="content" /> <slot name="content" />
</div> </div>
{/if} {/if}
{/if} {/if}
{/if}
</div> </div>

View file

@ -26,7 +26,7 @@
<slot name="content"> <slot name="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-[130px] rounded-lg px-1 py-1.5 border border-gray-900 z-50 bg-gray-850 text-white" class="w-full max-w-[130px] rounded-lg p-1 border border-gray-900 z-50 bg-gray-850 text-white"
sideOffset={8} sideOffset={8}
{side} {side}
{align} {align}

View file

@ -236,7 +236,7 @@
/> />
{:else} {:else}
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"> <div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
{item?.file?.data?.content ?? 'No content'} {(item?.file?.data?.content ?? '').trim() || 'No content'}
</div> </div>
{/if} {/if}
{:else} {:else}
@ -251,7 +251,7 @@
{#if item?.file?.data} {#if item?.file?.data}
<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap"> <div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
{item?.file?.data?.content ?? 'No content'} {(item?.file?.data?.content ?? '').trim() || 'No content'}
</div> </div>
{/if} {/if}
{/if} {/if}

View file

@ -16,14 +16,15 @@
export let name = ''; export let name = '';
export let collapsible = true; export let collapsible = true;
export let className = '';
export let buttonClassName = 'text-gray-600 dark:text-gray-400';
export let chevron = true; export let chevron = true;
export let onAddLabel: string = ''; export let onAddLabel: string = '';
export let onAdd: null | Function = null; export let onAdd: null | Function = null;
export let dragAndDrop = true; export let dragAndDrop = true;
export let className = '';
let folderElement; let folderElement;
let draggedOver = false; let draggedOver = false;
@ -138,20 +139,20 @@
> >
<!-- 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 text-gray-500 dark:text-gray-500 transition" 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}
<div class="text-gray-300 dark:text-gray-600 p-[1px]"> <div class=" p-[1px]">
{#if open} {#if open}
<ChevronDown className=" size-3.5" strokeWidth="2.5" /> <ChevronDown className=" size-3" strokeWidth="2" />
{:else} {:else}
<ChevronRight className=" size-3.5" strokeWidth="2.5" /> <ChevronRight className=" size-3" strokeWidth="2" />
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="translate-y-[0.5px]"> <div class="translate-y-[0.5px] {chevron ? '' : 'pl-0.5'}">
{name} {name}
</div> </div>
</button> </button>

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

@ -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-2xl'; export let className = 'bg-white dark:bg-gray-900 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();

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